From 5ecf33aab1e493ca355a34f1ba0bcaaef5541b27 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 12:03:36 +0800 Subject: [PATCH 01/37] refactor(session-projection): acquire retained readers during activation --- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 12 +- packages/core/agent-loop/src/inbox.ts | 28 ++--- packages/core/agent-loop/src/index.ts | 108 +++++----------- packages/core/agent-loop/tests/inbox.spec.ts | 33 ++--- packages/core/agent-loop/tests/loop.spec.ts | 6 +- .../agent-loop/tests/request-freeze.spec.ts | 11 +- .../tests/resource-lifecycle.spec.ts | 8 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 12 +- packages/core/agent/package.json | 3 + packages/core/agent/src/index.ts | 2 +- packages/core/agent/src/projection.ts | 56 ++++++++- .../tests/cache.spec.ts | 18 +-- .../tests/fixtures.spec.ts | 4 +- .../session-projection/README.i18n.yaml | 8 +- packages/session/session-projection/README.md | 7 +- .../session/session-projection/README.zh.md | 7 +- .../session/session-projection/src/index.ts | 99 ++++++++------- .../tests/registration-reader.spec.ts | 113 +++++++++++++++++ .../session-projection/tests/registry.spec.ts | 115 +++++++++--------- 22 files changed, 388 insertions(+), 270 deletions(-) create mode 100644 packages/session/session-projection/tests/registration-reader.spec.ts diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 0bbdb05d37..ab692943de 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -39,8 +39,8 @@ en: 2a8b93869eb5a2f9 zh: ddab8e738d188a61 /deepseek-ai-dsh-agent-loop/understand-the-implementation/creation-and-teardown: - en: d079cf181ece9267 - zh: 047f76fecfc5497c + en: 79aa89d1db536612 + zh: a86635bf4aa54b59 /deepseek-ai-dsh-agent-loop/understand-the-implementation/persistence-integration: en: 3c5721bc00a0bbf4 zh: 711f832343ffbb75 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 62dcd35a92..7308c64c73 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -109,7 +109,7 @@ The loop deep-freezes each derived message identity on its first request and reu Creation is one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created`; await serial `agent/created` listeners; then release queued input. A caller creating a runtime child sets `options.parentAgent`; the caller Context separately owns the transaction and live handle. Setup, commit, or listener failure and owner disposal roll back the prepared resources. Announcements already delivered remain observable and receive matching disposal notifications. Teardown stops and drains the driver, unwinds the scope, closes the session's write path, detaches the agent, then detaches the session. Every detach binds the exact entered object, so a stale disposer cannot remove a later same-id replacement. -Factory unload retains the inbox and turn-boundary projections through scope cleanup, including cancellation from scoped abort listeners, until every Agent teardown and startup task settles. Cleanup failures do not release those projections early. Caller unload, factory unload, and handle disposal await the same teardown. +The loop awaits inbox and turn-boundary registration before publishing its factory and passes those handles into synchronous Agent construction. During factory unload, global registrations may disappear before teardown finishes; retained handles remain readable through scope cleanup, including cancellation from scoped abort listeners. Caller unload, factory unload, and handle disposal await the same teardown. ### Persistence integration diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index ee355a328d..714f3f3839 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -109,7 +109,7 @@ const handle = await ctx.agents.create({ 创建是同一个受回滚保护的事务:构造私有会话、具象 agent 与带作用域上下文;等待可选 setup;进入两个注册表;宣告 `session/created`;等待串行 `agent/created` 监听器;随后释放已排队输入。创建运行时子 Agent 的调用方设置 `options.parentAgent`;调用方 Context 则单独拥有事务和存活句柄。Setup、commit、监听器失败或所有者 dispose 都会回滚已准备的资源。已送达的宣告仍可被观察,并有配对的销毁通知。Teardown 停止并排空驱动器、撤销作用域、关闭会话写路径、detach agent,再 detach 会话。每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。 -工厂卸载时在整个作用域清理期间保留 inbox 投影与轮次边界投影,包括作用域内 abort 监听器发起取消的时刻,直到所有 Agent teardown 和启动任务结束。清理失败不会使这些投影提前释放。调用方卸载、工厂卸载和句柄释放等待同一次 teardown。 +loop 在发布工厂前等待 inbox 与轮次边界注册完成,并将句柄传入 Agent 的同步构造过程。工厂卸载时,全局注册可以先于 teardown 完成而消失;保留句柄在整个作用域清理期间仍可读取,包括作用域内 abort 监听器发起取消的时刻。调用方卸载、工厂卸载和句柄释放等待同一次 teardown。 ### 持久化集成 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index adf2e33ddc..65797b988f 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -12,6 +12,8 @@ import type { AgentStatus, CancelOptions, InboxTarget, + InboxState, + TurnBoundaryProjection, PreStepDecision, RequestErrorAction, } from '@deepseek-ai/dsh-agent' @@ -31,7 +33,7 @@ import type { EpochHeader, RequestContext, Session, SessionId, SessionSeq, TurnE import { canonicalHeader, headerEquals, ToolCallRecovery } from '@deepseek-ai/dsh-session' import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { Context } from '@deepseek-ai/cordis' import { ReactLoopInbox } from './inbox.ts' import { RuntimeContextProjection } from './runtime-context.ts' @@ -124,15 +126,15 @@ export class ReactLoopAgent implements Agent { public readonly id: SessionId, public readonly options: AgentOptions, public readonly session: Session, + inbox: ProjectionHandle, + boundary: ProjectionHandle, ) { this.requestSurfaceGeneration = session.surface.contentGeneration this.dispatch = agentEvents(loopCtx, this) this.scope = createScope(loopCtx, this) this.ctx = this.scope.ctx - this.inbox = new ReactLoopInbox(this.ctx.sessionProjections, session, this.dispatch) - /* v8 ignore next -- the loop registers its own turnBoundary unit, so the key is always present */ - const lastTurn = this.loopCtx.sessionProjections.stateOf(session, 'turnBoundary')?.lastTurn ?? 0 - this.phase = { kind: 'idle', lastTurn } + this.inbox = new ReactLoopInbox(() => inbox.stateOf(session), session, this.dispatch) + this.phase = { kind: 'idle', lastTurn: boundary.stateOf(session).lastTurn } this.runtimeContext = new RuntimeContextProjection(this.ctx, session) this.systemPrompt = new SystemPromptProjection(session) } diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index ef00887a54..affe0c6b2f 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -6,7 +6,6 @@ import type { MessageId } from '@deepseek-ai/dsh-llm' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' -import type SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session' import type { AgentEventDispatch, @@ -67,30 +66,30 @@ export const inboxProjectionDefinition = { /** * Driver-owned durable Inbox implementation used by ReactLoopAgent and focused * provider tests. - * @param projections - registry with the standard Inbox projection registered by AgentLoop. + * @param readState - reader for the live Inbox projection state. * @param session - session whose durable events store pending input. * @param dispatch - agent-scoped notifications for Inbox lifecycle events. */ export class ReactLoopInbox implements InboxContract { constructor( - private readonly projections: SessionProjectionRegistry, + private readonly readState: () => InboxState, private readonly session: Session, private readonly dispatch: AgentEventDispatch, ) {} /** Prompts awaiting individual turns. */ get nextTurn(): readonly UserMessage[] { - return this.current()['next-turn'] + return this.readState()['next-turn'] } /** Input awaiting the next step boundary. */ get nextStep(): readonly UserMessage[] { - return this.current()['next-step'] + return this.readState()['next-step'] } /** Whether either pending-message list contains work. */ get hasPending(): boolean { - const state = this.current() + const state = this.readState() return state['next-turn'].length > 0 || state['next-step'].length > 0 } @@ -119,7 +118,7 @@ export class ReactLoopInbox implements InboxContract { * @param message - message to append. */ append(target: InboxTarget, message: UserMessage): void { - this.splice(target, this.current()[target].length, 0, [message]) + this.splice(target, this.readState()[target].length, 0, [message]) } /** @@ -175,7 +174,7 @@ export class ReactLoopInbox implements InboxContract { /** Locate one pending identity across both owned lists. */ private locate(messageId: MessageId): { target: InboxTarget; index: number } | undefined { - const state = this.current() + const state = this.readState() for (const target of ['next-turn', 'next-step'] as const) { const index = state[target].findIndex(message => message.id === messageId) if (index >= 0) return { target, index } @@ -183,17 +182,6 @@ export class ReactLoopInbox implements InboxContract { return undefined } - /** Read the current durable projection state. */ - private current(): InboxState { - const state = this.projections.stateOf(this.session, 'inbox') - if (state === undefined) { - throw new Error( - `agent "${this.session.id}" cannot read inbox state: its projection registration is not active`, - ) - } - return state - } - /** Commit one normalized mutation and publish its live events. */ private mutate( target: InboxTarget, @@ -202,7 +190,7 @@ export class ReactLoopInbox implements InboxContract { inserted: UserMessage[], discardRemoved: boolean, ): UserMessage[] { - const state = this.current() + const state = this.readState() const inbox = state[target] const truncatedStart = Math.trunc(start) const offset = Number.isNaN(truncatedStart) ? 0 : truncatedStart diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index ca4532af8c..60d4e2e8c5 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -9,26 +9,26 @@ import type { Volatile } from '@deepseek-ai/cosmokit' import { Context, FiberState, Service } from '@deepseek-ai/cordis' import { randomUUID } from 'node:crypto' import z from '@deepseek-ai/schemastery' -import { z as zod } from 'zod' import { brandString } from '@deepseek-ai/dsh-brand' -import type { - Agent, - AgentFactory, - AgentHandle, - AgentOptions, - AgentSetup, - CreateAgentOptions, - ResumeAgentOptions, - SessionStartSource, - TurnBoundaryProjection, +import { + turnBoundaryProjectionDefinition, + type Agent, + type AgentFactory, + type AgentHandle, + type AgentOptions, + type AgentSetup, + type CreateAgentOptions, + type ResumeAgentOptions, + type SessionStartSource, + type InboxState, + type TurnBoundaryProjection, } from '@deepseek-ai/dsh-agent' import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import { interruptedTurnClosers, SessionLogOffset, SessionPreparation, SessionSeq } from '@deepseek-ai/dsh-session' +import { interruptedTurnClosers, SessionLogOffset, SessionPreparation } from '@deepseek-ai/dsh-session' import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-session-projection' -import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import { SessionPersistenceNotFoundError } from '@deepseek-ai/dsh-session-persistence' import type { SessionHandle, SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { ReactLoopAgent } from './agent.ts' @@ -36,6 +36,8 @@ import { inboxProjectionDefinition } from './inbox.ts' import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts' import type {} from './runtime-context.ts' +export { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' + /** Fiber states that cannot own or serve a new lifecycle. */ const INACTIVE_STATES: ReadonlySet = new Set([ FiberState.UNLOADING, @@ -43,57 +45,6 @@ const INACTIVE_STATES: ReadonlySet = new Set([ FiberState.FAILED, ]) -const turnBoundaryProjectionSchema: zod.ZodType = zod.object({ - openTurnStartSeq: zod.number().int().nonnegative().transform(SessionSeq).nullable(), - lastStepStartSeq: zod.number().int().nonnegative().transform(SessionSeq).nullable(), - lastStepBoundary: zod.object({ - kind: zod.union([zod.literal('start'), zod.literal('end')]), - seq: zod.number().int().nonnegative().transform(SessionSeq), - }).nullable(), - lastTurn: zod.number().int().nonnegative(), -}) - -/** Host projection of agent turn and step boundaries. */ -export const turnBoundaryProjectionDefinition = { - key: 'turnBoundary', - stateVersion: 2, - stateSchema: turnBoundaryProjectionSchema, - init: () => ({ - openTurnStartSeq: null, - lastStepStartSeq: null, - lastStepBoundary: null, - lastTurn: 0, - }), - apply: (state, event) => { - switch (event.type) { - case 'turn/start': - return { - ...state, - openTurnStartSeq: event.seq, - lastTurn: event.data.turn, - } - case 'turn/end': - return { - ...state, - openTurnStartSeq: null, - } - case 'step/start': - return { - ...state, - lastStepStartSeq: event.seq, - lastStepBoundary: { kind: 'start', seq: event.seq }, - } - case 'step/end': - return { - ...state, - lastStepBoundary: { kind: 'end', seq: event.seq }, - } - default: - return state - } - }, -} satisfies ProjectionDefinition<'turnBoundary', TurnBoundaryProjection> - /** Re-throw one collected failure as itself, or several as one `AggregateError`. */ function throwCollectedFailures(failures: readonly unknown[], message: string): void { if (failures.length === 1) throw failures[0] @@ -361,6 +312,8 @@ export class AgentLoop extends Service implements AgentFactory { private readonly ownership: FactoryOwnership /** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */ private readonly runtime: { ctx: Context } + private inbox!: ProjectionHandle + private turnBoundary!: ProjectionHandle constructor(ctx: Context, config: Config) { super(ctx, 'agentLoop') @@ -372,19 +325,17 @@ export class AgentLoop extends Service implements AgentFactory { validateConfiguredAgents(this.config.agents) // Register only after every config validation above has passed, so a // rejected constructor leaves no projection unit behind. - const ownership = this.ownership = new FactoryOwnership(ctx.fiber) + this.ownership = new FactoryOwnership(ctx.fiber) this.runtime = { ctx } - ctx.effect(function* () { - const unregisterTurnBoundary = ctx.sessionProjections.register(turnBoundaryProjectionDefinition) - yield unregisterTurnBoundary - const unregisterInbox = ctx.sessionProjections.register(inboxProjectionDefinition) - yield unregisterInbox - // Cordis skips later disposers after a rejection; finally still releases projections. - yield () => ownership.dispose().finally(() => { - unregisterInbox() - unregisterTurnBoundary() - }) - }, 'agentLoop.transactions()') + } + + protected async [Service.init](): Promise { + const ctx = this.runtime.ctx + const ownership = this.ownership + // Agents retain readers while fiber disposal removes global projection registrations. + ctx.effect(() => () => ownership.dispose(), 'agentLoop.transactions()') + this.turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + this.inbox = await ctx.sessionProjections.register(inboxProjectionDefinition) ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()') ctx.systemPrompt.variable('provider', context => context.agent?.options.provider) ctx.systemPrompt.variable('model', context => context.agent?.options.model) @@ -599,9 +550,10 @@ export class AgentLoop extends Service implements AgentFactory { } } const untrack = this.ownership.track(dispose) + const { inbox, turnBoundary } = this try { unfollowOwner = ownerCtx.effect(function* () { - const created = machine = new ReactLoopAgent(loopCtx, id, options, session) + const created = machine = new ReactLoopAgent(loopCtx, id, options, session, inbox, turnBoundary) machineReady.resolve() // Adopt the scope on its original fiber so it cannot unload before teardown. // A loop already unloading during minting owns the provisional scope itself. diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 739d18b990..ba908715e0 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -49,10 +49,10 @@ async function inboxAgent(rawId: string): Promise<{ onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(inboxProjectionDefinition) + const projection = await ctx.sessionProjections.register(inboxProjectionDefinition) const session = ctx.sessions.create(SessionId(rawId)) const agent = stubAgent(rawId, { ctx, session }) - const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent)) + const inbox = new ReactLoopInbox(() => projection.stateOf(session), session, agentEvents(ctx, agent)) Object.assign(agent, { inbox }) return { ctx, session, agent, inbox } } @@ -67,9 +67,9 @@ async function reconstructPersistedInbox( const session = ctx.sessions.create(SessionId(rawId)) populate(session) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(inboxProjectionDefinition) + const projection = await ctx.sessionProjections.register(inboxProjectionDefinition) const agent = stubAgent(rawId, { ctx, session }) - const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent)) + const inbox = new ReactLoopInbox(() => projection.stateOf(session), session, agentEvents(ctx, agent)) try { void inbox.nextTurn } catch (error: unknown) { @@ -80,12 +80,12 @@ async function reconstructPersistedInbox( } describe('ReactLoopInbox', () => { - it('reads the shared projection without owning its registration', async () => { + it('retains inbox reads after the shared registration is released', async () => { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - const unregister = ctx.sessionProjections.register(inboxProjectionDefinition) + const projection = await ctx.sessionProjections.register(inboxProjectionDefinition) const session = ctx.sessions.create(SessionId('inbox-projection')) const pending = createUserMessage({ content: [{ type: 'text', text: 'pending' }], @@ -96,8 +96,8 @@ describe('ReactLoopInbox', () => { }) const agent = stubAgent('inbox-projection', { ctx, session }) const dispatch = agentEvents(ctx, agent) - const first = new ReactLoopInbox(ctx.sessionProjections, session, dispatch) - const second = new ReactLoopInbox(ctx.sessionProjections, session, dispatch) + const first = new ReactLoopInbox(() => projection.stateOf(session), session, dispatch) + const second = new ReactLoopInbox(() => projection.stateOf(session), session, dispatch) expect(first.nextTurn).toEqual([pending]) expect(second.nextTurn).toEqual([pending]) @@ -106,9 +106,12 @@ describe('ReactLoopInbox', () => { 'next-step': [], }) - unregister() - expect(ctx.sessionProjections.stateOf(session, 'inbox')).toBeUndefined() - expect(() => first.nextTurn).toThrow('its projection registration is not active') + await projection.dispose() + expect(ctx.sessionProjections.checkpoint(session).inbox?.val).toBeUndefined() + expect(first.nextTurn).toEqual([pending]) + first.clear() + expect(second.nextTurn).toEqual([]) + expect(ctx.sessionProjections.snapshot(session).values.inbox).toBeUndefined() }) it('rejects invalid durable coordinates and duplicate identities during reconstruction', async () => { @@ -141,10 +144,10 @@ describe('ReactLoopInbox', () => { onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(inboxProjectionDefinition) + const projection = await ctx.sessionProjections.register(inboxProjectionDefinition) const parent = ctx.sessions.create(SessionId('inbox-fork-parent')) const parentAgent = stubAgent('inbox-fork-parent', { ctx, session: parent }) - const parentInbox = new ReactLoopInbox(ctx.sessionProjections, parent, agentEvents(ctx, parentAgent)) + const parentInbox = new ReactLoopInbox(() => projection.stateOf(parent), parent, agentEvents(ctx, parentAgent)) const inherited = createUserMessage({ content: [{ type: 'text', text: 'parent pending' }], source: { kind: 'user' }, @@ -152,7 +155,7 @@ describe('ReactLoopInbox', () => { parentInbox.append('next-turn', inherited) const child = ctx.sessions.fork(parent, undefined, SessionId('inbox-fork-child')) const childAgent = stubAgent('inbox-fork-child', { ctx, session: child }) - const childInbox = new ReactLoopInbox(ctx.sessionProjections, child, agentEvents(ctx, childAgent)) + const childInbox = new ReactLoopInbox(() => projection.stateOf(child), child, agentEvents(ctx, childAgent)) expect(child.inheritedEventCount).toBe(parent.snapshotEvents().length) expect(childInbox.nextTurn).toEqual([inherited]) @@ -175,7 +178,7 @@ describe('ReactLoopInbox', () => { let observed: readonly UserMessage[] | undefined ctx.on('session/event', (subject, event) => { if (subject === session && event.type === 'agent/inbox/spliced') { - observed = ctx.sessionProjections.stateOf(session, 'inbox')?.['next-turn'] + observed = inbox.nextTurn } }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 9ae652df61..0dccc2a43b 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, ToolCallId, LlmError, ReasoningEffortId, StreamChunk, expandAssistantStream } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' @@ -1912,6 +1912,7 @@ describe('agent loop', () => { defaultEffort: effort, }) const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) @@ -1923,6 +1924,7 @@ describe('agent loop', () => { }) ctx.llm.registerAdapter(['mock'], adapter) + await vi.waitFor(() => { expect(ctx.agents.list()).toHaveLength(1) }) const agent = ctx.agents.list()[0]! expect(agent).toBeDefined() expect(agent.id).toBe(agent.session.id) @@ -1940,6 +1942,7 @@ describe('agent loop', () => { it('attaches config agent cwd to the fresh session header', async () => { const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) @@ -1950,6 +1953,7 @@ describe('agent loop', () => { agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock', cwd: '/work/project' }], }) + await vi.waitFor(() => { expect(ctx.agents.list()).toHaveLength(1) }) const agent = ctx.agents.list()[0]! expect(agent.session.header.cwd).toBe('/work/project') }) diff --git a/packages/core/agent-loop/tests/request-freeze.spec.ts b/packages/core/agent-loop/tests/request-freeze.spec.ts index 560be88627..ccb4c89770 100644 --- a/packages/core/agent-loop/tests/request-freeze.spec.ts +++ b/packages/core/agent-loop/tests/request-freeze.spec.ts @@ -3,12 +3,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { turnBoundaryProjectionDefinition, type Agent } from '@deepseek-ai/dsh-agent' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { createAssistantMessage, createUserMessage, isAgentLoopRequest } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, ToolSchema } from '@deepseek-ai/dsh-llm' import { Session, SessionId, SessionLogOffset, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import * as values from '@deepseek-ai/dsh-util-values' +import { inboxProjectionDefinition } from '../src/inbox.ts' import { ReactLoopAgent } from '../src/agent.ts' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -73,7 +74,9 @@ describe('loop-owned request freezing', () => { expect(Object.isFrozen(userEvent.data.content)).toBe(false) expect(Object.isFrozen(assistantEvent.data.message)).toBe(false) ctx.effect(() => ctx.sessions.enter(session)) - const agent = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session) + const agent = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session, + await loopCtx.sessionProjections.register(inboxProjectionDefinition), + await loopCtx.sessionProjections.register(turnBoundaryProjectionDefinition)) cleanups.push(async () => { agent.cancel({ kind: 'disposed' }) await agent.whenIdle() @@ -122,7 +125,9 @@ describe('loop-owned request freezing', () => { expect(Object.isFrozen(session.deriveMessages())).toBe(false) expect(freeze.mock.calls.filter(([value]) => value === userEvent.data)).toHaveLength(1) expect(freeze.mock.calls.filter(([value]) => value === replacement.data)).toHaveLength(1) - const resumed = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session) + const resumed = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session, + await loopCtx.sessionProjections.register(inboxProjectionDefinition), + await loopCtx.sessionProjections.register(turnBoundaryProjectionDefinition)) cleanups.push(async () => { resumed.cancel({ kind: 'disposed' }) await resumed.whenIdle() diff --git a/packages/core/agent-loop/tests/resource-lifecycle.spec.ts b/packages/core/agent-loop/tests/resource-lifecycle.spec.ts index 4ed14d48a4..cb4e272ecf 100644 --- a/packages/core/agent-loop/tests/resource-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/resource-lifecycle.spec.ts @@ -38,7 +38,6 @@ it.each([0, 1, 2])('retains Agent resources until all work exits with %i failed const aborted = Promise.withResolvers() let workExited = false let scopeDisposedBeforeWorkExit = false - let projectionPresentAtWorkExit = false let scopeDisposed = false let abortHandled = 0 const controller = new AbortController() @@ -57,8 +56,6 @@ it.each([0, 1, 2])('retains Agent resources until all work exits with %i failed const work = agent.runMaintenance(async (signal) => { signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true }) await release.promise - projectionPresentAtWorkExit = projections.stateOf(agent.session, 'inbox') !== undefined - && projections.stateOf(agent.session, 'turnBoundary') !== undefined workExited = true }) const disposal = loop.dispose() @@ -71,11 +68,10 @@ it.each([0, 1, 2])('retains Agent resources until all work exits with %i failed await Promise.all([disposal, ownerDisposal]) for (const { handle, error } of failed) await expect(handle.dispose()).rejects.toBe(error) expect(scopeDisposedBeforeWorkExit).toBe(false) - expect(projectionPresentAtWorkExit).toBe(true) expect(scopeDisposed).toBe(true) expect(abortHandled).toBe(1) - expect(projections.stateOf(agent.session, 'inbox')).toBeUndefined() - expect(projections.stateOf(agent.session, 'turnBoundary')).toBeUndefined() + expect(projections.checkpoint(agent.session).inbox).toBeUndefined() + expect(projections.checkpoint(agent.session).turnBoundary).toBeUndefined() expect(ctx.agents.get(agent.id)).toBeUndefined() expect(ctx.sessions.get(agent.session.id)).toBeUndefined() }) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 2addf9cba2..02d84f2fba 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -288,7 +288,7 @@ describe('agent scope lifecycle', () => { const { ctx, loopFiber } = await harnessWithLoop() onTestFinished(() => ctx.fiber.dispose()) const cold = ctx.sessions.create(SessionId('projection-before-any-agent')) - expect(ctx.sessionProjections.stateOf(cold, 'inbox')).toEqual({ 'next-turn': [], 'next-step': [] }) + expect(ctx.sessionProjections.checkpoint(cold).inbox?.val).toEqual({ 'next-turn': [], 'next-step': [] }) let first!: Awaited> let second!: Awaited> const firstOwner = await ctx.plugin(Object.assign(async (inner: Context) => { @@ -304,15 +304,15 @@ describe('agent scope lifecycle', () => { }) }, { inject: ['agents'] })) - expect(ctx.sessionProjections.stateOf(first.agent.session, 'inbox')).toBeDefined() + expect(ctx.sessionProjections.checkpoint(first.agent.session).inbox?.val).toBeDefined() await firstOwner.dispose() - expect(ctx.sessionProjections.stateOf(second.agent.session, 'inbox')).toBeDefined() + expect(ctx.sessionProjections.checkpoint(second.agent.session).inbox?.val).toBeDefined() await secondOwner.dispose() - expect(ctx.sessionProjections.stateOf(second.agent.session, 'inbox')).toBeDefined() + expect(ctx.sessionProjections.checkpoint(second.agent.session).inbox?.val).toBeDefined() await Promise.all([first.dispose(), second.dispose()]) await loopFiber.dispose() - expect(ctx.sessionProjections.stateOf(second.agent.session, 'inbox')).toBeUndefined() + expect(ctx.sessionProjections.checkpoint(second.agent.session).inbox?.val).toBeUndefined() }) it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => { @@ -567,7 +567,7 @@ describe('agent scope lifecycle', () => { await ctx.fiber.dispose() }) - it('factory unload during scope minting skips setup and awaits provisional cleanup', async () => { + it('factory unload during scope minting drains provisional setup and cleanup', async () => { const { ctx, loopFiber } = await harnessWithLoop() let unloaded = false let setupCalls = 0 diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index deb3a08154..a8cd2318c6 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -60,5 +60,8 @@ "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/dsh-workspace": "workspace:*", "@deepseek-ai/cordis": "workspace:~" + }, + "dependencies": { + "zod": "4.4.3" } } diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 54dd8a805d..b4ba4e77be 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -18,7 +18,7 @@ import type { AgentOptions, SessionStartSource } from './runtime-types.ts' export * from './runtime-types.ts' export * from './types.ts' -export type * from './projection.ts' +export { turnBoundaryProjectionDefinition } from './projection.ts' export * from './consumed-work.ts' export * from './model-selection.ts' export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' diff --git a/packages/core/agent/src/projection.ts b/packages/core/agent/src/projection.ts index 89c10ac858..64333a692f 100644 --- a/packages/core/agent/src/projection.ts +++ b/packages/core/agent/src/projection.ts @@ -1,5 +1,8 @@ +/** Shared pure fold of the Session's turn and step events. */ import type { TurnBoundaryProjection } from './types.ts' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import { SessionSeq } from '@deepseek-ai/dsh-session' +import { z as zod } from 'zod' declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { @@ -8,4 +11,53 @@ declare module '@deepseek-ai/dsh-session-projection/types' { } } -export {} +const turnBoundaryProjectionSchema: zod.ZodType = zod.object({ + openTurnStartSeq: zod.number().int().nonnegative().transform(SessionSeq).nullable(), + lastStepStartSeq: zod.number().int().nonnegative().transform(SessionSeq).nullable(), + lastStepBoundary: zod.object({ + kind: zod.union([zod.literal('start'), zod.literal('end')]), + seq: zod.number().int().nonnegative().transform(SessionSeq), + }).nullable(), + lastTurn: zod.number().int().nonnegative(), +}) + +/** Host projection of agent turn and step boundaries. */ +export const turnBoundaryProjectionDefinition = { + key: 'turnBoundary', + stateVersion: 2, + stateSchema: turnBoundaryProjectionSchema, + init: () => ({ + openTurnStartSeq: null, + lastStepStartSeq: null, + lastStepBoundary: null, + lastTurn: 0, + }), + apply: (state, event) => { + switch (event.type) { + case 'turn/start': + return { + ...state, + openTurnStartSeq: event.seq, + lastTurn: event.data.turn, + } + case 'turn/end': + return { + ...state, + openTurnStartSeq: null, + } + case 'step/start': + return { + ...state, + lastStepStartSeq: event.seq, + lastStepBoundary: { kind: 'start', seq: event.seq }, + } + case 'step/end': + return { + ...state, + lastStepBoundary: { kind: 'end', seq: event.seq }, + } + default: + return state + } + }, +} satisfies ProjectionDefinition<'turnBoundary', TurnBoundaryProjection> diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index 87860844ee..77d2f81ab3 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -149,7 +149,7 @@ async function harness(options: HarnessOptions = {}) { await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' }) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(marksUnit(options.stateVersion)) + await ctx.sessionProjections.register(marksUnit(options.stateVersion)) const fiber = await ctx.plugin(SessionProjectionCache, options.config ?? { writeEveryEvents: 100, writeIntervalMs: 60_000 }) return { ctx, root, fiber, cache: ctx.sessionProjectionCache } } @@ -300,7 +300,7 @@ describe('SessionProjectionCache write policy', () => { await ctx.sessionProjectionCache.write(clean) expect((await storedRows(root, clean.id))?.['cache-test/marks']).toEqual({ ver: 1, seq: -1, val: null }) // A unit whose state violates the plain-JSON contract fails the write loud. - ctx.sessionProjections.register({ + await ctx.sessionProjections.register({ key: 'cache-test/marks2', stateSchema: z.custom>(() => true), init: () => new Map(), @@ -336,7 +336,7 @@ describe('SessionProjectionCache write policy', () => { await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' }) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) // A directory where the record document must land makes the atomic @@ -387,7 +387,7 @@ describe('SessionProjectionCache listing read', () => { 'cache-test/secondary-marks': { ver: 1, seq: SessionSeq(6), val: { marks: ['secondary'] } }, }) const { ctx, cache } = await harness({ root }) - ctx.sessionProjections.register(secondaryMarksUnit) + await ctx.sessionProjections.register(secondaryMarksUnit) // The block carries one watermark: the seq every served value has folded // through at least, so the lower of the two rows names the block. @@ -457,7 +457,7 @@ describe('SessionProjectionCache listing read', () => { }, ) const { ctx, cache } = await harness({ root }) - ctx.sessionProjections.register(titleUnit) + await ctx.sessionProjections.register(titleUnit) const seededHeader = { ...headerOf(id), isSeeded: true } // An older format generation never serves the full block, but its title @@ -509,7 +509,7 @@ describe('SessionProjectionCache listing read', () => { 'cache-test/secret': { ver: 1, seq: SessionSeq(4), val: 'private prompt text' }, }) const { ctx, cache } = await harness({ root }) - ctx.sessionProjections.register(secretUnit) + await ctx.sessionProjections.register(secretUnit) const header = headerOf(SessionId('host-state')) expect(cache.cachedSnapshot(header)).toEqual({ @@ -546,7 +546,7 @@ describe('SessionProjectionCache listing read', () => { 'cache-test/marks3': { ver: 1, seq: SessionSeq(4), val: { marks: ['b'] } }, }) const { ctx, cache } = await harness({ root }) - ctx.sessionProjections.register(marks3Unit) + await ctx.sessionProjections.register(marks3Unit) const block = cache.cachedSnapshot(headerOf(SessionId('multi-row'))) expect(block?.values).toEqual({ 'cache-test/marks': { marks: ['a'] }, @@ -752,7 +752,7 @@ describe('SessionProjectionCache cold-read seeding', () => { }) const { cache, ctx } = await harness({ root }) const apply = vi.fn((_state: number, _event: SessionEvent) => 1) - ctx.sessionProjections.register({ + await ctx.sessionProjections.register({ key: 'cache-test/count', stateSchema: z.number().int().nonnegative(), init: () => 0, @@ -794,7 +794,7 @@ describe('SessionProjectionCache cold-read seeding', () => { await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' }) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) // A directory where the record document must land makes the write-back diff --git a/packages/session/session-projection-cache/tests/fixtures.spec.ts b/packages/session/session-projection-cache/tests/fixtures.spec.ts index 2849ea1b81..1fd5126eeb 100644 --- a/packages/session/session-projection-cache/tests/fixtures.spec.ts +++ b/packages/session/session-projection-cache/tests/fixtures.spec.ts @@ -110,7 +110,7 @@ async function harness(root: string) { const ctx = await storageHarness(root) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(titleUnit) + await ctx.sessionProjections.register(titleUnit) await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) return { ctx, cache: ctx.sessionProjectionCache } } @@ -324,7 +324,7 @@ describe('archived version recovery', () => { await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' }) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(titleUnit) + await ctx.sessionProjections.register(titleUnit) const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) // The boot survives the broken record — this line rejecting IS the fixed bug. await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index c6305ff877..cf0fd8eeeb 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -18,14 +18,14 @@ en: 3a15581f0b043e79 zh: 302fc8afe8fb45fa /deepseek-ai-dsh-session-projection/use-this-package/when-to-choose-it: - en: 90b7c26cb45be52a - zh: 386e294ec09607c1 + en: 7ea79ed69e0ea085 + zh: b7791be4c547495c /deepseek-ai-dsh-session-projection/use-this-package/define-a-projection-unit: en: 09758d1475fad86e zh: 473637cb34aebe72 /deepseek-ai-dsh-session-projection/use-this-package/register-and-read: - en: 6a19c075f826b3f7 - zh: c200277cb58062be + en: e10e44c46035dbdb + zh: dff312b52f4ec2ce /deepseek-ai-dsh-session-projection/use-this-package/persisted-checkpoints: en: 45f1c05fb7e08180 zh: f8d62c749899651a diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 79c5e0b213..61e2677e47 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -29,7 +29,7 @@ Mount `dsh-session-projection` wherever client carriers need current values of l ### When to choose it -Choose it when a domain keeps state that clients should see without re-deriving it — a todo list, a goal snapshot, conversation statistics. The registry drives units eagerly over committed events, so any registered unit's value is current by construction. Skip it for host-only bookkeeping that no client reads: a unit without a `wire` block stays host-only. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. +Choose it when a domain keeps state that clients should see without re-deriving it — a todo list, a goal snapshot, conversation statistics. The registry drives units eagerly over committed events, so any registered unit's value is current by construction. A unit without a `wire` block keeps its state host-only. Host readers declare `sessionProjections` in their plugin dependencies and await registration before exposing synchronous reads. ### Define a projection unit @@ -55,10 +55,11 @@ const definition = { ### Register and read -`register(definition)` installs the unit; registrants with the same key and `stateVersion` share its cells, while an incompatible version or invalid `stateVersion` throws. Registration is an effect on the calling fiber, so the last unload removes the key and its cached cells. The returned disposer can be yielded into a composite `ctx.effect()` on the same fiber to keep the unit available through dependent cleanup. Carriers read a consistent synchronous cut over every client-visible unit with `snapshot(session)` — `{ asOfSeq, values }`, where `asOfSeq` is the seq of the last event every value reflects — and subscribe to per-change notifications with `onChanged(listener)`. `stateOf(session, key)` reads one unit's live read-only host state without computing unrelated views. +`await register(definition)` installs the unit and returns a handle with synchronous `stateOf(session)` reads and `dispose()`. Registrants with the same key and `stateVersion` share cells; incompatible or invalid versions reject registration. Registration belongs to the calling fiber. The last unregister removes the unit from global event drives, snapshots, checkpoints, and change notifications. Existing handles remain readable: each retains its original unit and folds new Session events on demand, even if another version later registers the same key. Reading a handle does not initialize unrelated units or register it again. Carriers use `snapshot(session)` for a consistent client-view cut and `onChanged(listener)` for changes to registered units. ```text -const dispose = ctx.sessionProjections.register(definition) +const projection = await ctx.sessionProjections.register(definition) +const state = projection.stateOf(session) const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ``` diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index cb44e32ef9..2a0ac169d7 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 何时选择 -当领域保存客户端应看到、但不应自行重新派生的状态——todo 清单、goal 快照、对话统计——时选择本包。注册表在已提交事件上主动驱动单元,因此任何已注册单元的值按构造即为当前值。当维护的是无客户端读取的 host-only 记账时跳过:不带 `wire` 块的单元保持 host-only。host 读取方要么在插件 `inject` 中声明 `sessionProjections`,要么在注册表或必需 key 缺席时明确失败。贡献方可以继续通过 `ctx.inject(['sessionProjections'], ...)` 保持可选注册。 +当领域保存客户端应看到、但不应自行重新派生的状态——todo 清单、goal 快照、对话统计——时选择本包。注册表在已提交事件上主动驱动单元,因此任何已注册单元的值按构造即为当前值。不带 `wire` 块的单元将状态保留在 host。host 读取方在插件依赖中声明 `sessionProjections`,并等待注册完成后才提供同步读取。 ### 定义投影单元 @@ -55,10 +55,11 @@ const definition = { ### 注册与读取 -`register(definition)` 安装单元;具有相同 key 和 `stateVersion` 的注册方共享其 cell,版本不兼容或 `stateVersion` 非法时会 throw。注册是挂在调用方 fiber 上的 effect,因此最后一个注册方卸载后会移除 key 及其缓存 cell。返回的 disposer 可以 yield 到同一 fiber 上的复合 `ctx.effect()` 中,使单元在依赖它的清理完成前保持可用。载体用 `snapshot(session)` 对每个客户端可见单元读取一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` 是所有值共同反映到的最后一个事件的 seq——并用 `onChanged(listener)` 订阅逐变更通知。`stateOf(session, key)` 读取一个单元的实时只读 host 状态,不计算无关视图。 +`await register(definition)` 安装单元,返回提供同步 `stateOf(session)` 读取和 `dispose()` 的 handle。具有相同 key 和 `stateVersion` 的注册方共享 cell;版本不兼容或非法时注册会拒绝。注册归调用方 fiber 所有。最后一个注册方注销后,单元退出全局事件驱动、快照、检查点和变更通知。已有 handle 仍可读取:它保留原有单元,按需折叠新增的 Session 事件,即使同一 key 后来注册了其他版本也不受影响。读取 handle 不会初始化无关单元,也不会重新注册。载体用 `snapshot(session)` 读取一致的客户端视图切面,用 `onChanged(listener)` 订阅已注册单元的变更。 ```text -const dispose = ctx.sessionProjections.register(definition) +const projection = await ctx.sessionProjections.register(definition) +const state = projection.stateOf(session) const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ``` diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index ac6e63458f..9a27c2b98a 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -37,6 +37,19 @@ import type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts export type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts' +/** An effect-owned registration with a reader retained independently of registration. */ +export interface ProjectionHandle { + /** + * Read the current state, including after unregistering, by folding new + * Session events with this handle's original unit. Do not mutate the live value. + * @param session - Session whose committed events are folded. + * @returns the projection state at the Session's current cursor. + */ + stateOf(session: Session): S + /** Unregister from global drives and snapshots without invalidating this reader. */ + dispose(): void | Promise +} + /** * One domain's state-driven computation unit: a pure synchronous fold plus * declarations and an optional client view — never an opaque getter. The framework drives @@ -156,8 +169,8 @@ interface UnitCell { } /** - * One live registration: the unit plus its per-session cells (dropped whole - * once the last registrant releases it). + * One shared unit and its per-session cells, retained by acquired readers after + * the final registration leaves the live table. * * `refs` exists because one unit definition already serves every session — the * cells are keyed by `Session` — while registrants are per-session: @@ -186,15 +199,11 @@ function cursorBefore(offset: SessionLogOffset): SessionSeqCursor { * raw result changes by `Object.is`. * Cells build lazily — a unit registered after events flowed, or a session * older than the registry, folds `init` over the in-memory log on first - * touch (event or read). Registration is an effect (disposer rides the - * calling fiber): an unloaded domain plugin's key disappears from snapshots - * and clients read it as capability absence. A host reader either declares - * `sessionProjections` in its plugin `inject` or fails explicitly when the - * registry or required key is absent. Contributors may preserve optional - * registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key - * share one unit and are counted: the same tool package mounted in N agent - * presets registers N times, and the key survives until the last one - * unloads. + * touch (event or read). Consumers declare `sessionProjections` as a required + * injection, await registration during activation, and retain its ready handle. + * Registrants sharing a key share one unit and are counted. The last release + * removes the unit from live drives, checkpoints, and client snapshots; retained + * handles still fold new events on demand using the original unit. */ export class SessionProjectionRegistry extends Service { private readonly registrations = new Map() @@ -223,12 +232,12 @@ export class SessionProjectionRegistry extends Service { } /** - * Register one domain's unit. The registration is an effect on the calling - * context's fiber: disposing the fiber (or calling the returned disposer) - * removes the key — and the unit's cached cells — from subsequent drives - * and snapshots. + * Register one domain's unit on the calling context's fiber. Fiber or handle + * disposal releases one reference; the final release removes live participation. + * Retained handles remain readable after release. + * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns the exact disposer that unregisters this unit. + * @returns a ready handle with synchronous state reads and the effect disposer. */ register< K extends keyof SessionProjectionMap, @@ -237,22 +246,23 @@ export class SessionProjectionRegistry extends Service { definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, - ): () => void + ): Promise> /** * Register one host-only unit. Its state is omitted from client snapshots * and always checkpointed like every other unit. + * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns the exact disposer that unregisters this unit. + * @returns a ready handle with synchronous state reads and the effect disposer. */ register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'>, - ): () => void - register( + ): Promise> + async register( definition: ProjectionDefinition, - ): () => void { + ): Promise> { const wire = definition.wire as { viewSchema: ZodType view(state: S): unknown @@ -270,27 +280,34 @@ export class SessionProjectionRegistry extends Service { if (!Number.isSafeInteger(definition.stateVersion) || definition.stateVersion < 0) { throw new Error(`session projection ${JSON.stringify(definition.key)} stateVersion must be a non-negative integer, got ${String(definition.stateVersion)}`) } - const dispose = this.ctx.effect(function* (this: SessionProjectionRegistry) { + let registration!: Registration + const dispose = this.ctx.effect(() => { const key = erased.key const existing = this.registrations.get(key) if (existing === undefined) { - this.registrations.set(key, { def: erased, cells: new WeakMap(), refs: 1 }) + registration = { def: erased, cells: new WeakMap(), refs: 1 } + this.registrations.set(key, registration) } else { if (existing.def.stateVersion !== erased.stateVersion) { throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(erased.stateVersion)}`) } existing.refs += 1 + registration = existing } - yield () => { - const live = this.registrations.get(key) - /* v8 ignore next -- the disposer runs once per successful registration, so the entry it counted is still here */ - if (live === undefined) return - live.refs -= 1 - if (live.refs === 0) this.registrations.delete(key) + return () => { + registration.refs -= 1 + if (registration.refs === 0) this.registrations.delete(key) } - }.bind(this), 'sessionProjections.register()') - // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; keep the Cordis disposer so effects can adopt it. - return dispose + }, 'sessionProjections.register()') + // Observe disposal during the asynchronous registration handoff before exposing a reader. + await Promise.resolve() + this.ctx.fiber.assertActive() + return { + dispose, + stateOf: (session) => { + return this.cellFor(registration, session).state as S + }, + } } /** @@ -309,24 +326,6 @@ export class SessionProjectionRegistry extends Service { return () => void dispose() } - /** - * Read one unit's current host state after materializing every registered - * unit at the Session cursor. Unrelated wire views are not produced. - * The returned value is live; callers must not mutate it. - * @param session - the session whose state is read. - * @param key - the registered unit key. - * @returns current state, or `undefined` when the key is not registered. - */ - stateOf( - session: Session, - key: K, - ): SessionProjectionStateMap[K] | undefined { - const registration = this.registrations.get(key) - if (registration === undefined) return undefined - this.materializeCells(session) - return this.cellFor(registration, session).state as SessionProjectionStateMap[K] - } - /** * One consistent cut over every registered client-visible unit for one session, read from * the watermark cache (missing cells fold lazily over the in-memory log). diff --git a/packages/session/session-projection/tests/registration-reader.spec.ts b/packages/session/session-projection/tests/registration-reader.spec.ts new file mode 100644 index 0000000000..3ee393b85a --- /dev/null +++ b/packages/session/session-projection/tests/registration-reader.spec.ts @@ -0,0 +1,113 @@ +/** Retained readers follow Session events independently of registry participation. */ +import { Context } from '@deepseek-ai/cordis' +import SessionStore from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { z } from 'zod' + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + 'test/reader': number + 'test/unrelated-reader': number + } + interface SessionProjectionMap { + 'test/reader': number + } +} + +const definition = { + key: 'test/reader', + stateSchema: z.number(), + stateVersion: 1, + init: () => 0, + apply: (state: number) => state + 1, + wire: { viewSchema: z.number(), view: (state: number) => state }, +} satisfies ProjectionDefinition<'test/reader', number> + +async function setup() { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + const owner = await ctx.plugin(Object.assign(function registrationOwner() {}, { inject: ['sessionProjections'] })) + const session = ctx.sessions.create() + return { ctx, owner, session } +} + +describe('retained projection readers', () => { + it.each(['handle', 'fiber'] as const)('reads new events after %s disposal without rejoining snapshots or notifications', async (release) => { + const { ctx, owner, session } = await setup() + const apply = vi.fn(definition.apply) + const handle = await owner.ctx.sessionProjections.register({ ...definition, apply }) + const changed = vi.fn() + ctx.sessionProjections.onChanged(changed) + expect(handle.stateOf(session)).toBe(0) + session.append('turn/start', { turn: 1 }) + expect(handle.stateOf(session)).toBe(1) + expect(changed).toHaveBeenCalledOnce() + if (release === 'handle') await handle.dispose() + else await owner.dispose() + session.append('turn/start', { turn: 2 }) + expect(apply).toHaveBeenCalledTimes(1) + expect(handle.stateOf(session)).toBe(2) + expect(apply).toHaveBeenCalledTimes(2) + expect(changed).toHaveBeenCalledOnce() + expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) + expect(ctx.sessionProjections.checkpoint(session)).toEqual({}) + const later = ctx.sessions.create() + later.append('turn/start', { turn: 1 }) + expect(handle.stateOf(later)).toBe(1) + }) + + it('keeps the original unit when the same key is registered with a new version', async () => { + const { ctx, owner, session } = await setup() + const original = await owner.ctx.sessionProjections.register(definition) + expect(original.stateOf(session)).toBe(0) + await original.dispose() + const replacement = await ctx.sessionProjections.register({ ...definition, stateVersion: 2, apply: state => state + 10 }) + session.append('turn/start', { turn: 1 }) + expect(original.stateOf(session)).toBe(1) + expect(replacement.stateOf(session)).toBe(10) + expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/reader': 10 }) + }) + + it('reads only the selected unit without materializing unrelated registrations', async () => { + const { ctx, owner, session } = await setup() + const unrelatedInit = vi.fn(() => 0) + await ctx.sessionProjections.register({ + key: 'test/unrelated-reader', stateSchema: z.number(), stateVersion: 1, + init: unrelatedInit, apply: state => state, + }) + const handle = await owner.ctx.sessionProjections.register(definition) + expect(handle.stateOf(session)).toBe(0) + expect(unrelatedInit).not.toHaveBeenCalled() + }) + + it('rejects initialization when the owner unloads before the handle is returned', async () => { + const { ctx, owner, session } = await setup() + const registration = owner.ctx.sessionProjections.register(definition) + const rejected = expect(registration).rejects.toThrow() + await owner.dispose() + await rejected + expect(ctx.sessionProjections.checkpoint(session)).toEqual({}) + }) + + it.each(['twice', 'owner-unload'] as const)('releases one reference once under %s disposal', async (release) => { + const { ctx, owner, session } = await setup() + const retained = await ctx.sessionProjections.register(definition) + const handle = await owner.ctx.sessionProjections.register(definition) + if (release === 'twice') { + await handle.dispose() + await handle.dispose() + } else { + await Promise.all([owner.dispose(), Promise.resolve(handle.dispose())]) + } + session.append('turn/start', { turn: 1 }) + expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/reader': 1 }) + expect(handle.stateOf(session)).toBe(1) + await retained.dispose() + expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) + }) + +}) diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index de7ecdde60..189bcf96f2 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -165,7 +165,7 @@ describe('SessionProjectionRegistry drive', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(cutUnit()) + const cut = await ctx.sessionProjections.register(cutUnit()) const inherited: SessionEvent[] = [ { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, @@ -176,7 +176,7 @@ describe('SessionProjectionRegistry drive', () => { meta: { isSeeded: true }, }) - expect(ctx.sessionProjections.stateOf(session, 'test/cut')).toBe(inherited.length) + expect(cut.stateOf(session)).toBe(inherited.length) const restored = ctx.sessionProjections.restore( {}, inherited, @@ -197,12 +197,12 @@ describe('SessionProjectionRegistry drive', () => { inherited, SessionLogOffset(0), ).asOfSeq).toBe(1) - expect(ctx.sessionProjections.stateOf(prepared, 'test/cut')).toBe(inherited.length) + expect(cut.stateOf(prepared)).toBe(inherited.length) }) it('drives a registered unit over committed events and snapshots the current value', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) mark(session, ['a']) mark(session, ['a', 'b']) const snapshot = ctx.sessionProjections.snapshot(session) @@ -213,7 +213,7 @@ describe('SessionProjectionRegistry drive', () => { it('builds the cell lazily from the full log for a unit registered after events flowed', async () => { const { ctx, session } = await harness() mark(session, ['pre-registration']) - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['pre-registration'] }) // The lazily-built cell then continues on the live drive path. mark(session, ['after']) @@ -222,7 +222,7 @@ describe('SessionProjectionRegistry drive', () => { it('serves init-derived state and asOfSeq -1 for an empty log', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) const snapshot = ctx.sessionProjections.snapshot(session) expect(snapshot.asOfSeq).toBe(-1) expect(snapshot.values['test/marks']).toEqual({ marks: [] }) @@ -230,7 +230,7 @@ describe('SessionProjectionRegistry drive', () => { it('notifies onChanged with the validated view and the causing seq, and skips same-reference applies', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) const seen: { key: string; value: unknown; seq: SessionSeq; sessionId: string }[] = [] ctx.sessionProjections.onChanged((changedSession, key, value, seq) => { seen.push({ key, value, seq, sessionId: String(changedSession.id) }) @@ -244,19 +244,19 @@ describe('SessionProjectionRegistry drive', () => { it('does not compute a view while no change listener exists', async () => { const { ctx, session } = await harness() const view = vi.fn((state: StableViewState) => state.value) - ctx.sessionProjections.register(stableViewUnit(view)) + const handle = await ctx.sessionProjections.register(stableViewUnit(view)) session.append('turn/start', { turn: 1 }) session.append('turn/start', { turn: 2 }) - expect(ctx.sessionProjections.stateOf(session, 'test/stable-view')?.revision).toBe(2) + expect(handle.stateOf(session).revision).toBe(2) expect(view).not.toHaveBeenCalled() }) it('publishes the first observed view and suppresses later same-reference views', async () => { const { ctx, session } = await harness() const view = vi.fn((state: StableViewState) => state.value) - ctx.sessionProjections.register(stableViewUnit(view)) + await ctx.sessionProjections.register(stableViewUnit(view)) const seen: unknown[] = [] ctx.sessionProjections.onChanged((_session, key, value) => { @@ -277,7 +277,7 @@ describe('SessionProjectionRegistry drive', () => { it('publishes the first view after an unobserved state change', async () => { const { ctx, session } = await harness() const view = vi.fn((state: StableViewState) => state.value) - ctx.sessionProjections.register(stableViewUnit(view)) + await ctx.sessionProjections.register(stableViewUnit(view)) const first: unknown[] = [] const stop = ctx.sessionProjections.onChanged((_session, key, value) => { if (key === 'test/stable-view') first.push(value) @@ -305,7 +305,7 @@ describe('SessionProjectionRegistry drive', () => { const stateByEvent = new Map() const viewByState = new Map() const computedViews: MarksView[] = [] - ctx.sessionProjections.register({ + await ctx.sessionProjections.register({ key: 'test/marks', stateSchema: marksViewSchema.nullable(), init: () => initialState, @@ -431,7 +431,7 @@ describe('SessionProjectionRegistry drive', () => { it('drives independently per session (cells are per-session watermarks)', async () => { const { ctx, session } = await harness() const other = ctx.sessions.create() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) mark(session, ['one']) mark(other, ['two']) expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['one'] }) @@ -440,70 +440,71 @@ describe('SessionProjectionRegistry drive', () => { it('updates host-only units without publishing them to wire listeners', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + const count = await ctx.sessionProjections.register(countUnit()) const changedKeys: string[] = [] ctx.sessionProjections.onChanged((_session, key) => { changedKeys.push(key) }) session.append('turn/start', { turn: 1 }) expect(changedKeys).toEqual([]) - expect(ctx.sessionProjections.stateOf(session, 'test/count')).toBe(1) + expect(count.stateOf(session)).toBe(1) expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/marks': { marks: [] } }) }) it('shares one unit between registrants of the same key', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) // One definition already serves every session (cells are keyed by // Session), and registrants are per-session now: an agent preset mounts // the same tool package once per agent. - expect(() => ctx.sessionProjections.register(marksUnit())).not.toThrow() + const handle = await ctx.sessionProjections.register(marksUnit()) + expect(typeof handle.stateOf).toBe('function') mark(session, ['kept']) expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['kept'] }) }) it('keeps the unit until the last registrant releases it', async () => { const { ctx, session } = await harness() - const first = ctx.sessionProjections.register(marksUnit()) - const second = ctx.sessionProjections.register(marksUnit()) + const first = await ctx.sessionProjections.register(marksUnit()) + const second = await ctx.sessionProjections.register(marksUnit()) mark(session, ['kept']) - first() + await first.dispose() // The regression this counts against: without last-release semantics, one // session ending strips the projection from every other live session, // because the first registrant owns the only disposer. expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['kept'] }) - second() + await second.dispose() expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) }) it('refuses to share a key across a stateVersion change', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) // The one incompatibility a runtime comparison can name: the versioned // contract says the cached state shape differs, so the two cannot share // cells. Everything else about a definition is functions. - expect(() => ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 9 })) - .toThrow(/already registered at stateVersion 1; refusing to share it with stateVersion 9/) + await expect(ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 9 })) + .rejects.toThrow(/already registered at stateVersion 1; refusing to share it with stateVersion 9/) }) it('rejects a non-integer or negative stateVersion at register time', async () => { const { ctx } = await harness() - expect(() => ctx.sessionProjections.register({ ...marksUnit(), stateVersion: -1 })).toThrow(/stateVersion/) - expect(() => ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 1.5 })).toThrow(/stateVersion/) + await expect(ctx.sessionProjections.register({ ...marksUnit(), stateVersion: -1 })).rejects.toThrow(/stateVersion/) + await expect(ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 1.5 })).rejects.toThrow(/stateVersion/) }) it('register() disposer removes the key (with its cells) and frees it for re-registration', async () => { const { ctx, session } = await harness() - const dispose = ctx.sessionProjections.register(marksUnit()) + const dispose = await ctx.sessionProjections.register(marksUnit()) mark(session, ['cached']) - dispose() + await dispose.dispose() expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) // Fresh registration rebuilds from the log, not from a stale cell. expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['cached'] }) }) @@ -511,8 +512,8 @@ describe('SessionProjectionRegistry drive', () => { it('removes registrations and change listeners when their owning fiber unloads (HMR safety)', async () => { const { ctx, session } = await harness() const notifications: string[] = [] - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.sessionProjections.register(marksUnit()) + const fiber = await ctx.plugin(Object.assign(async (inner: Context) => { + await inner.sessionProjections.register(marksUnit()) inner.sessionProjections.onChanged((_session, key) => { notifications.push(key) }) @@ -525,38 +526,36 @@ describe('SessionProjectionRegistry drive', () => { expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) }) - it('keeps a yielded registration live until the composite finishes using it', async () => { + it('retains readable state during owner teardown', async () => { const { ctx, session } = await harness() onTestFinished(async () => { await ctx.fiber.dispose() }) let observed: MarksState | undefined - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.effect(function* () { - yield inner.sessionProjections.register(marksUnit()) - yield () => { observed = inner.sessionProjections.stateOf(session, 'test/marks') } - }) + const fiber = await ctx.plugin(Object.assign(async (inner: Context) => { + const handle = await inner.sessionProjections.register(marksUnit()) + inner.effect(() => () => { observed = handle.stateOf(session) }) }, { inject: ['sessionProjections'] })) mark(session, ['live']) await fiber.dispose() expect(observed).toEqual({ marks: ['live'] }) - expect(ctx.sessionProjections.stateOf(session, 'test/marks')).toBeUndefined() + expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toBeUndefined() }) it('snapshot serves client views and excludes host-only state', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + const count = await ctx.sessionProjections.register(countUnit()) mark(session, ['a', 'b']) const values = ctx.sessionProjections.snapshot(session).values expect(values['test/marks']).toEqual({ marks: ['a', 'b'] }) expect('test/count' in values).toBe(false) - expect(ctx.sessionProjections.stateOf(session, 'test/count')).toBe(1) + expect(count.stateOf(session)).toBe(1) expect('test/unregistered' in values).toBe(false) }) it('checkpoints every persisted unit with its stateVersion and per-cell watermark', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register({ ...countUnit(), stateVersion: 7 }) + await ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register({ ...countUnit(), stateVersion: 7 }) const markEvent = mark(session, ['a']) const rows = ctx.sessionProjections.checkpoint(session) expect(rows['test/marks']).toEqual({ ver: 1, seq: markEvent.seq, val: { marks: ['a'] } }) @@ -568,7 +567,7 @@ describe('SessionProjectionRegistry drive', () => { it('checkpoint states are detached clones — mutating them cannot corrupt the watermark cache', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) mark(session, ['a']) const rows = ctx.sessionProjections.checkpoint(session) // Hostile (or merely careless) consumer mutates the handed-out state. @@ -582,8 +581,8 @@ describe('SessionProjectionRegistry drive', () => { it('restoreFloor anchors one below the lowest usable watermark and at 0 for missing or mismatched rows', async () => { const { ctx } = await harness() expect(ctx.sessionProjections.restoreFloor({})).toBeUndefined() // no unit registered - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(countUnit()) expect(ctx.sessionProjections.restoreFloor({})).toBe(0) // Lowest usable watermark is count's 5 → the anchored tail starts AT 5 // (one below the first needed seq 6), so the read proves seq 5 still exists. @@ -605,8 +604,8 @@ describe('SessionProjectionRegistry drive', () => { it('restore folds the tail past each usable row and refolds from init on version mismatch', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(countUnit()) const tail: SessionEvent[] = [ { type: 'test/mark', seq: SessionSeq(3), time: 3, data: { marks: ['new'] } }, { type: 'turn/end', seq: SessionSeq(4), time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, @@ -639,8 +638,8 @@ describe('SessionProjectionRegistry drive', () => { it('restore over a suffix folds only past each row watermark and serves an exact empty-tail cut', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(countUnit()) const rows = { 'test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['done'] } }, 'test/count': { ver: 1, seq: SessionSeq(2), val: 3 }, @@ -675,8 +674,8 @@ describe('SessionProjectionRegistry drive', () => { it('viewCheckpoint serves version-matching rows without any log and skips mismatched keys', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(countUnit()) const values = ctx.sessionProjections.viewCheckpoint({ 'test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['stored'] } }, 'test/count': { ver: 99, seq: SessionSeq(4), val: 5 }, // mismatched: absent @@ -688,8 +687,8 @@ describe('SessionProjectionRegistry drive', () => { it('viewCheckpoint and restore exclude host-only state while retaining its checkpoint', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(countUnit()) const rows = { 'test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['stored'] } }, 'test/count': { ver: 1, seq: SessionSeq(4), val: 5 }, @@ -713,7 +712,7 @@ describe('SessionProjectionRegistry drive', () => { it('rejects version-matching rows whose state no longer matches the registered schema', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) const drifted = { 'test/marks': { ver: 1, seq: SessionSeq(2), val: { marks: 'not-an-array' } }, } @@ -730,7 +729,7 @@ describe('SessionProjectionRegistry drive', () => { it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(countUnit()) const rows = { 'test/count': { ver: 1, seq: SessionSeq(9), val: 10 } } // The anchored floor sits ON the watermark, so the tail read must return // at least seq 9 from an intact log… @@ -775,7 +774,7 @@ describe('SessionProjectionRegistry drive', () => { it('fails loud when a unit view violates its own schema (async unit output is unrepresentable)', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register({ + await ctx.sessionProjections.register({ key: 'test/marks', stateSchema: z.object({ marks: z.array(z.string()) }).nullable(), init: () => null as MarksState, From 08e48f34b583f19453305f78dbc0a554c2099890 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 12:03:36 +0800 Subject: [PATCH 02/37] refactor(plugins): migrate projection consumers to retained handles --- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 + packages/api/session-controller/README.zh.md | 2 + packages/api/session-controller/src/agent.ts | 30 +- packages/api/session-controller/src/index.ts | 37 +- packages/api/session-controller/src/list.ts | 32 +- .../src/model-selection-projection.ts | 12 +- .../tests/agent.host.spec.ts | 16 +- .../commands-queue-attachment.host.spec.ts | 2 +- .../tests/controller.host.spec.ts | 6 +- .../tests/list-scheduling.host.spec.ts | 8 +- .../tests/projection-discovery.host.spec.ts | 6 +- .../tests/session-cold.host.spec.ts | 42 +- .../tests/session-fork.host.spec.ts | 44 +- .../session-history-journal.host.spec.ts | 12 +- .../tests/session-list-blank.host.spec.ts | 2 +- .../tests/session-models.host.spec.ts | 44 +- .../session-open-workspace-path.host.spec.ts | 36 +- .../tests/session-presets.host.spec.ts | 4 +- .../tests/session-projections.host.spec.ts | 86 ++-- .../tests/session-rename.host.spec.ts | 10 +- .../tests/session-search.host.spec.ts | 56 +-- .../tests/subagent-catalog.host.spec.ts | 4 +- .../session-controller/tests/test-remote.ts | 33 +- .../tests/transport.host.spec.ts | 2 +- .../agent-instructions/README.i18n.yaml | 4 +- packages/context/agent-instructions/README.md | 2 +- .../context/agent-instructions/README.zh.md | 2 +- .../context/agent-instructions/src/index.ts | 23 +- .../tests/agent-instructions.spec.ts | 33 +- .../harness-dev-context/README.i18n.yaml | 4 +- .../context/harness-dev-context/README.md | 2 +- .../context/harness-dev-context/README.zh.md | 2 +- .../context/harness-dev-context/src/index.ts | 8 +- packages/context/time-context/src/index.ts | 8 +- .../time-context/tests/time-context.spec.ts | 6 +- packages/context/tmux-context/src/index.ts | 12 +- .../deliverables/tool-present/src/index.ts | 11 +- .../tool-present/tests/built-errors.e2e.ts | 6 +- .../tool-present/tests/present.spec.ts | 9 +- .../experimental/agent-team/README.i18n.yaml | 8 +- packages/experimental/agent-team/README.md | 4 +- packages/experimental/agent-team/README.zh.md | 4 +- packages/experimental/agent-team/src/index.ts | 26 +- .../experimental/agent-team/src/journal.ts | 9 +- .../tests/projection-events.spec.ts | 6 +- .../agent-team/tests/team.spec.ts | 60 ++- .../claude-code-mods/README.i18n.yaml | 4 +- .../experimental/claude-code-mods/README.md | 2 +- .../claude-code-mods/README.zh.md | 2 +- .../claude-code-mods/src/host-ops.ts | 34 +- .../claude-code-mods/src/index.ts | 27 +- .../claude-code-mods/tests/bridge.spec.ts | 4 +- .../claude-code-mods/tests/define-mod.spec.ts | 8 +- .../claude-code-mods/tests/host-ops.spec.ts | 42 +- .../tests/activation.host.spec.ts | 6 +- .../hooks-claude-code/src/index.ts | 34 +- .../hooks-claude-code/tests/coverage-cases.ts | 2 +- .../experimental/hooks-codex/src/index.ts | 71 +-- .../hooks-codex/tests/coverage-cases.ts | 2 +- .../tests/provider.spec.ts | 4 +- .../tool-session-query/src/index.ts | 25 +- .../tool-session-query/src/operations.ts | 423 ++++++++++-------- .../src/workspace-access.ts | 21 +- .../tests/sqlite-integration.spec.ts | 8 +- .../tests/tool-session-query.spec.ts | 24 +- packages/fs/tool-fs/tests/tools.spec.ts | 2 +- packages/goal/goal/src/index.ts | 19 +- packages/goal/goal/tests/goal.spec.ts | 12 +- packages/goal/goal/tests/projection.spec.ts | 5 +- packages/goal/tool-goal/README.i18n.yaml | 4 +- packages/goal/tool-goal/README.md | 1 + packages/goal/tool-goal/README.zh.md | 1 + packages/goal/tool-goal/package.json | 3 +- packages/goal/tool-goal/src/authority.ts | 140 +++--- packages/goal/tool-goal/src/index.ts | 13 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 49 +- .../permission-presets/README.i18n.yaml | 8 +- .../interaction/permission-presets/README.md | 4 +- .../permission-presets/README.zh.md | 4 +- .../permission-presets/src/index.ts | 14 +- .../tests/permission-presets.spec.ts | 19 +- .../interaction/user-questions/src/index.ts | 21 +- .../tests/user-questions.spec.ts | 2 +- packages/llm/llm-retry/src/index.ts | 20 +- packages/llm/llm-retry/tests/retry.spec.ts | 29 +- packages/llm/token-meter/src/index.ts | 10 +- .../context-breakdown-projection.spec.ts | 3 +- .../token-meter/tests/route-pricing.spec.ts | 6 +- .../llm/token-meter/tests/token-meter.spec.ts | 8 +- packages/plan/plan-mode/README.i18n.yaml | 8 +- packages/plan/plan-mode/README.md | 4 +- packages/plan/plan-mode/README.zh.md | 4 +- packages/plan/plan-mode/src/index.ts | 18 +- .../plan/plan-mode/tests/integration.spec.ts | 3 +- .../plan/plan-mode/tests/plan-mode.spec.ts | 21 +- .../plan/plan-mode/tests/projection.spec.ts | 8 +- .../preset/agent-preset-registry/src/index.ts | 16 +- .../tests/registry.spec.ts | 6 +- packages/sandbox/sandbox-policy/src/index.ts | 11 +- .../sandbox-policy/tests/policy.spec.ts | 4 +- packages/session/session-stats/src/index.ts | 8 +- .../tests/provider.spec.ts | 4 +- packages/session/session-title/src/index.ts | 19 +- .../session-title/tests/projection.spec.ts | 6 +- .../session-title/tests/provider.spec.ts | 4 +- packages/session/session-title/tsconfig.json | 1 - .../session/session-turn-outline/src/index.ts | 8 +- .../tests/projection.spec.ts | 2 +- .../session/working-directory/src/index.ts | 13 +- packages/shell/tool-bash/tests/tools.spec.ts | 2 +- packages/shell/tool-pwsh/tests/tools.spec.ts | 2 +- .../fixtures/loader/scoped-tool-subagent.ts | 4 +- .../tests/subagent-dsh-sdk.spec.ts | 2 + packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- packages/subagent/subagent/package.json | 3 - packages/subagent/subagent/src/index.ts | 16 +- .../subagent/subagent/tests/catalog.spec.ts | 4 +- .../tests/continuation-inheritance.spec.ts | 2 +- .../subagent/tests/continuation.spec.ts | 5 + .../subagent/subagent/tests/control.spec.ts | 2 + .../subagent/tests/list-children.spec.ts | 9 +- .../subagent/subagent/tests/service.spec.ts | 4 +- .../subagent/tests/timing-projection.spec.ts | 8 +- packages/subagent/tool-subagent/src/index.ts | 14 +- .../src/model-selection-state.ts | 14 +- .../subagent/tool-subagent/tests/harness.ts | 7 +- .../tests/model-selection-settings.spec.ts | 41 +- .../tool-subagent/tests/tool-subagent.spec.ts | 20 +- packages/terminal/terminal-bash/src/index.ts | 9 +- .../terminal-bash/tests/index.spec.ts | 2 +- .../tests/agent-loop-testkit.spec.ts | 2 +- packages/todo/tool-todo/src/index.ts | 6 +- pnpm-lock.yaml | 7 + 136 files changed, 1253 insertions(+), 1029 deletions(-) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 94dd4e518f..5dc133470c 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: d26a58a8f2406cd5 - zh: 82135eceb9adb628 + en: 5209c0658a6f643b + zh: bb2f9575215bac7a /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index ca922877f8..94daea954c 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -27,6 +27,8 @@ Desktop analytics follows the [product collection policy](../../client/product-a ## Use this package +The controller registers the `agentPreset` projection independently of preset execution support. Its value is the recorded preset id or null; key presence does not indicate that `agentPresets` is composed. + History pages and follow opening snapshots carry one `{ type: 'event', event: SessionWireEvent }` record per durable Session event. The Client retains each accepted record as one durable `SessionEventLikeEntry`; Assistant token boundaries remain inside the compact stream on `assistant/message` or `assistant/attempt`. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 585c4e0363..79773c94fb 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" ## 使用本包 +控制器独立于预设执行能力注册 `agentPreset` 投影。其值为已记录的预设 id 或 null;键存在不代表组合中包含 `agentPresets`。 + 历史页与 follow opening 快照为每个持久 Session 事件携带一条 `{ type: 'event', event: SessionWireEvent }` record。Client 把每条已接受 record 保留为一个持久 `SessionEventLikeEntry`;Assistant token 边界保留在 `assistant/message` 或 `assistant/attempt` 的紧凑流内。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;控制器不解析工具定义、不运行展示转换器,也不附加 UI 数据。 Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。 diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index 3f0fa77944..41f56e4f14 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -7,14 +7,16 @@ import type { Agent, AgentOptions, AgentSetup, ModelSelection as AgentModelSelection, ModelSelectionRef, } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' -import type {} from '@deepseek-ai/dsh-agent-preset-registry' +import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-typert-registry' -import type { ModelSelection } from './types.ts' +import type { ModelSelection, ModelSelectionProjectionState } from './types.ts' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' +import { modelSelectionProjection } from './model-selection-projection.ts' /** Cold Session identity absent from persistence. */ export class ApiSessionNotFound extends Error {} @@ -138,13 +140,24 @@ export async function inspectApiSession( /** Owns every operation that may create, resume, or configure a Web Agent. */ export class ApiSessionAgentController { + private modelSelection!: ProjectionHandle + private agentPreset!: ProjectionHandle private readonly resumes = new Map>() private readonly creations = new Map>() private readonly selections = new WeakMap() private readonly imageAdmissionChains = new WeakMap>() /** @param ctx - Host context carrying Agent, model, persistence, and Typert services. */ - constructor(private readonly ctx: Context) { + constructor(private readonly ctx: Context) {} + + /** + * Acquire projection readers before exposing dependent operations. + * @returns when all required registrations are ready. + */ + async initialize(): Promise { + const ctx = this.ctx + this.modelSelection = await ctx.sessionProjections.register(modelSelectionProjection) + this.agentPreset = await ctx.sessionProjections.register(agentPresetProjectionDefinition) ctx.typert.lookups.configure('agent', async (sessionId: SessionId) => { const found = await this.resolveAgent(sessionId) if ('error' in found) throw found.error @@ -283,10 +296,7 @@ export class ApiSessionAgentController { selectionFor(agent: Agent): InstalledSelection { const installed = this.selections.get(agent) if (installed !== undefined) return installed - const projectionState = this.ctx.sessionProjections.stateOf(agent.session, 'modelSelection') - if (projectionState === undefined) { - throw new Error('api-session: required modelSelection projection is not registered') - } + const projectionState = this.modelSelection.stateOf(agent.session) let picked = projectionState.pending === null ? undefined : agentModelSelection(projectionState.pending) @@ -355,10 +365,10 @@ export class ApiSessionAgentController { /** * Read the current Agent preset from the Session projection. * @param session - live Session whose projection state is available. - * @returns the current preset, or undefined when the capability is absent. + * @returns the current preset, or undefined when the Session has no recorded preset. */ presetForSession(session: Session): string | undefined { - return this.ctx.sessionProjections.stateOf(session, 'agentPreset') ?? undefined + return this.agentPreset.stateOf(session) ?? undefined } /** @@ -506,7 +516,7 @@ export class ApiSessionAgentController { /** * Read the current Agent preset from an all-projections observation. * @param observation - exact Session observation carrying its projection snapshot. - * @returns the current preset, or undefined when the capability is absent. + * @returns the current preset, or undefined when the Session has no recorded preset. */ presetForObservation(observation: SessionObservation): string | undefined { if (observation.projections === undefined) { diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 0ba5bf8ff0..fbb4234fdf 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -4,7 +4,7 @@ import { hostname } from 'node:os' import { resolve } from 'node:path' import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-fs' -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-client-file-upload' @@ -25,7 +25,6 @@ import { SessionHistoryController } from './history.ts' import { SessionFileReferences } from './file-references.ts' import { ApiSessionList } from './list.ts' import { buildModelCatalog, hasProviderApiKey } from './catalog.ts' -import { installModelSelectionProjection } from './model-selection-projection.ts' import { SessionSkillCatalog } from './skill-catalog.ts' import { SessionMediaReferences } from './media-references.ts' import { ArchivedSessionGate } from './archived-session-gate.ts' @@ -122,16 +121,17 @@ export class SessionController extends TypertRemoteService { listWorkSliceMs: z.natural().min(1).default(16), }) - private readonly agents: ApiSessionAgentController - private readonly commands: SessionCommandController - private readonly controlState: SessionControlController - private readonly history: SessionHistoryController - private readonly listState: ApiSessionList + private agents!: ApiSessionAgentController + private commands!: SessionCommandController + private controlState!: SessionControlController + private history!: SessionHistoryController + private listState!: ApiSessionList private readonly openPath: (path: string, signal: AbortSignal) => Promise private readonly fileApplications: typeof nativeFileApplications private readonly openFileApplication: typeof openNativeFileApplication private readonly revealPath: (path: string, signal: AbortSignal) => Promise private readonly canOpenPath: () => boolean + private readonly listWorkSliceMs: number private readonly promotions = new Set>() /** @@ -141,9 +141,19 @@ export class SessionController extends TypertRemoteService { */ constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) { super(ctx, 'sessionController', { namespace: 'session' }) - const resolved = SessionController.Config(config) - installModelSelectionProjection(ctx) + this.listWorkSliceMs = SessionController.Config(config).listWorkSliceMs + this.fileApplications = internals.fileApplications ?? nativeFileApplications + this.openFileApplication = internals.openFileApplication ?? openNativeFileApplication + this.openPath = internals.openPath ?? openNativeAssociatedPath + this.revealPath = internals.revealPath ?? revealNativePath + this.canOpenPath = internals.canOpenPath + ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) + } + + protected async [Service.init](): Promise { + const ctx = this.ctx this.agents = new ApiSessionAgentController(ctx) + await this.agents.initialize() this.commands = new SessionCommandController(ctx, this.agents, process.cwd()) ctx.effect(() => ctx.fileUploads.registerAgentResolver(async (sessionId) => { const result = await this.agents.resolveAgent(sessionId) @@ -157,13 +167,8 @@ export class SessionController extends TypertRemoteService { await Promise.allSettled([...this.promotions]) }, 'session-controller.promotions') this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) }) - this.listState = new ApiSessionList(ctx, resolved.listWorkSliceMs) - this.fileApplications = internals.fileApplications ?? nativeFileApplications - this.openFileApplication = internals.openFileApplication ?? openNativeFileApplication - this.openPath = internals.openPath ?? openNativeAssociatedPath - this.revealPath = internals.revealPath ?? revealNativePath - this.canOpenPath = internals.canOpenPath - ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) + this.listState = new ApiSessionList(ctx, this.listWorkSliceMs) + await this.listState.initialize() ctx.plugin(SessionFileReferences) ctx.plugin(SessionMediaReferences) ctx.plugin(SessionSkillCatalog) diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts index d7ef1ad43c..f109fee5e0 100644 --- a/packages/api/session-controller/src/list.ts +++ b/packages/api/session-controller/src/list.ts @@ -80,8 +80,14 @@ export class ApiSessionList { * @param ctx - Host context carrying Session, query, persistence, and projection services. * @param workSliceMs - Resolved positive integral list-work budget in milliseconds. */ - constructor(private readonly ctx: Context, private readonly workSliceMs: number) { - ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({ + constructor(private readonly ctx: Context, private readonly workSliceMs: number) {} + + /** + * Acquire projection readers before exposing dependent operations. + * @returns when all required registrations are ready. + */ + async initialize(): Promise { + await this.ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({ key: 'sessionListMetadata', stateSchema: sessionListMetadataSchema, init: () => ({ blank: true, lastPromptAt: null }), @@ -89,18 +95,16 @@ export class ApiSessionList { wire: { viewSchema: sessionListMetadataSchema, view: state => state }, stateVersion: 1, }) - ctx.inject(['attachments'], (attachmentCtx) => { - ctx.sessionProjections.register<'imageLimits', null>({ - key: 'imageLimits', - stateSchema: z.null(), - init: () => null, - apply: state => state, - wire: { - viewSchema: imageLimitsSchema, - view: () => attachmentCtx.attachments.imageLimits, - }, - stateVersion: 1, - }) + await this.ctx.sessionProjections.register<'imageLimits', null>({ + key: 'imageLimits', + stateSchema: z.null(), + init: () => null, + apply: state => state, + wire: { + viewSchema: imageLimitsSchema, + view: () => this.ctx.attachments.imageLimits, + }, + stateVersion: 1, }) } diff --git a/packages/api/session-controller/src/model-selection-projection.ts b/packages/api/session-controller/src/model-selection-projection.ts index 47eddde181..071cf5e685 100644 --- a/packages/api/session-controller/src/model-selection-projection.ts +++ b/packages/api/session-controller/src/model-selection-projection.ts @@ -1,6 +1,5 @@ /** Durable model-selection intent and request-use projection. */ -import type { Context } from '@deepseek-ai/cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import { z } from 'zod' @@ -55,7 +54,8 @@ function applyModelSelectionProjection( : { lastUsed, pending } } -const modelSelectionProjection = { +/** Last-used and pending model selections shared by Session controller readers. */ +export const modelSelectionProjection = { key: 'modelSelection', stateSchema: modelSelectionProjectionStateSchema, init: () => ({ lastUsed: null, pending: null }), @@ -73,11 +73,3 @@ function sameSelection(left: ModelSelection | null, right: ModelSelection | null && left.model === right.model && left.reasoningEffort === right.reasoningEffort) } - -/** - * Register the durable model-selection projection when the registry is present. - * @param ctx - Session Controller context. - */ -export function installModelSelectionProjection(ctx: Context): void { - ctx.sessionProjections.register(modelSelectionProjection) -} diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 4756ae45b0..10a062d87e 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -18,7 +18,6 @@ import { ApiSessionSubagentOwnership, inspectApiSession, } from '../src/agent.ts' -import { installModelSelectionProjection } from '../src/model-selection-projection.ts' import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' const roots: Context[] = [] @@ -38,13 +37,14 @@ async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentControl await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) - ctx.sessionProjections.register(agentPresetProjectionDefinition) - installModelSelectionProjection(ctx) + await ctx.sessionProjections.register(agentPresetProjectionDefinition) ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), saveSelection: () => Promise.resolve(), } as never) - return { ctx, agents: new ApiSessionAgentController(ctx) } + const agents = new ApiSessionAgentController(ctx) + await agents.initialize() + return { ctx, agents } } function header(id: string, cwd: string | null = '/workspace'): SessionHeader { @@ -288,14 +288,6 @@ describe('ApiSession Agent lookup and recovery', () => { }) describe('ApiSession model selection', () => { - it('requires the model-selection projection', async () => { - const { ctx, agents } = await harness() - const live = agent(ctx, header('missing-model-projection')) - vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined) - - expect(() => agents.selectionFor(live)).toThrow('required modelSelection projection') - }) - it('reads a reasoning-free request and consumes only the exact pending selection', async () => { const { ctx, agents } = await harness() const logged = agent(ctx, header('logged-model')) diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 5a4d946bf0..ff5f980250 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -38,7 +38,7 @@ async function commandHarness( await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) - ctx.sessionProjections.register(subagentIdentityProjectionDefinition) + await ctx.sessionProjections.register(subagentIdentityProjectionDefinition) const sessionId = SessionId('commands-session') const ancestor = Session.create(SessionId('ancestor')) ancestor.append('subagent/descriptor', snapshotSubagentDescriptor({ diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index 43d1394851..45d3a523ec 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -52,7 +52,7 @@ describe('SessionController facade', () => { bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }), retirePrompt: () => {}, } as never) - const controller = createSessionTestController(ctx, defaults) + const controller = await createSessionTestController(ctx, defaults) const status = vi.fn() const failure = vi.fn() const activity = vi.fn() @@ -157,7 +157,7 @@ describe('SessionController facade', () => { events: [], }), }) as never) - const controller = createSessionTestController(ctx, defaults) + const controller = await createSessionTestController(ctx, defaults) const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents const apiError = vi.fn() ctx.on('api-session/error', apiError) @@ -213,7 +213,7 @@ describe('SessionController facade', () => { events: [], }), }) as never) - const controller = createSessionTestController(ctx, defaults) + const controller = await createSessionTestController(ctx, defaults) const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents const started = Promise.withResolvers() const release = Promise.withResolvers() diff --git a/packages/api/session-controller/tests/list-scheduling.host.spec.ts b/packages/api/session-controller/tests/list-scheduling.host.spec.ts index eefea76713..eeae34ff03 100644 --- a/packages/api/session-controller/tests/list-scheduling.host.spec.ts +++ b/packages/api/session-controller/tests/list-scheduling.host.spec.ts @@ -9,6 +9,7 @@ import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { scheduler } from 'node:timers/promises' import SessionController from '../src/index.ts' +import { createSessionTestController, TEST_IMAGE_LIMITS } from './test-remote.ts' import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiSessionList } from '../src/list.ts' @@ -47,7 +48,9 @@ async function harness(rows: readonly Row[], summaryMs = 8, workSliceMs = 8) { await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) + ctx.provide('attachments', { imageLimits: TEST_IMAGE_LIMITS } as never) const list = new ApiSessionList(ctx, workSliceMs) + await list.initialize() const clock = { now: 0 } const prepared = new Map() const agents = new Map() @@ -185,7 +188,10 @@ describe('Session-list work slices', () => { h.ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never) h.ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'test', model: 'test' }) } as never) h.ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) - const controller = new SessionController(h.ctx, { nativeOpen: false, listWorkSliceMs: 4 }, { canOpenPath: () => false }) + const controller = await createSessionTestController(h.ctx, { + nativeOpen: false, listWorkSliceMs: 4, canOpenPath: () => false, + defaultModelSelection: () => ({ provider: 'test', model: 'test' }), cwd: '/tmp', + }) const yieldWork = vi.spyOn(scheduler, 'yield') const result = await controller.list({}, new AbortController().signal) expect(result.items).toHaveLength(3) diff --git a/packages/api/session-controller/tests/projection-discovery.host.spec.ts b/packages/api/session-controller/tests/projection-discovery.host.spec.ts index 0a14c90f0a..ce51110107 100644 --- a/packages/api/session-controller/tests/projection-discovery.host.spec.ts +++ b/packages/api/session-controller/tests/projection-discovery.host.spec.ts @@ -49,8 +49,8 @@ async function harness(withPersistence = true) { await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(subagentCatalogProjectionDefinition) - ctx.sessionProjections.register(titleProjectionDefinition) + await ctx.sessionProjections.register(subagentCatalogProjectionDefinition) + await ctx.sessionProjections.register(titleProjectionDefinition) if (withPersistence) { await ctx.plugin(JsonlSessionPersistence, { root: join(root, 'logs'), compression: 'none' }) const list = vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(new Error('unexpected corpus listing')) @@ -58,7 +58,7 @@ async function harness(withPersistence = true) { expect(list).not.toHaveBeenCalled() }) } - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'test', model: 'test' }), cwd: root, }) diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index a6b7a033dd..dd1a68f610 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -97,7 +97,7 @@ describe('sessions.list cold merge', () => { const stat = vi.fn() const open = vi.fn() ctx.provide('sessionPersistence', { list, stat, open } as never) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -147,7 +147,7 @@ describe('sessions.list cold merge', () => { ? { asOfSeq: 2, values: { title: 'Cached predecessor title' } } : undefined, } as never) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -209,7 +209,7 @@ describe('sessions.list cold merge', () => { }, cachedPredecessorTitle: () => undefined, } as never) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const response = await remote.list(request({})) expect(response.ok).toBe(true) @@ -247,7 +247,7 @@ describe('attached updatedAt tracks human prompts', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) await new Promise(resolve => setTimeout(resolve, 0)) // Old work, resumed just now: the log tail would report the pickup. @@ -306,7 +306,7 @@ describe('cold history recovery view', () => { list: () => Promise.resolve([structuredClone(meta)]), inspect: () => Promise.resolve({ meta: structuredClone(meta), events: structuredClone(events) }), }) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const history = await remote.page({ address: { kind: 'session', sessionId }, @@ -365,7 +365,7 @@ describe('Remote Agent and Session lookup policy', () => { }), }) const resume = vi.spyOn(ctx.agents, 'resume') - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -391,7 +391,7 @@ describe('Remote Agent and Session lookup policy', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -427,7 +427,7 @@ describe('Remote Agent and Session lookup policy', () => { }) const defaultAgentLookup = ctx.typert.lookups.get('agent') const defaultSessionLookup = ctx.typert.lookups.get('session') - createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) await vi.waitFor(() => { expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) @@ -469,7 +469,7 @@ describe('Remote Agent and Session lookup policy', () => { const resume = vi.spyOn(ctx.agents, 'resume') const defaultAgentLookup = ctx.typert.lookups.get('agent') const defaultSessionLookup = ctx.typert.lookups.get('session') - createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) await vi.waitFor(() => { expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) @@ -511,7 +511,7 @@ describe('Remote Agent and Session lookup policy', () => { return { agent: published, dispose: () => Promise.resolve() } }) const defaultLookup = ctx.typert.lookups.get('agent') - createSessionTestRemote(ctx, { + await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -562,8 +562,8 @@ describe('subagent ownership fence', () => { inspect, }) const resume = vi.spyOn(ctx.agents, 'resume') - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - ctx.sessionProjections.register(subagentIdentityProjectionDefinition) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + await ctx.sessionProjections.register(subagentIdentityProjectionDefinition) const history = await new SessionHistoryController( ctx, @@ -629,7 +629,7 @@ describe('subagent ownership fence', () => { // answering `agent-busy`. const resume = vi.spyOn(ctx.agents, 'resume') .mockRejectedValue(new Error('registry unavailable in this bench')) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const prompt = await remote.prompt(promptRequest({ sessionId, @@ -669,7 +669,7 @@ describe('subagent ownership fence', () => { }) const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent ctx.agents.enter(startingChild, parent) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const stopped = await remote.cancel(request({ sessionId: originChild.id })) expect(stopped.ok).toBe(false) @@ -719,7 +719,7 @@ describe('subagent ownership fence', () => { id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup, } as unknown as Agent await ctx.agents.register(agent) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const response = await remote.prompt(promptRequest({ sessionId: agent.id, @@ -740,7 +740,7 @@ describe('subagent ownership fence', () => { id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup, } as unknown as Agent await ctx.agents.register(agent) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -805,7 +805,7 @@ describe('degenerate composition (no persistence, no factory)', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const listed = await remote.list(request({})) expect(listed.ok).toBe(true) @@ -833,7 +833,7 @@ describe('degenerate composition (no persistence, no factory)', () => { stat, inspect, }) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const response = await remote.page({ address: { kind: 'session', sessionId: sid('session-missing') }, @@ -883,7 +883,7 @@ describe('sessions.prompt synchronous rejection', () => { provider: 'p', id: 'm', name: 'Model', inputModalities: ['text', 'image'], }), } as never) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -960,7 +960,7 @@ describe('sessions.prompt synchronous rejection', () => { followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, } as unknown as Agent) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) for (const mode of ['queue', 'steer'] as const) { const response = await remote.prompt(promptRequest({ @@ -1001,7 +1001,7 @@ describe('sessions.prompt synchronous rejection', () => { await ctx.agents.register(child) throw new Error('session id already published') }) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const selection = await remote.selectModel(request({ sessionId, provider: 'p', model: 'm' })) expect(selection.ok).toBe(false) diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts index e6ba8321ce..87fbd2456e 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -90,7 +90,7 @@ async function liveAgent( return session } -const remote = (ctx: Context) => createSessionTestRemote(ctx, { +const remote = async (ctx: Context) => await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'default-provider', model: 'default-model' }), cwd: '/tmp', }) @@ -120,7 +120,7 @@ describe('sessions.fork', () => { }) as never) const open = vi.spyOn(ctx.sessionPersistence, 'open') - const response = await remote(ctx).fork({ + const response = await (await remote(ctx)).fork({ sessionId: source.id, ...allowMigration === undefined ? {} : { allowMigration }, }) @@ -150,7 +150,7 @@ describe('sessions.fork', () => { const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') const create = vi.spyOn(ctx.agents, 'create') - await expect(remote(ctx).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({ + await expect((await remote(ctx)).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({ ok: false, error: { code: 'session/migration-required', details: { sessionId: sourceId } }, }) expect(stat).toHaveBeenCalledExactlyOnceWith(sourceId) @@ -171,7 +171,7 @@ describe('sessions.fork', () => { const stat = vi.fn(() => Promise.reject(new Error('unexpected metadata read'))) ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never) - const result = await remote(ctx).fork({ sessionId: source.id, allowMigration: false }) + const result = await (await remote(ctx)).fork({ sessionId: source.id, allowMigration: false }) if (!result.ok) throw result.error expect(stat).not.toHaveBeenCalled() @@ -199,7 +199,7 @@ describe('sessions.fork', () => { return finish.promise }) ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never) - const operation = remote(ctx).fork({ sessionId: sourceId, allowMigration: false }) + const operation = (await remote(ctx)).fork({ sessionId: sourceId, allowMigration: false }) try { await entered.promise const live = ctx.sessions.create(sourceId, { meta: source.header }) @@ -229,7 +229,7 @@ describe('sessions.fork', () => { const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') const create = vi.spyOn(ctx.agents, 'create') - await expect(remote(ctx).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({ + await expect((await remote(ctx)).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({ ok: false, error: { code: 'gateway/internal', message: 'source metadata unavailable' }, }) @@ -262,9 +262,9 @@ describe('sessions.fork', () => { expect(compacted.at(-1)).toMatchObject({ type: 'compaction/end', data: { turn: null } }) source.inbox.append('next-turn', message('queued after compaction')) - const response = await createSessionTestRemote(ctx, { + const response = await (await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'mock', model: 'mock' }), cwd: '/tmp', - }).fork({ sessionId: source.id }) + })).fork({ sessionId: source.id }) if (!response.ok) throw response.error const child = ctx.agents.get(response.value.sessionId)! @@ -304,9 +304,9 @@ describe('sessions.fork', () => { } const original = source.session.snapshotEvents() const atSeq = anchor === 'omitted' ? undefined : boundary - const response = await createSessionTestRemote(ctx, { + const response = await (await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'mock', model: 'mock' }), cwd: '/tmp', - }).fork({ sessionId: source.id, ...(atSeq === undefined ? {} : { atSeq }) }) + })).fork({ sessionId: source.id, ...(atSeq === undefined ? {} : { atSeq }) }) if (!response.ok) throw response.error const child = ctx.agents.get(response.value.sessionId)! const requestCount = adapter.requests.length @@ -327,7 +327,7 @@ describe('sessions.fork', () => { it('cuts exactly at a completed turn and records lineage and cwd', async () => { const ctx = await composed() const source = await liveAgent(ctx, 'session-source', 2) - const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: 2 })) + const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: 2 })) expect(response.ok ? null : response.error).toBeNull() if (!response.ok) return const child = ctx.sessions.get(response.value.sessionId) @@ -369,7 +369,7 @@ describe('sessions.fork', () => { root: { header: owner.header, live: true, persisted: false }, }) - const response = await remote(ctx).fork(request({ sessionId: grandchild.id })) + const response = await (await remote(ctx)).fork(request({ sessionId: grandchild.id })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return @@ -424,7 +424,7 @@ describe('sessions.fork', () => { }) as never) const resume = vi.spyOn(ctx.agents, 'resume') - const response = await remote(ctx).fork(request({ sessionId: sourceId })) + const response = await (await remote(ctx)).fork(request({ sessionId: sourceId })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return @@ -441,7 +441,7 @@ describe('sessions.fork', () => { it('uses the latest completed prefix only when atSeq is omitted', async () => { const ctx = await composed() const source = await liveAgent(ctx, 'session-tail', 2, 'open') - const proxy = remote(ctx) + const proxy = await remote(ctx) const omitted = await proxy.fork(request({ sessionId: source.id })) expect(omitted.ok).toBe(true) if (omitted.ok) { @@ -464,7 +464,7 @@ describe('sessions.fork', () => { it('rejects an omitted boundary when no turn has completed', async () => { const ctx = await composed() const source = await liveAgent(ctx, 'session-never-completed', 0, 'open') - const response = await remote(ctx).fork(request({ sessionId: source.id })) + const response = await (await remote(ctx)).fork(request({ sessionId: source.id })) expect(response).toMatchObject({ ok: false, error: { code: 'session/fork-unavailable', details: { sessionId: source.id } }, @@ -481,7 +481,7 @@ describe('sessions.fork', () => { const inherited = source.snapshotEvents() if (openNextTurn) source.append('turn/start', { turn: 2 }) - const response = await remote(ctx).fork({ sessionId: source.id }) + const response = await (await remote(ctx)).fork({ sessionId: source.id }) if (!response.ok) throw response.error const child = ctx.sessions.get(response.value.sessionId)! @@ -496,7 +496,7 @@ describe('sessions.fork', () => { it('rejects invalid fork anchors before reading or creating a Session', async () => { const ctx = await composed() - const proxy = remote(ctx) + const proxy = await remote(ctx) for (const atSeq of [-1, 0.5]) { await expect(proxy.fork(request({ sessionId: sid('missing'), atSeq }))) @@ -510,7 +510,7 @@ describe('sessions.fork', () => { const ctx = await composed() const source = await liveAgent(ctx, 'session-aborted', 1, 'aborted') const anchor = (source.snapshotEvents().at(-1)?.seq ?? 0) - 1 - const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor })) + const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: anchor })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return const child = ctx.sessions.get(response.value.sessionId) @@ -527,7 +527,7 @@ describe('sessions.fork', () => { const ctx = await composed() const source = await liveAgent(ctx, 'session-open', 1, 'open') const boundary = source.snapshotEvents().at(-1)?.seq ?? 0 - const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: boundary })) + const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: boundary })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return const child = ctx.sessions.get(response.value.sessionId) @@ -548,7 +548,7 @@ describe('sessions.fork', () => { const boundary = source.snapshotEvents().at(-1)?.seq ?? 0 source.append('session/title', { title: 'after boundary', messageSeqs: [], source: { kind: 'fallback' } }) - const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: boundary })) + const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: boundary })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return @@ -604,7 +604,7 @@ describe('sessions.fork', () => { }) as never) const resume = vi.spyOn(ctx.agents, 'resume') - const response = await remote(ctx).fork(request({ sessionId: sourceId, atSeq: 4 })) + const response = await (await remote(ctx)).fork(request({ sessionId: sourceId, atSeq: 4 })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return @@ -656,7 +656,7 @@ describe('sessions.fork', () => { reason: 'change', }) source.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: boundary.seq })) + const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: boundary.seq })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return const child = ctx.agents.get(response.value.sessionId) diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts index 86ed42a678..bf0c163914 100644 --- a/packages/api/session-controller/tests/session-history-journal.host.spec.ts +++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts @@ -719,7 +719,7 @@ describe('Session history raw journal', () => { it('serves raw call and result entries without parsing tool arguments', async () => { const { ctx } = await harness() - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const start = session.append('turn/start', { turn: 1 }) const call = session.append('tool/call', { @@ -760,7 +760,7 @@ describe('Session history raw journal', () => { ])('paginates $name in one contiguous Turn window', async ({ replies, beforeMessage, firstTurn, steering, minMessages = 50 }) => { const { ctx } = await harness() onTestFinished(() => ctx.fiber.dispose()) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const starts: SessionEvent[] = [] const messages: SessionEvent[][] = [] @@ -818,7 +818,7 @@ describe('Session history raw journal', () => { it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => { const { ctx } = await harness() - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) const first = appendUserText(session, 'first prompt') @@ -872,7 +872,7 @@ describe('Session history raw journal', () => { it('paginates a message with a large embedded stream without expanding physical records', async () => { const { ctx } = await harness() - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) @@ -910,7 +910,7 @@ describe('Session history raw journal', () => { it('keeps an earlier declared source on the same message-aligned page', async () => { const { ctx } = await harness() - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const source = session.append('request/context', { provider: 'p', model: 'm' }) const laterSource = session.append('request/context', { provider: 'p', model: 'm' }) @@ -931,7 +931,7 @@ describe('Session history raw journal', () => { it('keeps compact reasoning and tool-call runs nested in one attempt event', async () => { const { ctx } = await harness() - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const callId = ToolCallId('packed-call') const attempt = session.append('assistant/attempt', { diff --git a/packages/api/session-controller/tests/session-list-blank.host.spec.ts b/packages/api/session-controller/tests/session-list-blank.host.spec.ts index fce236d3e3..7ea81ea4bc 100644 --- a/packages/api/session-controller/tests/session-list-blank.host.spec.ts +++ b/packages/api/session-controller/tests/session-list-blank.host.spec.ts @@ -25,7 +25,7 @@ async function harness(): Promise<{ ctx: Context; remote: TestSessionRemote; att await ctx.plugin(AgentRegistry) return { ctx, - remote: createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }), + remote: await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }), attach: async (session) => { await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) }, diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index e852460e93..6af6983f70 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -183,7 +183,7 @@ describe('Web session model selection', () => { ctx.provide('attachments', Object.setPrototypeOf(attachments, AttachmentStore.prototype) as never) const followup = vi.fn() Object.assign(agent, { followup }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -251,7 +251,7 @@ describe('Web session model selection', () => { const steer = vi.fn() const followup = vi.fn() Object.assign(agent, { steer, followup }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -280,7 +280,7 @@ describe('Web session model selection', () => { it('allows a text-only selection while durable or pending images remain available for later models', async () => { const { ctx, agent, sessionId } = await harness() registerTextOnly(ctx) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -318,7 +318,7 @@ describe('Web session model selection', () => { } const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) })) ctx.provide('attachments', { readImage } as never) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -394,7 +394,7 @@ describe('Web session model selection', () => { value: { providers: { custom: { apiKeyEnv: 'CUSTOM_API_KEY', models: [] } } }, }]) await ctx.agentDefaultModel.saveSelection({ provider: 'removed', model: 'saved' }) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), cwd: '/tmp', }) await controller.initializeDefaultModel() @@ -411,7 +411,7 @@ describe('Web session model selection', () => { model: 'private-preview', reasoningEffort: ReasoningEffortId('max'), }) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) const catalog = expectValue(await remote.modelCatalog()) expect(currentSelection(ctx, sessionId)).toEqual({ @@ -460,7 +460,7 @@ describe('Web session model selection', () => { return Promise.reject('string catalog failure') } }('String Failure', [])) - createSessionTestRemote(ctx, { + await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -488,7 +488,7 @@ describe('Web session model selection', () => { it('rejects unlisted models and switches available models only after the next assembly', async () => { const { ctx, agent, sessionId } = await harness() - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } const signal = new AbortController().signal @@ -567,7 +567,7 @@ describe('Web session model selection', () => { it('reads the Agent default live for a session whose log names no selection', async () => { const { ctx, sessionId } = await harness() let stored = { provider: 'deepseek-official', model: 'deepseek-chat' } - createSessionTestRemote(ctx, { + await createSessionTestRemote(ctx, { defaultModelSelection: () => stored, cwd: '/tmp', }) @@ -589,7 +589,7 @@ describe('Web session model selection', () => { model: 'deepseek-chat', }) let stored = { provider: 'deepseek-official', model: 'deepseek-chat' } - createSessionTestRemote(ctx, { + await createSessionTestRemote(ctx, { defaultModelSelection: () => stored, cwd: '/tmp', }) @@ -607,12 +607,14 @@ describe('Web session model selection', () => { reasoningEffort: ReasoningEffortId('high'), adapterDefaults: { reasoningEffort: true }, }) - createSessionTestRemote(ctx, { + await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'duplicate', model: 'same' }), cwd: '/tmp', }) - expect(new ApiSessionAgentController(ctx).selectionFor(agent).current) + const controller = new ApiSessionAgentController(ctx) + await controller.initialize() + expect(controller.selectionFor(agent).current) .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) await ctx.fiber.dispose() }) @@ -627,7 +629,7 @@ describe('Web session model selection', () => { await Promise.allSettled([...operations, ...saves]) await ctx.fiber.dispose() }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), saveDefaultModelSelection: () => { saves.push(release.promise) @@ -653,7 +655,7 @@ describe('Web session model selection', () => { onTestFinished(() => { warn.mockRestore() }) const saved: unknown[] = [] let reject = false - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), saveDefaultModelSelection: (selection) => { saved.push(selection) @@ -692,7 +694,7 @@ describe('Web session model selection', () => { const { ctx, sessionId, agent } = await harness() const followup = vi.fn() Object.assign(agent, { followup }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) const list = vi.spyOn(ctx.llm, 'listModels') @@ -716,7 +718,7 @@ describe('Web session model selection', () => { }()) try { await expect(ctx.llm.resolveModelInfo('uncatalogued', 'custom-model')).resolves.toMatchObject({ id: 'custom-model' }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'uncatalogued', model: 'custom-model' }), cwd: '/tmp', }) expect(await remote.selectModel({ sessionId, provider: 'uncatalogued', model: 'custom-model' })) @@ -730,7 +732,7 @@ describe('Web session model selection', () => { const { ctx, sessionId, agent } = await harness() const followup = vi.fn() Object.assign(agent, { followup }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), cwd: '/tmp', }) @@ -752,7 +754,7 @@ describe('Web session model selection', () => { it('admits a removed model without changing its saved selection', async () => { const { ctx, sessionId, agent } = await harness({ provider: 'deepseek-official', model: 'removed-model' }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) const events = [...agent.session.snapshotEvents()] @@ -768,7 +770,7 @@ describe('Web session model selection', () => { it('serves a session and its catalog when the stored default names a route that is gone', async () => { const { ctx, sessionId } = await harness() - createSessionTestRemote(ctx, { + await createSessionTestRemote(ctx, { // What a Models-page removal leaves behind: the settings document still // names the route the user last picked, and nothing serves it. defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), @@ -816,7 +818,7 @@ describe('Web session model selection', () => { }, AttachmentStore.prototype) as never) const followup = vi.fn() Object.assign(agent, { followup }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -887,7 +889,7 @@ it('initializes the account model without reasoning metadata and rejects an empt const dispose = ctx.llm.registerAdapter(['deepseek-account'], new CatalogAdapter('Account', [ { provider: 'deepseek-account', id: 'basic', name: 'Basic' }, ])) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), cwd: '/tmp', }) await controller.initializeDefaultModel() diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index e8216b5214..a2ab39254a 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -23,7 +23,7 @@ async function context(): Promise { describe('session/openWorkspacePath', () => { it('reports the deployment opener capability independently of a Session', async () => { const ctx = await context() - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', canOpenPath: () => false, @@ -33,21 +33,21 @@ describe('session/openWorkspacePath', () => { }) it('derives opener availability from config, an injected opener, or the platform probe', async () => { - const configured = createSessionTestRemote(await context(), { + const configured = await createSessionTestRemote(await context(), { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: false, }) await expect(configured.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: false }) - const injected = createSessionTestRemote(await context(), { + const injected = await createSessionTestRemote(await context(), { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath: () => Promise.resolve(), }) await expect(injected.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: true }) - const detected = createSessionTestRemote(await context(), { + const detected = await createSessionTestRemote(await context(), { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', }) @@ -57,7 +57,7 @@ describe('session/openWorkspacePath', () => { it('hands a Client-resolved workspace path to the Host opener unchanged', async () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, @@ -73,7 +73,7 @@ describe('session/openWorkspacePath', () => { it('normalizes relative and absolute Host-resolvable paths', async () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, @@ -87,7 +87,7 @@ describe('session/openWorkspacePath', () => { it('rejects empty paths before opening anything', async () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, @@ -102,7 +102,7 @@ describe('session/openWorkspacePath', () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.reject(new Error('Command failed: powershell.exe -EncodedCommand private-script-text'))) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, @@ -129,7 +129,7 @@ describe('session/openWorkspacePath', () => { throw new Error('opening stopped') }) .mockRejectedValueOnce('desktop unavailable') - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, @@ -150,7 +150,7 @@ it('reports Host file-manager metadata and dispatches reveal separately from def const ctx = await context() const revealPath = vi.fn(async (_path: string, _signal: AbortSignal) => {}) const openPath = vi.fn(async (_path: string, _signal: AbortSignal) => {}) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, revealPath, }) try { @@ -167,7 +167,7 @@ it('uses the native reveal adapter without a test override and respects unsuppor const reveal = vi.spyOn(nativeCommand, 'revealNativePath').mockResolvedValue(undefined) const manager = vi.spyOn(nativeCommand, 'nativeFileManager').mockReturnValue(null) try { - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, }) expect(controller.workspaceDesktop()).toMatchObject({ available: false, fileManager: null }) @@ -183,7 +183,7 @@ it.each(['open', 'reveal'] as const)('rejects an unmapped remote path before nat onTestFinished(() => { mapping.mockRestore() }) const openPath = vi.fn(async () => {}) const revealPath = vi.fn(async () => {}) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, revealPath, }) await expect(controller.openWorkspacePath({ path: '/remote/report.html', ...(action === 'reveal' ? { action } : {}) }, new AbortController().signal)) @@ -197,7 +197,7 @@ it('rejects a filesystem mapping that resolves to another process path', async ( const mapping = vi.spyOn(ctx.fs, 'processPath').mockReturnValue('/different/report.html') onTestFinished(() => { mapping.mockRestore() }) const openPath = vi.fn(async () => {}) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, }) await expect(controller.openWorkspacePath({ path: '/report.html' }, new AbortController().signal)) @@ -209,7 +209,7 @@ it('dispatches default-app opening to the association adapter', async () => { const ctx = await context() const open = vi.spyOn(nativeCommand, 'openNativeAssociatedPath').mockResolvedValue(undefined) onTestFinished(() => { open.mockRestore() }) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, }) const signal = new AbortController().signal @@ -223,7 +223,7 @@ it('uses the same path verification for handler queries and explicit application const applications = [{ id: '/Applications/Music.app', name: 'Music', default: true, icon: null }] const fileApplications = vi.fn(async () => applications) const openFileApplication = vi.fn(async () => {}) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, fileApplications, openFileApplication, }) @@ -242,11 +242,11 @@ it('uses the same path verification for handler queries and explicit application it('avoids desktop queries when unavailable and rejects an empty query path', async () => { const fileApplications = vi.fn(async () => []) - const unavailable = createSessionTestController(await context(), { + const unavailable = await createSessionTestController(await context(), { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: false, fileApplications, }) expect(await unavailable.workspacePathApplications({ path: '/file.mp3' }, new AbortController().signal)).toEqual([]) - const available = createSessionTestController(await context(), { + const available = await createSessionTestController(await context(), { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, fileApplications, }) await expect(available.workspacePathApplications({ path: '' }, new AbortController().signal)).rejects.toMatchObject({ code: 'gateway/bad-request' }) @@ -262,7 +262,7 @@ it('returns bounded query failures and classifies cancellation through the Remot lifetime.abort() throw new Error('native query stopped') }) - const remote = createSessionTestRemote(await context(), { + const remote = await createSessionTestRemote(await context(), { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, fileApplications, }) await expect(remote.workspacePathApplications({ path: '/file.mp3' })).resolves.toMatchObject({ diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts index 5406d235f8..e39c7ea7d2 100644 --- a/packages/api/session-controller/tests/session-presets.host.spec.ts +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -75,11 +75,11 @@ async function harness(presets?: readonly string[]) { }, } ctx.agents.setFactory(factory) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), cwd, }) - if (presets !== undefined) ctx.sessionProjections.register(agentPresetProjectionDefinition) + if (presets !== undefined) await ctx.sessionProjections.register(agentPresetProjectionDefinition) return { ctx, remote } } diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index ed4f4a6b0c..0b565dfd33 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -160,7 +160,7 @@ function seedMessages(session: Session, count: number): void { } } -const remote = (ctx: Context) => createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) +const remote = async (ctx: Context) => await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) describe('session.history projections block', () => { it('keeps the v0 numeric seed cut on the wire while logical headers expose only lineage', async () => { @@ -182,7 +182,7 @@ describe('session.history projections block', () => { }, }) - const snapshot = await opening(remote(ctx), child.id) + const snapshot = await opening(await remote(ctx), child.id) expect(snapshot.header).toEqual({ version: SESSION_FORMAT_VERSION, @@ -197,7 +197,7 @@ describe('session.history projections block', () => { it('tracks pending and used model selections across repeated request headers', async () => { const { ctx, session } = await harness(true) - remote(ctx) + await remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) const selected = { provider: 'p', model: 'next' } session.append('model/selection', selected) @@ -225,9 +225,9 @@ describe('session.history projections block', () => { it('serves the unit value on the tail page with asOfSeq = last event seq', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(lastUserUnit()) + await ctx.sessionProjections.register(lastUserUnit()) seedMessages(session, 3) - const snapshot = await opening(remote(ctx), session.id) + const snapshot = await opening(await remote(ctx), session.id) const { records, projections } = snapshot expect(projections.asOfSeq).toBe(session.seq - 1) expect(projections.values['test/last-user']).toEqual({ text: 'm2' }) @@ -257,7 +257,7 @@ describe('session.history projections block', () => { list: () => Promise.resolve([meta]), inspect: () => Promise.resolve({ meta, events, inheritedEventCount: SessionLogOffset(0) }), }) as never) - const snapshot = await opening(remote(ctx), coldId) + const snapshot = await opening(await remote(ctx), coldId) expect(snapshot.projections.values.inbox).toEqual({ 'next-turn': [message], @@ -269,7 +269,7 @@ describe('session.history projections block', () => { it('removes claimed steering from the pending Inbox projection immediately', async () => { const { ctx, session, claim } = await harness(true) - const proxy = remote(ctx) + const proxy = await remote(ctx) const message = createUserMessage({ content: [{ type: 'text', text: 'apply this now' }], source: { kind: 'user' }, @@ -309,10 +309,10 @@ describe('session.history projections block', () => { it('returns a complete current replacement cut on each follow generation', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(lastUserUnit()) + await ctx.sessionProjections.register(lastUserUnit()) seedMessages(session, 2) - const snapshot = await opening(remote(ctx), session.id) + const snapshot = await opening(await remote(ctx), session.id) expect(snapshot.records.map(record => record.event.seq)).toEqual([0, 1]) expect(snapshot.projections.asOfSeq).toBe(1) @@ -323,9 +323,9 @@ describe('session.history projections block', () => { it('projects an empty log at cursor -1', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(lastUserUnit()) + await ctx.sessionProjections.register(lastUserUnit()) - const snapshot = await opening(remote(ctx), session.id) + const snapshot = await opening(await remote(ctx), session.id) expect(snapshot.records).toEqual([]) expect(snapshot.projections.asOfSeq).toBe(-1) @@ -350,7 +350,7 @@ describe('session.history projections block', () => { saveImage(): Promise { return Promise.reject(new Error('unused')) } readImage(): Promise { return Promise.reject(new Error('unused')) } }) - const gateway = remote(ctx) + const gateway = await remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) seedMessages(session, 2) const snapshot = await opening(gateway, session.id) @@ -377,18 +377,18 @@ describe('session.history projections block', () => { await expect(extra).resolves.toEqual({ done: true, value: undefined }) }) - it('leaves the imageLimits key absent while no attachment service is composed', async () => { + it("publishes image limits from the Controller's attachment dependency", async () => { const { ctx, session } = await harness(true) seedMessages(session, 1) - const snapshot = await opening(remote(ctx), session.id) - expect('imageLimits' in snapshot.projections.values).toBe(false) + const snapshot = await opening(await remote(ctx), session.id) + expect(snapshot.projections.values.imageLimits).toEqual(ctx.attachments.imageLimits) }) it('never carries the block on loadOlder pages (beforeSeq present)', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(lastUserUnit()) + await ctx.sessionProjections.register(lastUserUnit()) seedMessages(session, 5) - const older = await page(remote(ctx), request({ + const older = await page(await remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1, beforeSeq: 3, maxMessages: 2, })) expect(older.ok).toBe(true) @@ -399,7 +399,7 @@ describe('session.history projections block', () => { it('serves no block when the composition has no projection registry', async () => { const { ctx, session } = await harness(false) seedMessages(session, 2) - const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 })) + const response = await page(await remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 })) expect(response.ok).toBe(true) if (!response.ok) throw new Error('unreachable') expect('projections' in response.value).toBe(false) @@ -407,8 +407,8 @@ describe('session.history projections block', () => { it('never exposes a host-only unit through history, listing, or push frames', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(internalCountUnit()) - const proxy = remote(ctx) + await ctx.sessionProjections.register(internalCountUnit()) + const proxy = await remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) const abort = new AbortController() const iterator = proxy.control(abort.signal)[Symbol.asyncIterator]() @@ -438,13 +438,13 @@ describe('session.history projections block', () => { it('drops a disposed registration from subsequent tail pages (empty block, key absent)', async () => { const { ctx, session } = await harness(true) - const dispose = ctx.sessionProjections.register(lastUserUnit()) + const dispose = await ctx.sessionProjections.register(lastUserUnit()) seedMessages(session, 1) - const proxy = remote(ctx) + const proxy = await remote(ctx) const before = await opening(proxy, session.id) expect(before.projections.values['test/last-user']).toEqual({ text: 'm0' }) - dispose() + await dispose.dispose() const after = await opening(proxy, session.id) // The registry stays mounted; only the disposed key leaves while the // gateway-owned Session-list unit remains. @@ -459,8 +459,8 @@ describe('session.history projections block', () => { it('removes the gateway-owned Session-list unit when the gateway fiber unloads', async () => { const { ctx, session } = await harness(true) expect('sessionListMetadata' in ctx.sessionProjections.snapshot(session).values).toBe(false) - const fiber = ctx.plugin(Object.assign((gatewayCtx: Context) => { - createSessionTestRemote(gatewayCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const fiber = ctx.plugin(Object.assign(async (gatewayCtx: Context) => { + await createSessionTestRemote(gatewayCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) }, { inject: ['sessions', 'agents', 'sessionProjections'] })) await fiber.await() await vi.waitFor(() => { @@ -475,8 +475,8 @@ describe('session.history projections block', () => { describe('session.list projections column', () => { it('serves every already-materialized wire value from the live registry without folding', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(lastUserUnit()) - const gateway = remote(ctx) + await ctx.sessionProjections.register(lastUserUnit()) + const gateway = await remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) session.append('turn/start', { turn: 1 }) seedMessages(session, 1) @@ -496,8 +496,8 @@ describe('session.list projections column', () => { const session = ctx.sessions.create(SessionId('preset-list'), { meta: { cwd: '/workspace', agentPreset: 'standard' }, }) - ctx.sessionProjections.register(agentPresetProjectionDefinition) - const gateway = remote(ctx) + await ctx.sessionProjections.register(agentPresetProjectionDefinition) + const gateway = await remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) session.append('agent-preset/selected', { agentPreset: 'minimal' }) @@ -512,9 +512,9 @@ describe('session.list projections column', () => { seedMessages(session, 1) const unit = lastUserUnit() const apply = vi.fn(unit.apply) - ctx.sessionProjections.register({ ...unit, apply }) + await ctx.sessionProjections.register({ ...unit, apply }) - const response = await remote(ctx).list(request({})) + const response = await (await remote(ctx)).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === session.id) expect(row).toBeDefined() @@ -525,7 +525,7 @@ describe('session.list projections column', () => { it('omits the column entirely when no registry is mounted', async () => { const { ctx, session } = await harness(false) seedMessages(session, 1) - const response = await remote(ctx).list(request({})) + const response = await (await remote(ctx)).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === session.id) expect(row).toBeDefined() @@ -555,7 +555,7 @@ describe('session.list projections column', () => { } : undefined), } as never) - const response = await remote(ctx).list(request({})) + const response = await (await remote(ctx)).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === coldId) expect(row?.running).toBe(false) @@ -580,9 +580,9 @@ describe('session.list projections column', () => { await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(privatePromptUnit()) + await ctx.sessionProjections.register(privatePromptUnit()) await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) - const gateway = remote(ctx) + const gateway = await remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) const id = SessionId('session-cold-host-state') @@ -635,7 +635,7 @@ describe('session.list projections column', () => { await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(titleProjectionDefinition) + await ctx.sessionProjections.register(titleProjectionDefinition) await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) return ctx } @@ -647,7 +647,7 @@ describe('session.list projections column', () => { let lastSeq: number | undefined let promptTime: number | undefined try { - remote(first) + await remote(first) await new Promise(resolve => setTimeout(resolve, 0)) let child: Session | undefined const owner = await first.plugin(Object.assign((sessionCtx: Context) => { @@ -679,7 +679,7 @@ describe('session.list projections column', () => { // record, and nothing opens the log. const second = await boot() try { - const gateway = remote(second) + const gateway = await remote(second) await new Promise(resolve => setTimeout(resolve, 0)) const load = () => { throw new Error('a cold fork listing must not load the event log') } second.provide('sessionPersistence', testSessionPersistence(second, { @@ -712,7 +712,7 @@ describe('session.list projections column', () => { ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: async () => [{ version: SESSION_FORMAT_VERSION, id: coldId, createdAt: 5, isSeeded: false, cwd: '/tmp' }], }) as never) - const response = await remote(ctx).list(request({})) + const response = await (await remote(ctx)).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === coldId) expect(row).toBeDefined() @@ -721,7 +721,7 @@ describe('session.list projections column', () => { it('a throwing column read degrades that row, never the listing', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register({ + await ctx.sessionProjections.register({ ...lastUserUnit(), wire: { viewSchema: z.union([z.object({ text: z.string() }), z.null()]), @@ -729,7 +729,7 @@ describe('session.list projections column', () => { }, }) seedMessages(session, 1) - const response = await remote(ctx).list(request({})) + const response = await (await remote(ctx)).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === session.id) expect(row).toBeDefined() @@ -754,8 +754,8 @@ describe('Session control projection frames', () => { it('broadcasts changed view references with the causing seq and skips same-reference applies', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(lastUserUnit()) - const proxy = remote(ctx) + await ctx.sessionProjections.register(lastUserUnit()) + const proxy = await remote(ctx) // The controller's onChanged subscription lives in an inject child whose // fiber activates asynchronously; yield until it lands before appending. await new Promise(resolve => setTimeout(resolve, 0)) diff --git a/packages/api/session-controller/tests/session-rename.host.spec.ts b/packages/api/session-controller/tests/session-rename.host.spec.ts index 0e4754d8b0..c9f9502331 100644 --- a/packages/api/session-controller/tests/session-rename.host.spec.ts +++ b/packages/api/session-controller/tests/session-rename.host.spec.ts @@ -65,14 +65,14 @@ async function liveAgent(ctx: Context, id: string, turns: number): Promise createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) +const remote = async (ctx: Context) => await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) describe('sessions.rename', () => { it('accepts through the composed title service: normalized user-source event, echoed seq', async () => { const ctx = await composed() const source = await liveAgent(ctx, 'session-rename', 1) - const renamed = await remote(ctx).rename(request({ sessionId: source.id, title: ' new name ' })) + const renamed = await (await remote(ctx)).rename(request({ sessionId: source.id, title: ' new name ' })) expect(renamed.ok).toBe(true) if (!renamed.ok) return expect(renamed.value.title).toBe('new name') @@ -86,7 +86,7 @@ describe('sessions.rename', () => { const source = await liveAgent(ctx, 'session-rename-bad', 1) // U+200B passes a client-side trim gate but normalizes to empty host-side. - const response = await remote(ctx).rename(request({ sessionId: source.id, title: ' ​ ' })) + const response = await (await remote(ctx)).rename(request({ sessionId: source.id, title: ' ​ ' })) expect(response.ok).toBe(false) if (!response.ok) { expect(response.error).toMatchObject({ @@ -107,7 +107,7 @@ describe('sessions.rename', () => { const stale = await liveAgent(foreign, 'session-rename-stale', 1) await ctx.agents.register({ id: stale.id, session: stale, status: 'idle', ctx } as Agent) - const response = await remote(ctx).rename(request({ sessionId: stale.id, title: 'name' })) + const response = await (await remote(ctx)).rename(request({ sessionId: stale.id, title: 'name' })) expect(response.ok).toBe(false) if (!response.ok) expect(response.error.code).toBe('gateway/internal') }) @@ -116,7 +116,7 @@ describe('sessions.rename', () => { const ctx = await composed(false) const source = await liveAgent(ctx, 'session-no-titles', 1) - const response = await remote(ctx).rename(request({ sessionId: source.id, title: 'name' })) + const response = await (await remote(ctx)).rename(request({ sessionId: source.id, title: 'name' })) expect(response.ok).toBe(false) if (!response.ok) { expect(response.error.code).toBe('gateway/internal') diff --git a/packages/api/session-controller/tests/session-search.host.spec.ts b/packages/api/session-controller/tests/session-search.host.spec.ts index d8690ed0a0..b96b6e9463 100644 --- a/packages/api/session-controller/tests/session-search.host.spec.ts +++ b/packages/api/session-controller/tests/session-search.host.spec.ts @@ -17,7 +17,7 @@ import { type SessionSearchHit, type SessionSearchRequest, } from '@deepseek-ai/dsh-session-query' -import { createSessionTestRemote, testSessionPersistence } from './test-remote.ts' +import { createSessionTestRemote, testSessionPersistence, TEST_IMAGE_LIMITS } from './test-remote.ts' import { ApiSessionList } from '../src/list.ts' const sid = (value: string): SessionId => value as SessionId @@ -96,7 +96,9 @@ function installSearchQuery( describe('session.search', () => { it('rejects search when the query service is absent', async () => { const ctx = await baseContext() + ctx.provide('attachments', { imageLimits: TEST_IMAGE_LIMITS } as never) const list = new ApiSessionList(ctx, 8) + await list.initialize() await expect(list.search('query', new AbortController().signal)).rejects.toMatchObject({ code: 'gateway/internal', @@ -151,7 +153,7 @@ describe('session.search', () => { ], })) installSearchQuery(ctx, searchSessions) - const remote = createSessionTestRemote(ctx, defaults) + const remote = await createSessionTestRemote(ctx, defaults) const signal = new AbortController().signal const response = await remote.search(request(' matching answer '), signal) @@ -187,7 +189,7 @@ describe('session.search', () => { ctx.sessions.create(sid('visible'), { meta: header('visible') }) const searchSessions = vi.fn() installSearchQuery(ctx, searchSessions) - const remote = createSessionTestRemote(ctx, defaults) + const remote = await createSessionTestRemote(ctx, defaults) for (const query of ['', ' ', 'contains\0nul', 'x'.repeat(501)]) { await expect(remote.search(request(query), new AbortController().signal)) @@ -201,7 +203,7 @@ describe('session.search', () => { const ctx = await baseContext() const searchSessions = vi.fn() installSearchQuery(ctx, searchSessions) - const remote = createSessionTestRemote(ctx, defaults) + const remote = await createSessionTestRemote(ctx, defaults) const response = await remote.search( request('anything'), @@ -235,7 +237,7 @@ describe('session.search', () => { ], })) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('match'), new AbortController().signal, ) @@ -262,7 +264,7 @@ describe('session.search', () => { }) .mockResolvedValueOnce({ items: items.slice(19) }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('match'), new AbortController().signal, ) @@ -303,7 +305,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('adaptive-page-limit'), new AbortController().signal, ) @@ -346,7 +348,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('endless-pages'), new AbortController().signal, ) @@ -411,7 +413,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('stale-restart'), new AbortController().signal, ) @@ -450,7 +452,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('stale-churn'), new AbortController().signal, ) @@ -479,7 +481,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('abort-stale'), controller.signal, ) @@ -500,7 +502,7 @@ describe('session.search', () => { ))) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('first-page-stale'), new AbortController().signal, ) @@ -524,7 +526,7 @@ describe('session.search', () => { )) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('continuation-invalid-limit'), new AbortController().signal, ) @@ -551,7 +553,7 @@ describe('session.search', () => { )) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('minimum-page-limit'), new AbortController().signal, ) @@ -577,7 +579,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('abort-invalid-limit'), controller.signal, ) @@ -596,7 +598,7 @@ describe('session.search', () => { const searchSessions = vi.fn(() => Promise.resolve({ items: oversized })) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('oversized-page'), new AbortController().signal, ) @@ -622,7 +624,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('adapted-oversized-page'), new AbortController().signal, ) @@ -648,7 +650,7 @@ describe('session.search', () => { } installSearchQuery(ctx, () => Promise.resolve({ items: [overlong] })) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('bounded-snippet'), new AbortController().signal, ) @@ -670,7 +672,7 @@ describe('session.search', () => { .mockResolvedValueOnce({ items: [], nextCursor: 'repeated' }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('repeated-cursor'), new AbortController().signal, ) @@ -693,7 +695,7 @@ describe('session.search', () => { .mockResolvedValueOnce({ items: items.slice(20), nextCursor: 'repeated' }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('repeated-lookahead-cursor'), new AbortController().signal, ) @@ -720,7 +722,7 @@ describe('session.search', () => { .mockResolvedValueOnce({ items: items.slice(20) }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('duplicate-pages'), new AbortController().signal, ) @@ -748,7 +750,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('cancel-continuation'), controller.signal, ) @@ -777,7 +779,7 @@ describe('session.search', () => { })) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('large corpus'), new AbortController().signal, ) @@ -813,7 +815,7 @@ describe('session.search', () => { const searchSessions = vi.fn() installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('cancel-during-visibility'), controller.signal, ) @@ -840,7 +842,7 @@ describe('session.search', () => { const searchSessions = vi.fn(() => Promise.resolve({ items: [] })) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('header-only-visibility'), new AbortController().signal, ) @@ -856,7 +858,7 @@ describe('session.search', () => { it('maps preflight cancellation, query cancellation, and provider failure', async () => { const missingCtx = await baseContext() missingCtx.sessions.create(sid('visible'), { meta: header('visible') }) - const missingApi = createSessionTestRemote(missingCtx, defaults) + const missingApi = await createSessionTestRemote(missingCtx, defaults) const preAborted = new AbortController() preAborted.abort() const cancelledBeforeLookup = await missingApi.search( @@ -875,7 +877,7 @@ describe('session.search', () => { .mockRejectedValueOnce(aborted) .mockRejectedValueOnce(new Error('database unavailable')) installSearchQuery(ctx, searchSessions) - const remote = createSessionTestRemote(ctx, defaults) + const remote = await createSessionTestRemote(ctx, defaults) const cancelled = await remote.search( request('first'), diff --git a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts index de1852991a..7d7d5b384f 100644 --- a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts +++ b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts @@ -59,7 +59,7 @@ async function bench() { installSessionReadTestServices(ctx) provideWorkingDirectoryFixture(ctx) await ctx.plugin(SubagentRuntime) - const controller = createSessionTestController(ctx, defaults) + const controller = await createSessionTestController(ctx, defaults) return { ctx, controller } } @@ -195,7 +195,7 @@ describe('SessionController subagent catalog', () => { await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) - const controller = createSessionTestController(ctx, defaults) + const controller = await createSessionTestController(ctx, defaults) const session = ctx.sessions.create(PARENT, { meta: { cwd: '/workspace' } }) const expected = ctx.sessionProjections.snapshot(session) expect(expected.values.subagentCatalog).toBeUndefined() diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 71db06b22b..3c4a7afa79 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -2,7 +2,7 @@ import type { SessionControllerInternals } from '../src/index.ts' import { SessionLogOffset } from '@deepseek-ai/dsh-session' -import type { Context } from '@deepseek-ai/cordis' +import { Service, type Context } from '@deepseek-ai/cordis' import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' import type { AdmittedPromptContentPart, @@ -103,11 +103,13 @@ export interface TestSessionRemoteDefaults { readonly openFileApplication?: SessionControllerInternals['openFileApplication'] readonly revealPath?: (path: string, signal: AbortSignal) => Promise readonly canOpenPath?: () => boolean + readonly listWorkSliceMs?: number } const installed = new WeakMap() -const TEST_IMAGE_LIMITS: ImageAttachmentLimits = Object.freeze({ +/** Image policy for controller fixtures without attachment I/O. */ +export const TEST_IMAGE_LIMITS: ImageAttachmentLimits = Object.freeze({ maxImageBytes: 5 * 1024 * 1024, maxImagesPerMessage: 20, maxMessageImageBytes: 100 * 1024 * 1024, @@ -231,10 +233,10 @@ export function installSessionReadTestServices(ctx: Context): void { if (ctx.get('sessionQuery') === undefined) new TestSessionQuery(ctx) } -function installControllers( +async function installControllers( ctx: Context, defaults: TestSessionRemoteDefaults, -): SessionController { +): Promise { const found = installed.get(ctx) if (found !== undefined) return found @@ -290,12 +292,18 @@ function installControllers( } installSessionReadTestServices(ctx) const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd) - let controller: SessionController + class TestController extends SessionController { + initialize(): Promise { + return this[Service.init]() + } + } + let controller: TestController try { - controller = new SessionController( + controller = new TestController( ctx, { ...defaults.nativeOpen === undefined ? {} : { nativeOpen: defaults.nativeOpen }, + ...defaults.listWorkSliceMs === undefined ? {} : { listWorkSliceMs: defaults.listWorkSliceMs }, }, { ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, @@ -305,6 +313,7 @@ function installControllers( ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath }, }, ) + await controller.initialize() } finally { cwd.mockRestore() } @@ -313,11 +322,11 @@ function installControllers( } /** Build or return the production Session Controller for a direct unit harness. */ -export function createSessionTestController( +export async function createSessionTestController( ctx: Context, defaults: TestSessionRemoteDefaults, -): SessionController { - return installControllers(ctx, defaults) +): Promise { + return await installControllers(ctx, defaults) } function remoteResult( @@ -341,11 +350,11 @@ function remoteResult( } /** Build the generated Session Remote's unary result semantics without a carrier. */ -export function createSessionTestRemote( +export async function createSessionTestRemote( ctx: Context, defaults: TestSessionRemoteDefaults, -): TestSessionRemote { - const direct = createSessionTestController(ctx, defaults) +): Promise { + const direct = await createSessionTestController(ctx, defaults) return { workspacePathApplications: (request, signal = new AbortController().signal) => remoteResult( () => direct.workspacePathApplications(request, signal), signal, diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index 890000a0ac..66b697fcef 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -76,7 +76,7 @@ async function setup(): Promise<{ ctx: Context; transport: SessionHistoryControl const ctx = new Context() await ctx.plugin(SessionStore) installSessionReadTestServices(ctx) - ctx.sessionProjections.register(subagentIdentityProjectionDefinition) + await ctx.sessionProjections.register(subagentIdentityProjectionDefinition) const transport = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) return { ctx, transport } } diff --git a/packages/context/agent-instructions/README.i18n.yaml b/packages/context/agent-instructions/README.i18n.yaml index 9fc0cb6eae..818bcda27b 100644 --- a/packages/context/agent-instructions/README.i18n.yaml +++ b/packages/context/agent-instructions/README.i18n.yaml @@ -30,8 +30,8 @@ en: 724df7ff6d1d9fea zh: 91fb1013eb9178ec /deepseek-ai-dsh-agent-instructions/understand-the-implementation/design-concept: - en: 4708b51bf8fe86f7 - zh: 869fc4b1f78383ee + en: 566f3518b2d1d100 + zh: c7d0a22b19e7697a /deepseek-ai-dsh-agent-instructions/understand-the-implementation/source-map: en: cb4c1f929ef5a835 zh: 3f0fcb7f568a0a5c diff --git a/packages/context/agent-instructions/README.md b/packages/context/agent-instructions/README.md index 25c48ba1ac..e935cedf8d 100644 --- a/packages/context/agent-instructions/README.md +++ b/packages/context/agent-instructions/README.md @@ -85,7 +85,7 @@ This section explains the design decisions behind the plugin; the observable beh ### Design concept -The plugin is built on one principle: workspace instructions are durable conversation content, owned per agent and per session. Baseline and refresh messages are ordinary sourced `user/message` events, so they replay, compact, and resume exactly like other history, and model-visible state is always reconstructable from the session log. The plugin owns the complete `` framing and every injected message reaches the model verbatim. +The plugin is built on one principle: workspace instructions are durable conversation content, owned per agent and per session. Baseline and refresh messages are ordinary sourced `user/message` events, so they replay, compact, and resume exactly like other history, and model-visible state is always reconstructable from the session log. The plugin owns the complete `` framing and every injected message reaches the model verbatim. The package exports `AgentInstructionSource` for typed consumers of these message sources. ### Source map diff --git a/packages/context/agent-instructions/README.zh.md b/packages/context/agent-instructions/README.zh.md index a87cfae53a..cf193f2a28 100644 --- a/packages/context/agent-instructions/README.zh.md +++ b/packages/context/agent-instructions/README.zh.md @@ -85,7 +85,7 @@ export interface Config { ### 设计理念 -该插件建立在一个原则上:工作区指令是持久的对话内容,按 agent 与会话分别归属。基线消息与刷新消息都是普通的带来源 `user/message` 事件,因此与其他历史一样可回放、可压缩(compaction)、可恢复,模型可见状态总能从会话日志重建。插件拥有完整的 `` 框架,每条注入消息都原样到达模型。 +该插件建立在一个原则上:工作区指令是持久的对话内容,按 agent 与会话分别归属。基线消息与刷新消息都是普通的带来源 `user/message` 事件,因此与其他历史一样可回放、可压缩(compaction)、可恢复,模型可见状态总能从会话日志重建。插件拥有完整的 `` 框架,每条注入消息都原样到达模型。 本包导出 `AgentInstructionSource`,供这些消息来源的消费者进行类型检查。 ### 源码地图 diff --git a/packages/context/agent-instructions/src/index.ts b/packages/context/agent-instructions/src/index.ts index 74d6e5fa22..b32a5e6bb7 100644 --- a/packages/context/agent-instructions/src/index.ts +++ b/packages/context/agent-instructions/src/index.ts @@ -9,6 +9,7 @@ * @module @deepseek-ai/dsh-agent-instructions */ +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-working-directory' import type { Context } from '@deepseek-ai/cordis' import { isDeepStrictEqual } from 'node:util' @@ -23,7 +24,6 @@ import { trimmedInstructionDigest } from './digest.ts' import { applyInstructionVersionUpdates, baselineInstructionState, - name, reconcileInstructionContext, agentInstructionsMessage, type InstructionVersionCache, @@ -31,9 +31,9 @@ import { } from './state.ts' import { instructionCandidateGroup, instructionScopeKey, scopeForDisplayPath, type AgentInstructionChange } from './render.ts' -export { Config, name } -/** Services required by workspace instruction projection. */ -export const inject = ['sessionProjections', 'workingDirectory'] +export { Config } +export { name } from './state.ts' +export type { AgentInstructionSource } from './state.ts' export { discoverBaselineInstructionFiles, loadBaselineInstructions, @@ -45,6 +45,8 @@ export type { export { renderAgentInstructions } from './render.ts' export type { RenderedAgentInstructions, TruncatedInstruction } from './render.ts' +export const inject = ['sessionProjections', 'workingDirectory'] + function visibleBaselineSource( agent: Agent, authorityMessages: readonly UserMessage[], @@ -83,7 +85,13 @@ function filePathFromExecution(exec: ToolExecution): string | undefined { return filePath.length > 0 ? filePath : undefined } -export function apply(ctx: Context, config: Config): void { +/** Register the agent-instructions plugin after its projections are ready. + * @param ctx - plugin context. + * @param config - workspace instruction policy. + * @returns when the plugin is ready. + */ +export async function apply(ctx: Context, config: Config): Promise { + const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) const resolved: ResolvedConfig = resolveConfig(config) const instructionVersions: InstructionVersionCache = new WeakMap() const baselinePreparations = new WeakMap { - const boundary = ctx.sessionProjections.stateOf(session, 'turnBoundary') - if (boundary === undefined) { - throw new Error('agent-instructions requires the turnBoundary session projection') - } + const boundary = turnBoundary.stateOf(session) return boundary.openTurnStartSeq !== null && boundary.lastStepBoundary?.kind === 'start' && boundary.lastStepBoundary.seq > boundary.openTurnStartSeq diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 721866df73..a4df1f6d21 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' +import type { Config as AgentInstructionsConfig } from '@deepseek-ai/dsh-agent-instructions' import * as AgentInstructions from '@deepseek-ai/dsh-agent-instructions' import LlmRuntime, { createUserMessage, ToolCallId, type Message, type MessageSource, type StreamChunk } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' @@ -236,19 +237,19 @@ class BlockingReadFileSystem extends RecordingFileSystem { } } -async function mountAgentInstructionsPlugin(ctx: Context, config: AgentInstructions.Config): Promise>> { +async function mountAgentInstructionsPlugin(ctx: Context, config: AgentInstructionsConfig): Promise>> { if (ctx.get('workingDirectory') === undefined) provideWorkingDirectoryFixture(ctx) if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) return ctx.plugin(AgentInstructions, config) } -async function mountAgentInstructions(ctx: Context, config: AgentInstructions.Config): Promise>> { +async function mountAgentInstructions(ctx: Context, config: AgentInstructionsConfig): Promise>> { await ctx.plugin(LocalFileSystem, { cwd: '/' }) return mountAgentInstructionsPlugin(ctx, config) } -async function mountFileToolsAndAgentInstructions(ctx: Context, config: AgentInstructions.Config): Promise>> { +async function mountFileToolsAndAgentInstructions(ctx: Context, config: AgentInstructionsConfig): Promise>> { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(LocalFileSystem, { cwd: '/' }) @@ -1234,7 +1235,7 @@ describe('workspace context request injection', () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) - await expect(mountAgentInstructionsPlugin(ctx, {} as AgentInstructions.Config)).rejects.toThrow(/maxBytes/) + await expect(mountAgentInstructionsPlugin(ctx, {} as AgentInstructionsConfig)).rejects.toThrow(/maxBytes/) }) it('mounts without requiring a filesystem provider', async () => { @@ -1251,28 +1252,6 @@ describe('workspace context request injection', () => { expect(AgentInstructions.inject).toEqual(['sessionProjections', 'workingDirectory']) }) - it('rejects a file-touch projection when the turn boundary unit is absent', async () => { - const ctx = new Context() - provideWorkingDirectoryFixture(ctx) - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(AgentInstructions, { maxBytes: 65536 }) - const exec = stubToolExecution({ - callId: ToolCallId('missing-turn-boundary'), - name: 'read', - arguments: { file_path: 'file.txt' }, - agent: await stubAgent('/virtual/repo'), - signal: testToolSignal, - }) - - expect(() => { - ctx.emit('tools/result', exec, { - content: [{ type: 'text', text: 'ok' }], - isError: false, - value: null, - }) - }).toThrow('agent-instructions requires the turnBoundary session projection') - }) - it('does not inject baseline context when no filesystem provider is present', async () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) diff --git a/packages/context/harness-dev-context/README.i18n.yaml b/packages/context/harness-dev-context/README.i18n.yaml index adc0b95c0e..5a9ee319b8 100644 --- a/packages/context/harness-dev-context/README.i18n.yaml +++ b/packages/context/harness-dev-context/README.i18n.yaml @@ -33,8 +33,8 @@ en: 4696b6c3a1f91f96 zh: 3a56d257ec768a5e /deepseek-ai-dsh-harness-dev-context/understand-the-implementation/main-flow: - en: f3844a1825b2e329 - zh: bdcafbcadbc6d316 + en: 55c4cdd475f9f851 + zh: ac4fb96e7be88810 /deepseek-ai-dsh-harness-dev-context/further-exploration: en: f291741c499581ac zh: 8ee0baef51e69ef0 diff --git a/packages/context/harness-dev-context/README.md b/packages/context/harness-dev-context/README.md index 04d3925181..3d4ca78246 100644 --- a/packages/context/harness-dev-context/README.md +++ b/packages/context/harness-dev-context/README.md @@ -57,7 +57,7 @@ A host-only session projection, `harnessDevLegacyPrompt`, folds every committed ### Main flow -Activation registers the projection unit, both sections, and both contexts. At each assembly the sections choose the admitted earlier paragraph or the current guidance; at each request admission the contexts read the advertised URL. Disposing the plugin fiber removes every registration. +Activation awaits the projection handle before registering both sections and both contexts. At each assembly the sections read the handle to choose the admitted earlier paragraph or the current guidance; at each request admission the contexts read the advertised URL. Disposing the plugin fiber removes every registration. diff --git a/packages/context/harness-dev-context/README.zh.md b/packages/context/harness-dev-context/README.zh.md index e108c6f161..b25f5eea66 100644 --- a/packages/context/harness-dev-context/README.zh.md +++ b/packages/context/harness-dev-context/README.zh.md @@ -57,7 +57,7 @@ kind: "package-reference" ### 主流程 -激活时注册投影单元、两个段落与两个上下文。每次组装时,段落在已提交的早期段落与当前说明之间选择;每次请求准入时,上下文读取公告 URL。销毁插件 fiber 会移除全部注册。 +激活时等待取得投影 handle,再注册两个段落与两个上下文。每次组装时,段落读取 handle,在已提交的早期段落与当前说明之间选择;每次请求准入时,上下文读取公告 URL。销毁插件 fiber 会移除全部注册。 diff --git a/packages/context/harness-dev-context/src/index.ts b/packages/context/harness-dev-context/src/index.ts index becb4a6005..d823735a2a 100644 --- a/packages/context/harness-dev-context/src/index.ts +++ b/packages/context/harness-dev-context/src/index.ts @@ -124,9 +124,10 @@ const ADMITS_NOTHING: LegacyPrompt = { source: null, web: null } * Register the two sections, their runtime-context values, and the * projection that keeps a Session's admitted paragraphs. * @param ctx - plugin context with every {@link inject} service. + * @returns after the projection reader and prompt registrations are ready. */ -export function apply(ctx: Context): void { - ctx.sessionProjections.register({ +export async function apply(ctx: Context): Promise { + const legacyPrompt = await ctx.sessionProjections.register({ key: LEGACY_PROMPT, stateVersion: 1, stateSchema: legacyPromptSchema, @@ -135,8 +136,7 @@ export function apply(ctx: Context): void { }) const admitted = (context: AssembleContext): LegacyPrompt => { const session = context.agent?.session - // This plugin registers the unit above, so its state exists for every Session. - return session === undefined ? ADMITS_NOTHING : ctx.sessionProjections.stateOf(session, LEGACY_PROMPT) as LegacyPrompt + return session === undefined ? ADMITS_NOTHING : legacyPrompt.stateOf(session) } ctx.systemPrompt.section({ name: HARNESS_SOURCE, diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 493630a811..d1049f4203 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -49,7 +49,6 @@ const timeContextStateSchema = zod.object({ /** Folded time-context readings. */ type TimeContextProjection = zod.infer -/** The agent registry that owns pre-step processing. */ export const inject = ['agents', 'sessionProjections'] /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ @@ -128,8 +127,9 @@ function validateRefreshInterval(refreshIntervalMs: number): void { * @param ctx - plugin context; the listener is disposed with it. * @param config - time zone and durable refresh scheduling configuration. * @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved. + * @returns when the plugin is ready. */ -export function apply(ctx: Context, config: Config): void { +export async function apply(ctx: Context, config: Config): Promise { const timeZone = config.timeZone const refreshIntervalMs = config.refreshIntervalMs ?? 600_000 validateRefreshInterval(refreshIntervalMs) @@ -154,7 +154,7 @@ export function apply(ctx: Context, config: Config): void { return created } - ctx.sessionProjections.register({ + const projection = await ctx.sessionProjections.register({ key: 'timeContext', stateVersion: 2, stateSchema: timeContextStateSchema, @@ -189,7 +189,7 @@ export function apply(ctx: Context, config: Config): void { const decision = await next() if (decision.kind === 'reject' || signal.aborted) return decision const now = Date.now() - const state = ctx.sessionProjections.stateOf(agent.session, 'timeContext') as TimeContextProjection + const state = projection.stateOf(agent.session) if (refreshIntervalMs > 0) { const lastInjection = state.lastInjectionTime if (lastInjection != null diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 92499007d1..a795935f2e 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -429,10 +429,10 @@ describe('time-context projection fold edges', () => { content: [{ type: 'text', text: 'reading' }], source: { kind: 'time-context' }, }), { surfaceOp: 'append' }) - expect(typeof ctx.sessionProjections.stateOf(session, 'timeContext')?.lastTurnInjectionTime).toBe('number') + expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toHaveProperty('lastTurnInjectionTime', expect.any(Number)) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) session.append('turn/start', { turn: 2 }) - expect(ctx.sessionProjections.stateOf(session, 'timeContext')).toMatchObject({ + expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({ lastTurnInjectionTime: null, }) }) @@ -441,7 +441,7 @@ describe('time-context projection fold edges', () => { const { ctx } = await mount() const session = Session.create(SessionId('end-without-start')) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - expect(ctx.sessionProjections.stateOf(session, 'timeContext')).toMatchObject({ + expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({ lastTurnInjectionTime: null, }) }) diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts index af6d636e93..fec71d8d69 100644 --- a/packages/context/tmux-context/src/index.ts +++ b/packages/context/tmux-context/src/index.ts @@ -36,11 +36,9 @@ declare module '@deepseek-ai/dsh-llm' { } } - /** Cordis plugin name used by loader diagnostics. */ export const name = 'tmux-context' -/** The agent registry that owns pre-step processing. */ export const inject = ['agents', 'sessionProjections'] /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ @@ -223,11 +221,15 @@ declare module '@deepseek-ai/dsh-session-projection/types' { } } -export function apply(ctx: Context, config: Config): void { +/** Register the tmux-context plugin after its projections are ready. + * @param ctx - plugin context. + * @returns when the plugin is ready. + */ +export async function apply(ctx: Context, config: Config): Promise { const refreshIntervalMs = config.refreshIntervalMs validateRefreshInterval(refreshIntervalMs) - ctx.sessionProjections.register({ + const projection = await ctx.sessionProjections.register({ key: 'tmuxContext', stateVersion: 1, stateSchema: tmuxContextStateSchema, @@ -251,7 +253,7 @@ export function apply(ctx: Context, config: Config): void { if (decision.kind === 'reject' || signal.aborted || step !== 1) return decision const bash = ctx.get('shell') if (bash === undefined) return decision - const previous = ctx.sessionProjections.stateOf(agent.session, 'tmuxContext') as TmuxContextState + const previous = projection.stateOf(agent.session) if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== null) { const now = Date.now() if (now >= previous.time && now - previous.time < refreshIntervalMs) return decision diff --git a/packages/deliverables/tool-present/src/index.ts b/packages/deliverables/tool-present/src/index.ts index 8b47748b03..afc6ab82a8 100644 --- a/packages/deliverables/tool-present/src/index.ts +++ b/packages/deliverables/tool-present/src/index.ts @@ -1,4 +1,5 @@ /** Scoped tool that declares filesystem deliveries in their owning Session. */ +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-working-directory' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' @@ -9,7 +10,6 @@ import type {} from '@deepseek-ai/dsh-session-projection' import type { Session } from '@deepseek-ai/dsh-session' import type { PresentedFile } from './types.ts' -/** Stable Loader identity. */ export const name = 'tool-present' /** Per-call delivery limit. */ @@ -23,15 +23,16 @@ export const Config: z = z.object({ maxFiles: z.number().default(8), }) -/** Services used by the scoped delivery tool. */ export const inject = ['tools', 'fs', 'sessionProjections', 'workingDirectory'] /** * Register present with durable file references in its tool result. * @param ctx - agent-scoped services. * @param config - maximum files per call. + * @returns when the plugin is ready. */ -export function apply(ctx: Context, config: Config): void { +export async function apply(ctx: Context, config: Config): Promise { + const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) if (!Number.isSafeInteger(config.maxFiles) || config.maxFiles < 1) { throw new Error('present requires a positive integer maxFiles') } @@ -76,8 +77,8 @@ export function apply(ctx: Context, config: Config): void { }, async execute(args, exec) { if (exec.agent === undefined) throw new Error('present requires an agent Session') - const boundary = ctx.sessionProjections.stateOf(exec.agent.session, 'turnBoundary') - if (boundary === undefined || boundary.openTurnStartSeq === null) throw new Error('present requires an open turn') + const boundary = turnBoundary.stateOf(exec.agent.session) + if (boundary.openTurnStartSeq === null) throw new Error('present requires an open turn') if (args.files.length === 0 || args.files.length > config.maxFiles) throw new Error(`present accepts 1 to ${config.maxFiles} files`) const cwd = await ctx.workingDirectory.ensure(exec.agent, exec.signal) const options = { cwd, signal: exec.signal } diff --git a/packages/deliverables/tool-present/tests/built-errors.e2e.ts b/packages/deliverables/tool-present/tests/built-errors.e2e.ts index e8b0bee060..7595872758 100644 --- a/packages/deliverables/tool-present/tests/built-errors.e2e.ts +++ b/packages/deliverables/tool-present/tests/built-errors.e2e.ts @@ -16,7 +16,8 @@ import LocalFileSystem from './packages/fs/fs-local/lib/index.js' import SystemPrompt from './packages/core/system-prompt/lib/index.js' import ToolRuntime from './packages/core/tools/lib/index.js' import { Session, SESSION_FORMAT_VERSION } from './packages/core/session/lib/index.js' -import * as Present from './packages/deliverables/tool-present/lib/index.js' +import Present from './packages/deliverables/tool-present/lib/index.js' +import SessionProjectionRegistry from './packages/session/session-projection/lib/index.js' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -28,10 +29,11 @@ try { await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: root }) - ctx.provide('sessionProjections', { stateOf() { return { openTurnStartSeq: 1, lastTurn: 1 } } }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(Present, { maxFiles: 2 }) const scope = ctx.plugin(() => {}) const session = Session.create('built-present', [], { version: SESSION_FORMAT_VERSION, id: 'built-present', createdAt: 0, cwd: root, isSeeded: false }) + session.append('turn/start', { turn: 1 }) const owner = { id: 'built-present', session, ctx: scope.ctx, options: {}, status: 'idle' } await ctx.agents.register(owner) const events = [] diff --git a/packages/deliverables/tool-present/tests/present.spec.ts b/packages/deliverables/tool-present/tests/present.spec.ts index afbbf5958a..94fae8366f 100644 --- a/packages/deliverables/tool-present/tests/present.spec.ts +++ b/packages/deliverables/tool-present/tests/present.spec.ts @@ -62,7 +62,7 @@ async function setup() { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: root }) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) const fiber = ctx.plugin(Present, { maxFiles: 2 }) await fiber const owner = await agent(ctx, root) @@ -149,9 +149,12 @@ describe('present', () => { }) -it('validates deployment limits before registering the tool', () => { +it('validates deployment limits before registering the tool', async () => { + const ctx = new Context() + cleanups.push(() => ctx.fiber.dispose()) + await ctx.plugin(SessionProjectionRegistry) for (const config of [{ maxFiles: 0 }, { maxFiles: 1.5 }, { maxFiles: Number.POSITIVE_INFINITY }]) { - expect(() => { Present.apply(new Context(), config) }).toThrow('positive integer maxFiles') + await expect(Present.apply(ctx, config)).rejects.toThrow('positive integer maxFiles') } }) diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index e4f3b0666e..0dc6d22bb4 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/README.i18n.yaml @@ -63,14 +63,14 @@ en: 6922e307e18696b4 zh: 083f4d95587a844b /deepseek-ai-dsh-experimental-agent-team/understand-the-implementation/disposal: - en: a12fb358183cc6d8 - zh: 7fdb0434d3c407fe + en: f32086839d37c9a6 + zh: b40ab7140b314afb /deepseek-ai-dsh-experimental-agent-team/further-exploration: en: 020d07f96ef2077d zh: 9e771d38a02048e1 /deepseek-ai-dsh-experimental-agent-team/further-exploration/browser-projection: - en: 4b3033806e6db528 - zh: 53e0dfef7309840e + en: 5dfc9113cabec7cd + zh: 22eaee731ca3f1b3 /deepseek-ai-dsh-experimental-agent-team/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 2d8abc7409..40872142cb 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -149,7 +149,7 @@ Mailbox projection and checkpoint admission preserve every decoded JSON field of ### Disposal -The runtime lifecycle tracks sends and complete creation transactions in one operation set. Disposal closes admission, aborts and awaits those operations, then releases the roster’s live children and descendants. Non-Team children remain untouched. Admitted operations share one `disposalTimeoutMs` settlement deadline; each subsequent Team child drain has its own deadline. This value is not a whole-service shutdown bound. Cleanup failures are reported. +The runtime lifecycle tracks sends and complete creation transactions in one operation set. Disposal closes admission, aborts and awaits those operations, then releases the roster’s live children and descendants. Non-Team children remain untouched. Admitted operations share one `disposalTimeoutMs` settlement deadline; each subsequent Team child drain has its own deadline. This value is not a whole-service shutdown bound. Cleanup failures are reported. Team shutdown is independent of projection unregistration; its retained reader follows Session events while admitted work settles. @@ -171,7 +171,7 @@ Read these pages when the package-level contract is not enough. They move from t ### Browser projection -The `agentTeam` Session projection publishes the Lead Session's durable roster identities and phases, member errors, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types. +The `agentTeam` Session projection publishes the Lead Session's durable roster identities and phases, member errors, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types. A failed projection rejects subsequent Team reads and mutations without appending further Team events. The [Web UI](../client-ui-agent-team/README.md) reads the shared Session projections and overlays activity from Session status. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types. diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index e60d93b255..815d41eb2b 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -149,7 +149,7 @@ Mailbox 投影与 checkpoint 准入保留本地声明的校验器之外获准内 ### Dispose -运行时生命周期用同一个操作集合跟踪发送和完整创建事务。dispose 关闭准入,中止并等待这些操作,然后释放 roster 中的 live child 及其后代。非 Team child 不受影响。已获准操作共享一个 `disposalTimeoutMs` 等待期限;随后每个 Team 的 child drain 各有独立期限。此值不是整个服务关闭的总时限。清理失败会明确报告。 +运行时生命周期用同一个操作集合跟踪发送和完整创建事务。dispose 关闭准入,中止并等待这些操作,然后释放 roster 中的 live child 及其后代。非 Team child 不受影响。已获准操作共享一个 `disposalTimeoutMs` 等待期限;随后每个 Team 的 child drain 各有独立期限。此值不是整个服务关闭的总时限。清理失败会明确报告。 Team 停机与投影注销独立;在已获准操作收尾期间,保留的 reader 仍会根据 Session 事件更新读取结果。 @@ -171,7 +171,7 @@ Mailbox 投影与 checkpoint 准入保留本地声明的校验器之外获准内 ### 浏览器投影 -`agentTeam` Session 投影发布 Lead Session 的持久成员身份与阶段、成员错误、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。 +`agentTeam` Session 投影发布 Lead Session 的持久成员身份与阶段、成员错误、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。 投影失败后,后续 Team 读取和修改均报错,不再追加 Team 事件。 [Web UI](../client-ui-agent-team/README.zh.md) 读取共享 Session 投影,并从 Session 状态叠加活动信息。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。 diff --git a/packages/experimental/agent-team/src/index.ts b/packages/experimental/agent-team/src/index.ts index 2d35933776..fd1ea84a29 100644 --- a/packages/experimental/agent-team/src/index.ts +++ b/packages/experimental/agent-team/src/index.ts @@ -9,8 +9,8 @@ import { steerHostSubagentPrompt } from '@deepseek-ai/dsh-subagent/internal' import { TeamActivity } from './activity.ts' import { errorMessage, TeamError } from './error.ts' import { TeamJournal } from './journal.ts' -import { TeamRuntimeLifecycle } from './lifecycle.ts' import { teamProjectionDefinition } from './projection.ts' +import { TeamRuntimeLifecycle } from './lifecycle.ts' import { resolveActiveMember, TeamRoster } from './roster.ts' import type { TeamMembership } from './roster.ts' import { TeamTaskBoard } from './task-board.ts' @@ -68,9 +68,9 @@ export class TeamService extends Service { private readonly activity: TeamActivity private readonly lifecycle: TeamRuntimeLifecycle - private readonly journal: TeamJournal - private readonly roster: TeamRoster - private readonly tasks: TeamTaskBoard + private journal!: TeamJournal + private roster!: TeamRoster + private tasks!: TeamTaskBoard constructor(ctx: Context, config: Config = {}) { super(ctx, 'agentTeams') @@ -86,25 +86,21 @@ export class TeamService extends Service { this.activity = new TeamActivity() this.lifecycle = new TeamRuntimeLifecycle(this.config.disposalTimeoutMs) - this.journal = new TeamJournal(ctx, (root) => { this.activity.notify(TeamId(root.id)) }) + } + + protected async [Service.init](): Promise { + const ctx = this.ctx + const projection = await ctx.sessionProjections.register(teamProjectionDefinition) + this.journal = new TeamJournal(ctx, projection, (root) => { this.activity.notify(TeamId(root.id)) }) this.roster = new TeamRoster(ctx, this.journal, this.lifecycle, this.config.maxMembers) this.tasks = new TeamTaskBoard(this.journal, this.config.maxTasks) + ctx.effect(() => () => this.disposeRuntime(), 'agentTeams.runtimeLifecycle()') ctx.on('agent/created', ({ agent }) => { this.scheduleRecovery(agent) }) ctx.on('agent/status', ({ agent }) => { const membership = this.roster.tryMembership(agent) if (membership !== undefined) this.activity.notify(membership.id) }) - ctx.effect(() => { - const disposeProjection = ctx.root.sessionProjections.register(teamProjectionDefinition) - return async () => { - try { - await this.disposeRuntime() - } finally { - disposeProjection() - } - } - }, 'agentTeams.runtimeLifecycle()') for (const agent of ctx.agents.list()) this.scheduleRecovery(agent) } diff --git a/packages/experimental/agent-team/src/journal.ts b/packages/experimental/agent-team/src/journal.ts index f69ac376b8..6a6002bb72 100644 --- a/packages/experimental/agent-team/src/journal.ts +++ b/packages/experimental/agent-team/src/journal.ts @@ -1,9 +1,10 @@ /** Serialized Team transactions over the exact live Lead Session log. */ import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { Context } from '@deepseek-ai/cordis' import type { SessionEventMap, SessionId } from '@deepseek-ai/dsh-session' -import type { TeamEventType, TeamState } from './projection.ts' +import type { TeamEventType, TeamProjectionState, TeamState } from './projection.ts' type AppendTeamEvent = (type: T, data: SessionEventMap[T]) => void type MutableTeamEventType = 'team/member' | 'team/task' @@ -14,10 +15,12 @@ export class TeamJournal { /** * @param ctx - Team service context with the injected Session service. + * @param projection - Team state registration owned by the Team service. * @param onCommit - synchronous notification after the Team event flush succeeds. */ constructor( private readonly ctx: Context, + private readonly projection: ProjectionHandle, private readonly onCommit: (root: Agent) => void, ) {} @@ -25,10 +28,10 @@ export class TeamJournal { * Read authoritative Team state for one exact live Lead. * @param root - exact live Team Lead. * @returns current projected state selected by the Lead Team id. + * @throws when the durable Team projection has failed. */ state(root: Agent): TeamState { - const projection = this.ctx.sessionProjections.stateOf(root.session, 'agentTeam') - if (projection === undefined) throw new Error('Agent Teams projection is not registered') + const projection = this.projection.stateOf(root.session) if (projection.failure !== undefined) throw new Error(projection.failure) return projection } diff --git a/packages/experimental/agent-team/tests/projection-events.spec.ts b/packages/experimental/agent-team/tests/projection-events.spec.ts index 2266b41483..2de9c75ff4 100644 --- a/packages/experimental/agent-team/tests/projection-events.spec.ts +++ b/packages/experimental/agent-team/tests/projection-events.spec.ts @@ -90,7 +90,7 @@ describe('Agent Teams projection events', () => { onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.effect(() => ctx.sessionProjections.register(teamProjectionDefinition)) + await ctx.sessionProjections.register(teamProjectionDefinition) const session = ctx.sessions.create(ROOT) const restore = (val: unknown) => ctx.sessionProjections.restore({ agentTeam: { ver: teamProjectionDefinition.stateVersion, seq: -1, val }, @@ -359,7 +359,7 @@ describe('Agent Teams projection events', () => { const registry = ctx.plugin(SessionProjectionRegistry) try { await registry - ctx.sessionProjections.register(teamProjectionDefinition) + await ctx.sessionProjections.register(teamProjectionDefinition) const extension = JSON.parse('{"type":"plugin/custom","__proto__":{"saved":true}}') as ContentBlock const queued = event('team/message/queued', { version: 2, teamId: TEAM, message: message({ content: [extension] }), @@ -546,7 +546,7 @@ describe('Agent Teams projection events', () => { onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.effect(() => ctx.sessionProjections.register(teamProjectionDefinition)) + await ctx.sessionProjections.register(teamProjectionDefinition) const published: Array<{ session: SessionId; key: string; value: unknown }> = [] ctx.sessionProjections.onChanged((session, key, value) => { published.push({ session: session.id, key, value }) diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index ec79684726..671cb281f7 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -1,3 +1,4 @@ +import { Service } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -141,19 +142,20 @@ async function waitRunning(ctx: Context, id: SessionId): Promise { } describe('Team identity and provisioning', () => { - it('rejects missing and failed authoritative Team projections', async () => { - const first = await setup([]) - const journal = teamInternals(first.ctx).journal - const stateOf = first.ctx.sessionProjections.stateOf.bind(first.ctx.sessionProjections) - const stateOfSpy = vi.spyOn(first.ctx.sessionProjections, 'stateOf').mockImplementation((session, key) => ( - key === 'agentTeam' ? undefined : stateOf(session, key) - )) - expect(() => journal.state(first.lead)).toThrow('Agent Teams projection is not registered') - stateOfSpy.mockImplementation((session, key) => key === 'agentTeam' - ? { ...teamProjectionDefinition.init(session.header), failure: 'failed Team projection' } - : stateOf(session, key)) - expect(() => journal.state(first.lead)).toThrow('failed Team projection') - stateOfSpy.mockRestore() + it('rejects reads and mutations after a Team projection fails', async () => { + const { ctx, lead } = await setup([]) + lead.session.append('team/message/delivered', { + version: 2, + teamId: TeamId(lead.id), + messageId: TeamMessageId('unqueued-message'), + targetId: lead.id, + }) + + expect(() => ctx.agentTeams.listMembers(lead)).toThrow('delivered before queueing') + const eventCount = lead.session.snapshotEvents().length + await expect(ctx.agentTeams.createTask(lead, { subject: 'blocked', description: 'blocked' })) + .rejects.toThrow('delivered before queueing') + expect(lead.session.snapshotEvents()).toHaveLength(eventCount) }) it('rejects deployment limits that are not positive safe integers', async () => { @@ -181,7 +183,11 @@ describe('Team identity and provisioning', () => { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) const lead = await ctx.agentLoop.create(SessionId('preexisting-lead'), {}) - const service = new TeamService(ctx) + class DirectTeamService extends TeamService { + initialize(): Promise { return this[Service.init]() } + } + const service = new DirectTeamService(ctx) + await service.initialize() expect(service.listMembers(lead)).toEqual([expect.objectContaining({ name: 'lead', @@ -1147,6 +1153,32 @@ describe('Team direct messages and waiting', () => { expect(ctx.get('agentTeams')).toBeUndefined() }) + it.each([false, true])('settles admitted work after projection unregistration (failure: %s)', async (fails) => { + const { ctx, lead, teamFiber } = await setup([]) + const internal = teamInternals(ctx) + const state = internal.journal.state(lead) + const finish = Promise.withResolvers() + let readDuringShutdown = false + void internal.lifecycle.track(finish.promise.then(() => { + expect(internal.journal.state(lead)).toBe(state) + readDuringShutdown = true + if (fails) throw new Error('admitted operation failed during shutdown') + })) + const disposal = teamFiber.dispose() + try { + await vi.waitFor(() => { + expect(internal.lifecycle.disposed).toBe(true) + expect(ctx.sessionProjections.checkpoint(lead.session)).not.toHaveProperty('agentTeam') + }) + expect(readDuringShutdown).toBe(false) + } finally { + finish.resolve(undefined) + await disposal + } + expect(readDuringShutdown).toBe(true) + expect(ctx.get('agentTeams')).toBeUndefined() + }) + it('closes creation admission and drains an in-flight spawn before unload completes', async () => { const { ctx, lead, teamFiber } = await setup(['hang']) const service = ctx.agentTeams diff --git a/packages/experimental/claude-code-mods/README.i18n.yaml b/packages/experimental/claude-code-mods/README.i18n.yaml index aa0a5e6116..68c4c306d6 100644 --- a/packages/experimental/claude-code-mods/README.i18n.yaml +++ b/packages/experimental/claude-code-mods/README.i18n.yaml @@ -9,8 +9,8 @@ en: bf54b493565f1624 zh: 6f28a518f7b0dea8 /deepseek-ai-dsh-experimental-claude-code-mods/summary: - en: 4ddb71e280b6b328 - zh: e4775593fd7f232c + en: e21a08cf7ad99a77 + zh: df7634372a42d9b2 /deepseek-ai-dsh-experimental-claude-code-mods/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb diff --git a/packages/experimental/claude-code-mods/README.md b/packages/experimental/claude-code-mods/README.md index 4fd02d3ded..52dc778067 100644 --- a/packages/experimental/claude-code-mods/README.md +++ b/packages/experimental/claude-code-mods/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Run [Claude Code mods](https://code.claude.com/docs/en/plugins/mods/overview) inside agent runs: wrap a mod's `register(on, options)` with `defineMod`, mount it as a plugin after this bridge, and its hooks guard tool calls, rewrite prompts, add commands and tools, read session facts, and draw a band above the prompt through the same `$`, `e`, `next` chain. The bridge requires `dsh-working-directory`, and each `$` call rides a composed harness service. It is an alpha interface-compatibility demonstration: an unserved event is reported at load, an unserved `$` member fails naming the gap, and [the compatibility page](../../../docs/subsystems/claude-code-mods.md) lists every difference. +Run [Claude Code mods](https://code.claude.com/docs/en/plugins/mods/overview) inside agent runs: wrap a mod's `register(on, options)` with `defineMod`, mount it as a plugin after this bridge, and its hooks guard tool calls, rewrite prompts, add commands and tools, read session facts, and draw a band above the prompt through the same `$`, `e`, `next` chain. The bridge requires `dsh-working-directory` and `dsh-session-projection`, and each `$` call rides a composed harness service. It is an alpha interface-compatibility demonstration: an unserved event is reported at load, an unserved `$` member fails naming the gap, and [the compatibility page](../../../docs/subsystems/claude-code-mods.md) lists every difference. ## Table of Contents diff --git a/packages/experimental/claude-code-mods/README.zh.md b/packages/experimental/claude-code-mods/README.zh.md index 1a2f909626..6b11df8418 100644 --- a/packages/experimental/claude-code-mods/README.zh.md +++ b/packages/experimental/claude-code-mods/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在智能体运行中执行 [Claude Code 模组](https://code.claude.com/docs/en/plugins/mods/overview):用 `defineMod` 包装模组的 `register(on, options)`,在本桥接之后作为插件挂载,其钩子即可通过同一套 `$`、`e`、`next` 链守卫工具调用、改写提示词、添加命令与工具、读取会话事实,并在提示框上方绘制一条横幅。桥接需要 `dsh-working-directory`,每次 `$` 调用都落在一个已组合的 harness 服务上。它是 alpha 阶段的接口兼容性演示:未服务的事件在加载时报告,未服务的 `$` 成员以指明缺口的消息失败,[兼容性页面](../../../docs/subsystems/claude-code-mods.zh.md) 列出了全部差异。 +在智能体运行中执行 [Claude Code 模组](https://code.claude.com/docs/en/plugins/mods/overview):用 `defineMod` 包装模组的 `register(on, options)`,在本桥接之后作为插件挂载,其钩子即可通过同一套 `$`、`e`、`next` 链守卫工具调用、改写提示词、添加命令与工具、读取会话事实,并在提示框上方绘制一条横幅。桥接需要 `dsh-working-directory` 和 `dsh-session-projection`,每次 `$` 调用都落在一个已组合的 harness 服务上。它是 alpha 阶段的接口兼容性演示:未服务的事件在加载时报告,未服务的 `$` 成员以指明缺口的消息失败,[兼容性页面](../../../docs/subsystems/claude-code-mods.zh.md) 列出了全部差异。 ## 目录 diff --git a/packages/experimental/claude-code-mods/src/host-ops.ts b/packages/experimental/claude-code-mods/src/host-ops.ts index 3d6363b463..7b61a18a4c 100644 --- a/packages/experimental/claude-code-mods/src/host-ops.ts +++ b/packages/experimental/claude-code-mods/src/host-ops.ts @@ -9,20 +9,17 @@ */ import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -// Type-only: activate the `ctx.get('subprocess')` and `ctx.get('sessionProjections')` Context declarations. -import type {} from '@deepseek-ai/dsh-subprocess' import type {} from '@deepseek-ai/dsh-session-projection' -import type { SessionProjectionStateMap } from '@deepseek-ai/dsh-session-projection/types' +import { turnBoundaryProjectionDefinition, type Agent } from '@deepseek-ai/dsh-agent' +// Type-only: activate the `ctx.get('subprocess')` Context declaration. +import type {} from '@deepseek-ai/dsh-subprocess' import type { CommandDefinition } from '@deepseek-ai/dsh-commands' import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import { scopeOf } from '@deepseek-ai/dsh-scope' -import type { Session } from '@deepseek-ai/dsh-session' import type { WorkingDirectoryService } from '@deepseek-ai/dsh-working-directory' -// Type-only: merges the token-meter projection states into SessionProjectionStateMap. -import type {} from '@deepseek-ai/dsh-token-meter' +import { contextPressureProjectionDefinition } from '@deepseek-ai/dsh-token-meter' import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' import type { Domain } from '@deepseek-ai/dsh-storage-domain' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' @@ -161,12 +158,14 @@ function sizeOf(info: { size?: number } | undefined): number { } /** - * Build the op table over the composed harness services. + * Build the op table with the required session projection registry and composed harness services. * @param options - the context, aliases, limits, and registration tracking. - * @returns engine behaviors by event name. + * @returns engine behaviors by event name, after both session projections are registered. */ -export function createHostOps(options: HostOpsOptions): OpTable { +export async function createHostOps(options: HostOpsOptions): Promise> { const { ctx, aliases } = options + const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + const contextPressure = await ctx.sessionProjections.register(contextPressureProjectionDefinition) let storeDomain: Promise> | undefined function store(): Promise> { @@ -225,11 +224,6 @@ export function createHostOps(options: HostOpsOptions): OpTable { if (agent !== undefined) options.redraw?.(agent.session.id) } - /** One projection's state, or undefined when the registry or the projection's owning plugin is not composed. */ - function projection(session: Session, key: K): SessionProjectionStateMap[K] | undefined { - return ctx.get('sessionProjections')?.stateOf(session, key) - } - return { // ---- ui: no mod drawing surface exists; log-style calls reach the host log ---- 'ui.log': (input, { mod }) => { @@ -411,16 +405,14 @@ export function createHostOps(options: HostOpsOptions): OpTable { }, 'session.turns': (_input, context) => { const agent = requireAgent(context, 'session.turns') - const boundary = projection(agent.session, 'turnBoundary') - if (boundary === undefined) throw new Error('$.session.turns needs the turnBoundary projection, which this deployment did not compose') - return boundary.lastTurn + return turnBoundary.stateOf(agent.session).lastTurn }, 'session.messages': (_input, context) => sessionMessages(requireAgent(context, 'session.messages').session.deriveMessages(), aliases), 'session.usage': (_input, context): SessionUsage => { const agent = requireAgent(context, 'session.usage') - const pressure = projection(agent.session, 'contextPressure') - const tokens = pressure?.pressureTokens - const window = pressure?.contextWindow ?? 0 + const pressure = contextPressure.stateOf(agent.session) + const tokens = pressure.pressureTokens + const window = pressure.contextWindow ?? 0 return { startedAt: agent.session.header.createdAt, context: { diff --git a/packages/experimental/claude-code-mods/src/index.ts b/packages/experimental/claude-code-mods/src/index.ts index 1c97870b73..f715a74d7f 100644 --- a/packages/experimental/claude-code-mods/src/index.ts +++ b/packages/experimental/claude-code-mods/src/index.ts @@ -10,7 +10,7 @@ * @module @deepseek-ai/dsh-experimental-claude-code-mods */ -import type { Context } from '@deepseek-ai/cordis' +import { Service, type Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-working-directory' import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' @@ -208,22 +208,25 @@ export class ClaudeCodeMods extends TypertRemoteService { bandRows: z.number().default(10), }) - // Session initialization requires its directory owner; other services are read on demand. - static inject = ['workingDirectory'] + // Session initialization requires its directory owner and projection registry; other services are read on demand. + static inject = ['workingDirectory', 'sessionProjections'] - private readonly engine: ModsEngine + private engine!: ModsEngine private readonly registrations = new Map void>>() - private readonly surfaces: SurfaceTable + private surfaces!: SurfaceTable - constructor(ctx: Context, config: Config) { + constructor(ctx: Context, private readonly config: Config) { super(ctx, 'claudeCodeMods', { namespace: 'claudeCodeMods' }) + for (const key of ['hookTimeoutMs', 'catchTimeoutMs', 'processTimeoutMs', 'bandColumns', 'bandRows'] as const) { + const value = config[key] + if (value !== undefined) assertPositive(key, value) + } + } + + protected async [Service.init](): Promise { + const { ctx, config } = this // The schema supplies every default; a direct construction passes the fields it needs. const { hookTimeoutMs = 10_000, catchTimeoutMs = 1_000, processTimeoutMs = 30_000, bandColumns = 120, bandRows = 10 } = config - assertPositive('hookTimeoutMs', hookTimeoutMs) - assertPositive('catchTimeoutMs', catchTimeoutMs) - assertPositive('processTimeoutMs', processTimeoutMs) - assertPositive('bandColumns', bandColumns) - assertPositive('bandRows', bandRows) const aliases = createToolNameAliases(config.toolAliases) const callOrigins = new Map() const modCommands = new Set() @@ -259,7 +262,7 @@ export class ClaudeCodeMods extends TypertRemoteService { }, 0).unref() } const modSubmissions = new Map() - const ops = createHostOps({ + const ops = await createHostOps({ ctx, aliases, processTimeoutMs, registrations: this.registrations, callOrigins, modCommands, modTools, redraw, submitted: (messageId, mod) => { modSubmissions.set(messageId, mod) }, }) diff --git a/packages/experimental/claude-code-mods/tests/bridge.spec.ts b/packages/experimental/claude-code-mods/tests/bridge.spec.ts index 4ee329ce99..7243febfc6 100644 --- a/packages/experimental/claude-code-mods/tests/bridge.spec.ts +++ b/packages/experimental/claude-code-mods/tests/bridge.spec.ts @@ -535,7 +535,7 @@ describe('the mods API over harness services', () => { expect(out.session).toEqual({ id: 'a1', cwd: h.workspace, root: h.workspace, model: 'mock', turns: 1, version: MODS_API_VERSION, - usage: { startedAt: agent.session.header.createdAt, context: { window: 0 }, rateLimits: [] }, + usage: { startedAt: agent.session.header.createdAt, context: { tokens: 10, window: 0 }, rateLimits: [] }, messages: [ { role: 'user', text: 'hello', toolUses: [] }, { role: 'user', text: `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: ${JSON.stringify(h.workspace)}.`, toolUses: [] }, @@ -951,7 +951,7 @@ describe('loading diagnostics and configuration', () => { }) it('is a Service plugin the Loader keeps by its default export', () => { - expect(ClaudeCodeMods.inject).toEqual(['workingDirectory']) + expect(ClaudeCodeMods.inject).toEqual(['workingDirectory', 'sessionProjections']) const loader = Object.create(Loader.prototype) as Loader const unwrapped: unknown = loader.unwrapExports({ default: ClaudeCodeMods }) expect(unwrapped).toBe(ClaudeCodeMods) diff --git a/packages/experimental/claude-code-mods/tests/define-mod.spec.ts b/packages/experimental/claude-code-mods/tests/define-mod.spec.ts index d4bc93702d..987c2db123 100644 --- a/packages/experimental/claude-code-mods/tests/define-mod.spec.ts +++ b/packages/experimental/claude-code-mods/tests/define-mod.spec.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from '@deepseek-ai/cordis' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import ClaudeCodeMods, { defineMod } from '../src/index.ts' @@ -32,7 +33,10 @@ describe('defineMod', () => { const ctx = new Context() fibers.push(ctx.fiber) provideWorkingDirectoryFixture(ctx) - await ctx.plugin(ClaudeCodeMods, {}) + const bridge = ctx.plugin(ClaudeCodeMods, {}) + expect(ctx.get('claudeCodeMods')).toBeUndefined() + await ctx.plugin(SessionProjectionRegistry) + await bridge const fiber = await ctx.plugin(plugin, { history: 3 }) await fiber.await() expect(seen).toEqual([{ history: 3, unit: 'tokens' }]) @@ -52,6 +56,7 @@ describe('defineMod', () => { const ctx = new Context() fibers.push(ctx.fiber) provideWorkingDirectoryFixture(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(ClaudeCodeMods, {}) const fiber = ctx.plugin(slow, {}) await vi.waitFor(() => { expect(release).toBeDefined() }) @@ -69,6 +74,7 @@ describe('defineMod', () => { const ctx = new Context() fibers.push(ctx.fiber) provideWorkingDirectoryFixture(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(ClaudeCodeMods, {}) const fiber = await ctx.plugin(bare, {}) await fiber.await() diff --git a/packages/experimental/claude-code-mods/tests/host-ops.spec.ts b/packages/experimental/claude-code-mods/tests/host-ops.spec.ts index 15400980f4..750a68adf9 100644 --- a/packages/experimental/claude-code-mods/tests/host-ops.spec.ts +++ b/packages/experimental/claude-code-mods/tests/host-ops.spec.ts @@ -6,7 +6,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Message } from '@deepseek-ai/dsh-llm' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness, provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import CommandRuntime from '@deepseek-ai/dsh-commands' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' @@ -34,10 +35,11 @@ afterEach(async () => { const mod: LoadedMod = { name: 'unit-mod', version: undefined, root: '/mods/unit-mod', options: {}, order: 0 } -function setup(ctx = new Context()) { +async function setup(ctx = new Context()) { contexts.push(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) const registrations = new Map void>>() - const ops = createHostOps({ + const ops = await createHostOps({ ctx, aliases: createToolNameAliases(), processTimeoutMs: 5_000, @@ -72,12 +74,16 @@ function fakeAgent(ctx: Context, overrides: { cwd?: string; model?: string; requ id: 'fake', ctx, options: overrides.model === undefined ? {} : { model: overrides.model }, - session: { - id: 'fake', - header: { id: 'fake', createdAt: 1, ...overrides.cwd === undefined ? {} : { cwd: overrides.cwd } }, + session: Object.assign(Session.create(SessionId('fake'), [], { + version: SESSION_FORMAT_VERSION, + id: SessionId('fake'), + createdAt: 1, + isSeeded: false, + ...overrides.cwd === undefined ? {} : { cwd: overrides.cwd }, + }), { requestHeader: () => overrides.requestModel === undefined ? undefined : { config: { provider: 'p', model: overrides.requestModel } }, deriveMessages: (): Message[] => [], - }, + }), } return facts as Agent } @@ -100,7 +106,7 @@ describe('values', () => { describe('services a deployment did not compose', () => { it('names the missing service in each call', async () => { - const { call, ctx } = setup() + const { call, ctx } = await setup() const agent = fakeAgent(ctx) await expect(call('ui.ask', { question: 'q' }, agent)).rejects.toThrow(/dsh-user-questions/) await expect(call('command.register', { name: 'x', description: 'd' }, agent)).rejects.toThrow(/dsh-commands/) @@ -113,7 +119,7 @@ describe('services a deployment did not compose', () => { await expect(call('store.get', { key: 'k' })).rejects.toThrow(/dsh-storage-domain/) await expect(call('fs.read', { path: 'x' })).rejects.toThrow(/dsh-fs/) await expect(call('process.run', { argv: ['ls'] })).rejects.toThrow(/dsh-subprocess/) - await expect(call('session.turns', {}, agent)).rejects.toThrow(/turnBoundary projection/) + await expect(call('session.turns', {}, agent)).resolves.toBe(0) // Without a drawn surface the ui calls that would redraw the band are plain no-ops. await expect(call('ui.invalidate', {}, agent)).resolves.toBeUndefined() await expect(call('ui.invalidate', {})).resolves.toBeUndefined() @@ -123,7 +129,7 @@ describe('services a deployment did not compose', () => { }) it('requires a session for session-bound calls and validates mod input', async () => { - const { call } = setup() + const { call } = await setup() for (const op of ['ui.ask', 'command.run', 'command.list', 'prompt.submit', 'session.id', 'session.cwd', 'session.root', 'session.model', 'session.turns', 'session.messages', 'session.usage']) { await expect(call(op, {})).rejects.toThrow(`$.${op} needs a session, and this event has none`) } @@ -137,7 +143,7 @@ describe('services a deployment did not compose', () => { describe('session facts from the agent', () => { it('falls back from the logged route to the agent option to an empty model, and from the session cwd to the process cwd', async () => { - const { call, ctx } = setup() + const { call, ctx } = await setup() expect(await call('session.model', {}, fakeAgent(ctx, { requestModel: 'logged', model: 'opt' }))).toBe('logged') expect(await call('session.model', {}, fakeAgent(ctx, { model: 'opt' }))).toBe('opt') expect(await call('session.model', {}, fakeAgent(ctx))).toBe('') @@ -157,7 +163,7 @@ describe('files through the filesystem seam', () => { dirs.push(root) const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: root }) - const { call } = setup(ctx) + const { call } = await setup(ctx) provideWorkingDirectoryFixture(ctx) writeFileSync(join(root, 'small.txt'), 'small') writeFileSync(join(root, 'big.txt'), Buffer.alloc(FS_MAX_BYTES + 1, 97)) @@ -182,7 +188,7 @@ describe('processes through the subprocess seam', () => { it('runs argv with an explicit cwd and env, and reports a signal-terminated child', async () => { const ctx = new Context() await ctx.plugin(LocalSubprocessRuntime) - const { call } = setup(ctx) + const { call } = await setup(ctx) const cwd = mkdtempSync(join(tmpdir(), 'dsh-cc-mods-proc-')) dirs.push(cwd) const run = await call('process.run', { @@ -220,7 +226,7 @@ describe('the store limit', () => { close: () => Promise.resolve(), } ctx.provide('storageDomain', { open: () => Promise.resolve(domain) }) - const { call } = setup(ctx) + const { call } = await setup(ctx) expect(await call('store.keys', {})).toEqual([]) await call('store.set', { key: 'ok', value: 1 }) expect(await call('store.keys', {})).toEqual(['ok']) @@ -247,7 +253,7 @@ describe('registrations outside a session', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(CommandRuntime) - const { call, registrations, engine } = setup(ctx) + const { call, registrations, engine } = await setup(ctx) await call('command.register', { name: 'Global', description: 'global command' }) await call('tool.register', { name: 'global_tool', description: 'global tool' }) expect(ctx.tools.schemas().map(tool => tool.name)).toContain('mcp__unit-mod__global_tool') @@ -264,7 +270,7 @@ describe('registrations outside a session', () => { describe('ui.log sinks', () => { it('writes transcript lines at info and debug lines at debug', async () => { - const { call, ctx } = setup() + const { call, ctx } = await setup() const info = vi.fn() const debug = vi.fn() ctx.logger.info = info as never @@ -293,7 +299,7 @@ describe('the committed directory owner', () => { await mountAgentLoopTestDependencies(ctx, { workingDirectory: true }) const harness = await mountAgentLoopTestHarness(ctx) await ctx.plugin(LocalSubprocessRuntime) - const { call } = setup(ctx) + const { call } = await setup(ctx) const agent = await harness.create(SessionId('directory-owner'), {}, { cwd: origin }) await ctx.workingDirectory.set(agent, current) @@ -322,7 +328,7 @@ describe('the committed directory owner', () => { }) it('fails explicitly when a session-bound operation has no directory owner', async () => { - const { ctx, call } = setup() + const { ctx, call } = await setup() await ctx.plugin(LocalFileSystem) await ctx.plugin(LocalSubprocessRuntime) const agent = fakeAgent(ctx) diff --git a/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts b/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts index 534d6d3408..5ed24ad300 100644 --- a/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts +++ b/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' -import SessionController from '@deepseek-ai/dsh-api-session-controller' +import { createSessionTestController } from '../../../api/session-controller/tests/test-remote.ts' import Translator from '@deepseek-ai/dsh-experimental-translator' import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import JsonlPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -66,7 +66,9 @@ async function fixture(origin?: 'subagent') { saveSelection: async () => {}, } as never) ctx.provide('fileUploads', { registerAgentResolver: () => () => {} } as never) - const sessions = new SessionController(ctx, { nativeOpen: false }) + const sessions = await createSessionTestController(ctx, { + nativeOpen: false, cwd: root, defaultModelSelection: () => ({ provider: 'unused', model: 'unused' }), + }) await ctx.plugin(Translator, Translator.Config({ bingEndpoint: `http://127.0.0.1:${address.port}/bing` })) const ui = ctx.plugin(CotTranslationController, {}) await ui diff --git a/packages/experimental/hooks-claude-code/src/index.ts b/packages/experimental/hooks-claude-code/src/index.ts index afbc60de08..1c79429b80 100644 --- a/packages/experimental/hooks-claude-code/src/index.ts +++ b/packages/experimental/hooks-claude-code/src/index.ts @@ -8,12 +8,13 @@ * @module @deepseek-ai/dsh-hooks-claude-code */ +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-working-directory' import { readFileSync } from 'node:fs' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type { Agent, PreStepDecision, TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-session-projection' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' declare module '@deepseek-ai/dsh-llm' { @@ -43,10 +44,11 @@ import type { SubagentRunId } from '@deepseek-ai/dsh-subagent' import { parseClaudeCodeConfig, type ClaudeCodeHookConfig } from './config.ts' export const name = 'hooks-claude-code' -// `shell` runs hooks and `sessionProjections` supplies turn numbers; the rest -// are read opportunistically via ctx.get so a deployment can omit them. + export const inject = ['shell', 'sessionProjections', 'workingDirectory'] +// `shell` runs hooks and `sessionProjections` supplies turn numbers; the rest +// are read opportunistically via ctx.get so a deployment can omit them. /** Plugin config: where the CC hook config lives + substitution roots. */ export interface Config { /** @@ -99,7 +101,12 @@ function assertPositiveInteger(name: string, value: number): void { } } -export function apply(ctx: Context, config: Config): void { +/** Register the hooks-claude-code plugin after its projections are ready. + * @param ctx - plugin context. + * @param config - hook configuration path and execution limits. + * @returns when the plugin is ready. + */ +export async function apply(ctx: Context, config: Config): Promise { // Validate before config parsing so a bad value cannot be hidden by its early return. const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) @@ -121,6 +128,8 @@ export function apply(ctx: Context, config: Config): void { return } + const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + // Emit-shaped points run detached, so track their chains; disposal aborts // active hooks and drains continuations before resolving. const detached = createDetachedRuns() @@ -242,7 +251,7 @@ export function apply(ctx: Context, config: Config): void { // --- PreToolUse → PreToolDecision. Matcher subject is the tool name. --- ctx.on('tools/pre-execute', async (exec, next): Promise => { - const turn = lastTurn(ctx, exec.agent) + const turn = lastTurn(exec.agent) const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} } @@ -251,7 +260,7 @@ export function apply(ctx: Context, config: Config): void { // --- PostToolUse → PostToolDecision. Matcher subject is the tool name. --- ctx.on('tools/post-execute', async (exec, result, next): Promise => { - const turn = lastTurn(ctx, exec.agent) + const turn = lastTurn(exec.agent) const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) const context = contextFrom(merged) if (merged.decision === 'deny') { @@ -301,6 +310,12 @@ export function apply(ctx: Context, config: Config): void { detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child, directory: 'committed' } : {}, signal: detached.signal }) .catch((error: unknown) => { ctx.logger.warn(`hooks-claude-code: SubagentStop hook failed: ${String(error)}`) })) }) + + function lastTurn(agent: Agent | undefined): number { + if (!agent) return 0 + const boundary = turnBoundary.stateOf(agent.session) + return boundary.lastTurn + } } /** @@ -314,13 +329,6 @@ const SUBAGENT_TYPE = 'general-purpose' // --- Per-event stdin payloads (the CC DIALECT shape). Field names match CC's // hook input schema; this is the part a bridge owns. --- -/** The last open turn number in the agent's log, or 0 without an agent. */ -function lastTurn(ctx: Context, agent: Agent | undefined): number { - if (!agent) return 0 - const boundary = ctx.sessionProjections.stateOf(agent.session, 'turnBoundary') as TurnBoundaryProjection - return boundary.lastTurn -} - /** Flatten content blocks to the text a hook payload carries (the common case). */ function blocksToText(content: ContentBlock[]): string { return content.filter((b): b is Extract => b.type === 'text').map(b => b.text).join('') diff --git a/packages/experimental/hooks-claude-code/tests/coverage-cases.ts b/packages/experimental/hooks-claude-code/tests/coverage-cases.ts index b77aa85c96..dec29257f9 100644 --- a/packages/experimental/hooks-claude-code/tests/coverage-cases.ts +++ b/packages/experimental/hooks-claude-code/tests/coverage-cases.ts @@ -381,7 +381,7 @@ export function defineCoverageCases(group: CoverageGroup): void { // Direct apply with only configPath — bypasses schemastery's defaults, so // the bridge must run on the raw minimal config (the per-hook timeout is // the protocol lib's reference default, not a config knob). - HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') }) + await HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) diff --git a/packages/experimental/hooks-codex/src/index.ts b/packages/experimental/hooks-codex/src/index.ts index 139a99de6d..6159a90dda 100644 --- a/packages/experimental/hooks-codex/src/index.ts +++ b/packages/experimental/hooks-codex/src/index.ts @@ -11,12 +11,13 @@ // Each dialect bridge keeps its complete dependency list visible at the entry // point; a cross-package facade for imports alone would add indirection. /* jscpd:ignore-start */ +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-working-directory' import { readFileSync } from 'node:fs' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-session-projection' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' declare module '@deepseek-ai/dsh-llm' { @@ -41,11 +42,13 @@ import { type MergedHookOutcome, } from '@deepseek-ai/dsh-hook-protocol' import { parseCodexConfig, type CodexHookConfig } from './config.ts' -/* jscpd:ignore-end */ export const name = 'hooks-codex' + export const inject = ['shell', 'sessionProjections', 'workingDirectory'] +/* jscpd:ignore-end */ + /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ export interface Config { /** @@ -84,7 +87,12 @@ function assertPositiveInteger(name: string, value: number): void { } } -export function apply(ctx: Context, config: Config): void { +/** Register the hooks-codex plugin after its projections are ready. + * @param ctx - plugin context. + * @param config - hook configuration path and execution limits. + * @returns when the plugin is ready. + */ +export async function apply(ctx: Context, config: Config): Promise { // Validate before config parsing so a bad value cannot be hidden by its early return. const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) @@ -102,6 +110,7 @@ export function apply(ctx: Context, config: Config): void { return } + const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) const model = config.model ?? '' // SessionStart is the one emit-shaped (detached) point Codex has: track its @@ -195,7 +204,6 @@ export function apply(ctx: Context, config: Config): void { .catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) }) detached.track(run) await run - /* jscpd:ignore-end */ }) // UserPromptSubmit → PreStepDecision. Codex supports reject, not rewrite or ask. @@ -226,18 +234,17 @@ export function apply(ctx: Context, config: Config): void { // PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored). ctx.on('tools/pre-execute', async (exec, next): Promise => { - const turn = lastTurn(ctx, exec.agent) - const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) - /* jscpd:ignore-end */ + const turn = lastTurn(exec.agent) + const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } return next() }) // PostToolUse → PostToolDecision (block with feedback, or attach context). ctx.on('tools/post-execute', async (exec, result, next): Promise => { - const turn = lastTurn(ctx, exec.agent) + const turn = lastTurn(exec.agent) /* jscpd:ignore-start */ - const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) + const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) const context = contextFrom(merged) if (merged.decision === 'deny') { return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} } @@ -261,8 +268,7 @@ export function apply(ctx: Context, config: Config): void { // avoid continuing the same turn indefinitely. It is always false here, so an // unconditionally blocking hook force-continues every step until it self-limits. ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise => { - const merged = await runPoint('Stop', '', { ...turnBase(ctx, agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn, signal }) - /* jscpd:ignore-end */ + const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn, signal }) if (merged.decision === 'deny') { // A blocking Stop hook forces continuation; a block with no reason (exit 2, // empty stderr) still forces it — fall back to a generic steering line @@ -271,6 +277,27 @@ export function apply(ctx: Context, config: Config): void { agent.steer(createUserMessage({ content: [{ type: 'text', text }], source: CONTEXT_SOURCE })) } }) + + function lastTurn(agent: Agent | undefined): number { + if (!agent) return 0 + return turnBoundary.stateOf(agent.session).lastTurn + } + + function turnBase(agent: Agent | undefined, event: string, model: string): Record { + return { ...base(agent, event, model), turn_id: String(lastTurn(agent)) } + } + + function preToolPayload(exec: ToolExecution, model: string): Record { + // `tool_name` is the REAL tool name (matching the `exec.name` matcher subject); + // a hardcoded constant would disagree with what the matcher tests and make a + // config's tool matcher never fire. `tool_input` keeps Codex's `{ command }` + // shape (its shell payload), derived from the call's `command` arg when present. + return { ...turnBase(exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId } + } + + function postToolPayload(exec: ToolExecution, result: ToolExecutionResult, model: string): Record { + return { ...turnBase(exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) } + } } // --- Codex DIALECT payloads: snake_case, model on every event, turn_id on @@ -279,11 +306,6 @@ export function apply(ctx: Context, config: Config): void { // These small payload helpers intentionally remain next to the dialect shape; // sharing them would pull bridge-only agent/LLM dependencies into hook-protocol. /* jscpd:ignore-start */ -function lastTurn(ctx: Context, agent: Agent | undefined): number { - if (!agent) return 0 - /* v8 ignore next -- agent-present hook points run inside AgentLoop, which owns this projection. */ - return ctx.sessionProjections.stateOf(agent.session, 'turnBoundary')?.lastTurn ?? 0 -} function blocksToText(content: ContentBlock[]): string { return content.filter((b): b is Extract => b.type === 'text').map(b => b.text).join('') @@ -303,11 +325,6 @@ function base(agent: Agent | undefined, event: string, model: string): Record { - return { ...base(agent, event, model), turn_id: String(lastTurn(ctx, agent)) } -} - /** Extract a `command` string from a tool call's parsed arguments, else ''. */ function commandOf(args: unknown): string { if (typeof args === 'object' && args !== null && 'command' in args) { @@ -316,15 +333,3 @@ function commandOf(args: unknown): string { } return '' } - -function preToolPayload(ctx: Context, exec: ToolExecution, model: string): Record { - // `tool_name` is the REAL tool name (matching the `exec.name` matcher subject); - // a hardcoded constant would disagree with what the matcher tests and make a - // config's tool matcher never fire. `tool_input` keeps Codex's `{ command }` - // shape (its shell payload), derived from the call's `command` arg when present. - return { ...turnBase(ctx, exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId } -} - -function postToolPayload(ctx: Context, exec: ToolExecution, result: ToolExecutionResult, model: string): Record { - return { ...turnBase(ctx, exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) } -} diff --git a/packages/experimental/hooks-codex/tests/coverage-cases.ts b/packages/experimental/hooks-codex/tests/coverage-cases.ts index 499a4a8fc1..d05a64984e 100644 --- a/packages/experimental/hooks-codex/tests/coverage-cases.ts +++ b/packages/experimental/hooks-codex/tests/coverage-cases.ts @@ -326,7 +326,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) ctx.logger.warn = warn as never // Direct apply (schema bypass) → the `model ?? ''` fallback is exercised. - HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') }) + await HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) diff --git a/packages/experimental/session-title-all-prompts-llm/tests/provider.spec.ts b/packages/experimental/session-title-all-prompts-llm/tests/provider.spec.ts index b2bf78ddca..282662b1a0 100644 --- a/packages/experimental/session-title-all-prompts-llm/tests/provider.spec.ts +++ b/packages/experimental/session-title-all-prompts-llm/tests/provider.spec.ts @@ -92,7 +92,7 @@ async function harness( await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SessionTitleService, TITLE_CONFIG) const adapter = new RecordingAdapter(script, reasoningEfforts) ctx.llm.registerAdapter(['title-route'], adapter) @@ -215,7 +215,7 @@ describe('all-messages LLM title provider', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) // A one-byte fallback cap cannot derive a title from the multibyte message, // so the service supplies no currentTitle to the provider. await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 1, maxTitleBytes: 80 }) diff --git a/packages/experimental/tool-session-query/src/index.ts b/packages/experimental/tool-session-query/src/index.ts index 61e18cc8f5..8bbf6c3bff 100644 --- a/packages/experimental/tool-session-query/src/index.ts +++ b/packages/experimental/tool-session-query/src/index.ts @@ -9,13 +9,11 @@ import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' import { toolInput } from './input.ts' -import { operations } from './operations.ts' +import { SessionQueryOperations } from './operations.ts' import { presentation } from './presentation.ts' -/** Cordis plugin name used by Loader diagnostics. */ export const name = 'tool-session-query' -/** Capability services required by the model-facing consumer. */ export const inject = ['tools', 'systemPrompt', 'sessionQuery', 'sessionProjections'] /** Default maximum number of authorized search hits returned by one call. */ @@ -53,8 +51,15 @@ const PROMPT_TEXT = + 'events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with ' + 'session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data.' -/** Register all five tools and their shared model guidance. */ -export function apply(ctx: Context, config: Config): void { +/** + * Register all five tools and their shared model guidance. + * @param ctx - owning plugin context. + * @param config - search limits. + * @returns when the tools and their projection reader are ready. + */ +export async function apply(ctx: Context, config: Config): Promise { + const operations = new SessionQueryOperations(ctx) + await operations.initialize() const resolved = resolveConfig(config) ctx.systemPrompt.section({ name: 'tool:session-query', @@ -68,7 +73,7 @@ export function apply(ctx: Context, config: Config): void { parameters: toolInput.sessionSearchParameters, output: TEXT_OUTPUT, timeoutMs: resolved.searchTimeoutMs, - execute: (args, exec) => operations.executeSessionSearch(ctx, args, exec, resolved.maxSearchResults), + execute: (args, exec) => operations.executeSessionSearch(args, exec, resolved.maxSearchResults), presentCall: presentation.presentSessionSearchCall, })) @@ -78,7 +83,7 @@ export function apply(ctx: Context, config: Config): void { parameters: toolInput.eventSearchParameters, output: TEXT_OUTPUT, timeoutMs: resolved.searchTimeoutMs, - execute: (args, exec) => operations.executeEventSearch(ctx, args, exec, resolved.maxSearchResults), + execute: (args, exec) => operations.executeEventSearch(args, exec, resolved.maxSearchResults), presentCall: presentation.presentEventSearchCall, })) @@ -88,7 +93,7 @@ export function apply(ctx: Context, config: Config): void { parameters: toolInput.targetSessionParameter, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => operations.executeSessionTrace(ctx, args, exec), + execute: (args, exec) => operations.executeSessionTrace(args, exec), presentCall: presentation.presentSessionTraceCall, })) @@ -101,7 +106,7 @@ export function apply(ctx: Context, config: Config): void { }, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => operations.executeEventTrace(ctx, args, exec), + execute: (args, exec) => operations.executeEventTrace(args, exec), presentCall: args => presentation.presentEventTargetCall('Trace event', args), })) @@ -116,7 +121,7 @@ export function apply(ctx: Context, config: Config): void { }, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => operations.executeEventRead(ctx, args, exec), + execute: (args, exec) => operations.executeEventRead(args, exec), presentCall: args => presentation.presentEventTargetCall('Read event', args), })) } diff --git a/packages/experimental/tool-session-query/src/operations.ts b/packages/experimental/tool-session-query/src/operations.ts index 3d5fa17f31..4c05480221 100644 --- a/packages/experimental/tool-session-query/src/operations.ts +++ b/packages/experimental/tool-session-query/src/operations.ts @@ -4,6 +4,8 @@ * @module @deepseek-ai/dsh-tool-session-query/operations */ +import { turnBoundaryProjectionDefinition, type TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { Context } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import { SessionSeq } from '@deepseek-ai/dsh-session' @@ -52,193 +54,249 @@ interface SearchCollection { readonly capped: boolean } -async function executeSessionSearch( - ctx: Context, - args: SessionSearchArgs, - exec: ToolRunContext, - maxResults: number, -): Promise { - const caller = workspaceAccess.callerOf(exec, ctx) - const cwd = caller.header.cwd - if (cwd === undefined) { - throw new HarnessError( - 'cross-session search is unavailable because the caller session has no workspace', - 'SESSION_QUERY_TOOL_UNAUTHORIZED', - ) - } - const query = toolInput.normalizeQuery(args.query) - const sessionFilters = toolInput.buildSessionFilters(args) - const eventFilters = toolInput.buildEventFilters({ - seqFrom: args.event_seq_from, - seqTo: args.event_seq_to, - timeFrom: args.event_time_from, - timeTo: args.event_time_to, - eventTypes: args.event_types, - surfaces: args.event_surfaces, - }) - const requestedParentIds = toolInput.materializeParentSessionIds(args.parent_session_ids) - if (requestedParentIds !== undefined || args.include_root_sessions === true) { - const authorizedParentIds = requestedParentIds === undefined - ? new Set() - : await workspaceAccess.authorizeSessionIds(ctx, caller, requestedParentIds, exec.signal) - const parentValues: Array = requestedParentIds - ?.filter(id => authorizedParentIds.has(id)) ?? [] - if (args.include_root_sessions === true) parentValues.push(null) - if (parentValues.length === 0) return presentation.formatEmptySessionSearch() - sessionFilters.push({ kind: 'parent', values: parentValues }) - } - sessionFilters.push({ kind: 'cwd', values: [cwd] }) - const collected = await collectPages( - maxResults, - exec.signal, - cursor => serviceBoundary.call(ctx, exec.signal, 'session search', () => - ctx.sessionQuery.searchSessions({ - query, - sessionFilters, - eventFilters, - ...cursor === undefined ? {} : { cursor }, - }, { signal: exec.signal })), - hit => hit.header.id !== caller.id && workspaceAccess.recordAuthorized(hit, caller), - ) +/** Session-history tool operations over the caller’s current turn state. */ +export class SessionQueryOperations { + private turnBoundary!: ProjectionHandle - const parentIds = collected.items - .map(hit => hit.header.parentSession) - .filter((id): id is SessionId => id !== undefined) - const authorizedParents = await workspaceAccess.authorizeSessionIds(ctx, caller, parentIds, exec.signal) - const titles = await workspaceAccess.readTitles( - ctx, - caller, - collected.items.map(hit => hit.header.id), - exec.signal, - ) - return presentation.formatSessionSearch(collected, titles, authorizedParents) -} + constructor(private readonly ctx: Context) {} -async function executeEventSearch( - ctx: Context, - args: EventSearchArgs, - exec: ToolRunContext, - maxResults: number, -): Promise { - const caller = workspaceAccess.callerOf(exec, ctx) - const sessionId = workspaceAccess.targetId(args, caller) - await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) - const query = toolInput.normalizeQuery(args.query) - const range = toolInput.sequenceRange(args.seq_from, args.seq_to) - if (sessionId === caller.id) { - const stepStartSeq = caller.boundary?.lastStepStartSeq - if (stepStartSeq === undefined) { + /** + * Acquire projection readers before exposing dependent operations. + * @returns when all required registrations are ready. + */ + async initialize(): Promise { + this.turnBoundary = await this.ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + } + + private callerOf(exec: ToolRunContext) { + const agent = exec.agent + if (agent === undefined) { throw new HarnessError( - 'current-session search requires an active step boundary', - 'SESSION_QUERY_TOOL_NO_CURRENT_STEP', + 'session query tools require an agent-bound caller', + 'SESSION_QUERY_TOOL_MISSING_AGENT', ) } - // `null` (no step started yet) caps the range to a degenerate `to` the - // filter validation rejects — the loop never runs tools outside a step. - range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, (stepStartSeq ?? 0) - 1) - } - const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) - if (range.from !== undefined && range.to !== undefined && range.from > range.to) { - return presentation.formatEventSearch(sessionId, title, { items: [], capped: false }) - } - const filters = toolInput.buildEventFilters({ - seqFrom: range.from, - seqTo: range.to, - timeFrom: args.time_from, - timeTo: args.time_to, - eventTypes: args.event_types, - surfaces: args.surfaces, - }) - const collected = await collectPages( - maxResults, - exec.signal, - async (cursor): Promise => { - const page = await serviceBoundary.call(ctx, exec.signal, 'event search', () => - ctx.sessionQuery.searchEvents({ - sessionId, - query, - filters, - ...cursor === undefined ? {} : { cursor }, - }, { signal: exec.signal })) - workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, page.session) - return page - }, - () => true, - ) - return presentation.formatEventSearch(sessionId, title, collected) -} - -async function executeSessionTrace( - ctx: Context, - args: SessionTargetArgs, - exec: ToolRunContext, -): Promise { - const caller = workspaceAccess.callerOf(exec, ctx) - const sessionId = workspaceAccess.targetId(args, caller) - await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) - const trace = await serviceBoundary.call(ctx, exec.signal, 'session lineage trace', () => - ctx.sessionQuery.traceSession(sessionId, exec.signal)) - workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.target.header) - - const ancestors: SessionRecord[] = [] - let ancestorBoundary = false - for (const ancestor of trace.ancestors) { - if (!workspaceAccess.recordAuthorized(ancestor, caller)) { - ancestorBoundary = true - break + return { + id: agent.session.id, + header: agent.session.header, + boundary: this.turnBoundary.stateOf(agent.session), } - ancestors.push(ancestor) } - if (ancestors.length === trace.ancestors.length && !trace.complete) ancestorBoundary = true - const descendants = workspaceAccess.authorizeDescendants(trace.descendants, caller) - const visibleIds = [ - trace.target.header.id, - ...ancestors.map(record => record.header.id), - ...workspaceAccess.descendantIds(descendants), - ] - const titles = await workspaceAccess.readTitles(ctx, caller, visibleIds, exec.signal) - return presentation.formatSessionTrace(trace, ancestors, ancestorBoundary, descendants, titles) -} -async function executeEventTrace( - ctx: Context, - args: EventTargetArgs, - exec: ToolRunContext, -): Promise { - toolInput.assertNonNegativeSafeInteger('seq', args.seq) - const seq = SessionSeq(args.seq) - const caller = workspaceAccess.callerOf(exec, ctx) - const sessionId = workspaceAccess.targetId(args, caller) - await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) - const trace = await serviceBoundary.call(ctx, exec.signal, 'event trace', () => - ctx.sessionQuery.traceEvent({ sessionId, seq }, exec.signal)) - workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.session) - const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) - return presentation.formatEventTrace(sessionId, title, trace) -} + /** + * Search other Sessions within the caller’s authorized workspace. + * @param args - Session and event filters. + * @param exec - Agent-bound caller and cancellation signal. + * @param maxResults - Maximum number of authorized matches to return. + * @returns Formatted authorized Session matches and truncation status. + */ + async executeSessionSearch( + args: SessionSearchArgs, + exec: ToolRunContext, + maxResults: number, + ): Promise { + const ctx = this.ctx + const caller = this.callerOf(exec) + const cwd = caller.header.cwd + if (cwd === undefined) { + throw new HarnessError( + 'cross-session search is unavailable because the caller session has no workspace', + 'SESSION_QUERY_TOOL_UNAUTHORIZED', + ) + } + const query = toolInput.normalizeQuery(args.query) + const sessionFilters = toolInput.buildSessionFilters(args) + const eventFilters = toolInput.buildEventFilters({ + seqFrom: args.event_seq_from, + seqTo: args.event_seq_to, + timeFrom: args.event_time_from, + timeTo: args.event_time_to, + eventTypes: args.event_types, + surfaces: args.event_surfaces, + }) + const requestedParentIds = toolInput.materializeParentSessionIds(args.parent_session_ids) + if (requestedParentIds !== undefined || args.include_root_sessions === true) { + const authorizedParentIds = requestedParentIds === undefined + ? new Set() + : await workspaceAccess.authorizeSessionIds(ctx, caller, requestedParentIds, exec.signal) + const parentValues: Array = requestedParentIds + ?.filter(id => authorizedParentIds.has(id)) ?? [] + if (args.include_root_sessions === true) parentValues.push(null) + if (parentValues.length === 0) return presentation.formatEmptySessionSearch() + sessionFilters.push({ kind: 'parent', values: parentValues }) + } + sessionFilters.push({ kind: 'cwd', values: [cwd] }) + const collected = await collectPages( + maxResults, + exec.signal, + cursor => serviceBoundary.call(ctx, exec.signal, 'session search', () => + ctx.sessionQuery.searchSessions({ + query, + sessionFilters, + eventFilters, + ...cursor === undefined ? {} : { cursor }, + }, { signal: exec.signal })), + hit => hit.header.id !== caller.id && workspaceAccess.recordAuthorized(hit, caller), + ) -async function executeEventRead( - ctx: Context, - args: EventReadArgs, - exec: ToolRunContext, -): Promise { - toolInput.assertNonNegativeSafeInteger('seq', args.seq) - const seq = SessionSeq(args.seq) - if (args.before !== undefined) toolInput.assertNonNegativeSafeInteger('before', args.before) - if (args.after !== undefined) toolInput.assertNonNegativeSafeInteger('after', args.after) - const caller = workspaceAccess.callerOf(exec, ctx) - const sessionId = workspaceAccess.targetId(args, caller) - await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) - const window = await serviceBoundary.call(ctx, exec.signal, 'event read', () => - ctx.sessionQuery.readEvent({ - sessionId, - seq, - ...args.before === undefined ? {} : { before: args.before }, - ...args.after === undefined ? {} : { after: args.after }, - }, exec.signal)) - workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, window.session) - const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) - return presentation.formatEventRead(sessionId, title, window) + const parentIds = collected.items + .map(hit => hit.header.parentSession) + .filter((id): id is SessionId => id !== undefined) + const authorizedParents = await workspaceAccess.authorizeSessionIds(ctx, caller, parentIds, exec.signal) + const titles = await workspaceAccess.readTitles( + ctx, + caller, + collected.items.map(hit => hit.header.id), + exec.signal, + ) + return presentation.formatSessionSearch(collected, titles, authorizedParents) + } + + /** + * Search authorized Session events, excluding the caller’s current step. + * @param args - Target Session and event filters. + * @param exec - Agent-bound caller and cancellation signal. + * @param maxResults - Maximum number of authorized matches to return. + * @returns Formatted matching events and truncation status. + */ + async executeEventSearch( + args: EventSearchArgs, + exec: ToolRunContext, + maxResults: number, + ): Promise { + const ctx = this.ctx + const caller = this.callerOf(exec) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const query = toolInput.normalizeQuery(args.query) + const range = toolInput.sequenceRange(args.seq_from, args.seq_to) + if (sessionId === caller.id) { + const stepStartSeq = caller.boundary.lastStepStartSeq + // `null` (no step started yet) caps the range to a degenerate `to` the + // filter validation rejects — the loop never runs tools outside a step. + range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, (stepStartSeq ?? 0) - 1) + } + const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) + if (range.from !== undefined && range.to !== undefined && range.from > range.to) { + return presentation.formatEventSearch(sessionId, title, { items: [], capped: false }) + } + const filters = toolInput.buildEventFilters({ + seqFrom: range.from, + seqTo: range.to, + timeFrom: args.time_from, + timeTo: args.time_to, + eventTypes: args.event_types, + surfaces: args.surfaces, + }) + const collected = await collectPages( + maxResults, + exec.signal, + async (cursor): Promise => { + const page = await serviceBoundary.call(ctx, exec.signal, 'event search', () => + ctx.sessionQuery.searchEvents({ + sessionId, + query, + filters, + ...cursor === undefined ? {} : { cursor }, + }, { signal: exec.signal })) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, page.session) + return page + }, + () => true, + ) + return presentation.formatEventSearch(sessionId, title, collected) + } + + /** + * Trace Session lineage within the caller’s workspace permissions. + * @param args - Optional target Session id; defaults to the caller. + * @param exec - Agent-bound caller and cancellation signal. + * @returns Formatted authorized ancestors and descendants. + */ + async executeSessionTrace( + args: SessionTargetArgs, + exec: ToolRunContext, + ): Promise { + const ctx = this.ctx + const caller = this.callerOf(exec) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const trace = await serviceBoundary.call(ctx, exec.signal, 'session lineage trace', () => + ctx.sessionQuery.traceSession(sessionId, exec.signal)) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.target.header) + + const ancestors: SessionRecord[] = [] + let ancestorBoundary = false + for (const ancestor of trace.ancestors) { + if (!workspaceAccess.recordAuthorized(ancestor, caller)) { + ancestorBoundary = true + break + } + ancestors.push(ancestor) + } + if (ancestors.length === trace.ancestors.length && !trace.complete) ancestorBoundary = true + const descendants = workspaceAccess.authorizeDescendants(trace.descendants, caller) + const visibleIds = [ + trace.target.header.id, + ...ancestors.map(record => record.header.id), + ...workspaceAccess.descendantIds(descendants), + ] + const titles = await workspaceAccess.readTitles(ctx, caller, visibleIds, exec.signal) + return presentation.formatSessionTrace(trace, ancestors, ancestorBoundary, descendants, titles) + } + + /** + * Trace an event after authorizing its owning Session. + * @param args - Target Session and event sequence. + * @param exec - Agent-bound caller and cancellation signal. + * @returns Formatted event lineage. + */ + async executeEventTrace( + args: EventTargetArgs, + exec: ToolRunContext, + ): Promise { + const ctx = this.ctx + toolInput.assertNonNegativeSafeInteger('seq', args.seq) + const seq = SessionSeq(args.seq) + const caller = this.callerOf(exec) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const trace = await serviceBoundary.call(ctx, exec.signal, 'event trace', () => + ctx.sessionQuery.traceEvent({ sessionId, seq }, exec.signal)) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.session) + const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) + return presentation.formatEventTrace(sessionId, title, trace) + } + + /** + * Read an event window after authorizing its owning Session. + * @param args - Target event and surrounding event counts. + * @param exec - Agent-bound caller and cancellation signal. + * @returns Formatted event window. + */ + async executeEventRead( + args: EventReadArgs, + exec: ToolRunContext, + ): Promise { + const ctx = this.ctx + toolInput.assertNonNegativeSafeInteger('seq', args.seq) + const seq = SessionSeq(args.seq) + if (args.before !== undefined) toolInput.assertNonNegativeSafeInteger('before', args.before) + if (args.after !== undefined) toolInput.assertNonNegativeSafeInteger('after', args.after) + const caller = this.callerOf(exec) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const window = await serviceBoundary.call(ctx, exec.signal, 'event read', () => + ctx.sessionQuery.readEvent({ + sessionId, + seq, + ...args.before === undefined ? {} : { before: args.before }, + ...args.after === undefined ? {} : { after: args.after }, + }, exec.signal)) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, window.session) + const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) + return presentation.formatEventRead(sessionId, title, window) + } } async function collectPages( @@ -275,12 +333,3 @@ async function collectPages( cursor = page.nextCursor } } - -/** Five model-facing session-query operation implementations. */ -export const operations = { - executeSessionSearch, - executeEventSearch, - executeSessionTrace, - executeEventTrace, - executeEventRead, -} diff --git a/packages/experimental/tool-session-query/src/workspace-access.ts b/packages/experimental/tool-session-query/src/workspace-access.ts index 9eabee4e46..4f11e64f38 100644 --- a/packages/experimental/tool-session-query/src/workspace-access.ts +++ b/packages/experimental/tool-session-query/src/workspace-access.ts @@ -6,7 +6,6 @@ import type { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' -import { HarnessError } from '@deepseek-ai/dsh-llm' import { type SessionHeader, type SessionId as SessionIdValue, @@ -16,15 +15,13 @@ import type { SessionLineageNode, SessionRecord, } from '@deepseek-ai/dsh-session-query' -import type { ToolRunContext } from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-session-projection' import { serviceBoundary } from './service-boundary.ts' interface Caller { readonly id: SessionIdValue readonly header: SessionHeader /** The caller's own-session boundary fold (the `turnBoundary` projection). */ - readonly boundary: TurnBoundaryProjection | undefined + readonly boundary: TurnBoundaryProjection } interface TitleView { @@ -53,21 +50,6 @@ interface DescendantVisit { readonly next: DescendantVisit | undefined } -function callerOf(exec: ToolRunContext, ctx: Context): Caller { - const agent = exec.agent - if (agent === undefined) { - throw new HarnessError( - 'session query tools require an agent-bound caller', - 'SESSION_QUERY_TOOL_MISSING_AGENT', - ) - } - return { - id: agent.session.id, - header: agent.session.header, - boundary: ctx.sessionProjections.stateOf(agent.session, 'turnBoundary'), - } -} - function targetId(args: { readonly session_id?: string }, caller: Caller): SessionIdValue { return args.session_id === undefined ? caller.id : brandString(args.session_id) } @@ -242,7 +224,6 @@ function titleText(view: TitleView): string { /** Workspace-scoped caller authorization, title access, and lineage projection. */ export const workspaceAccess = { - callerOf, targetId, authorizeTarget, recordAuthorized, diff --git a/packages/experimental/tool-session-query/tests/sqlite-integration.spec.ts b/packages/experimental/tool-session-query/tests/sqlite-integration.spec.ts index ed6402865f..d71079adb7 100644 --- a/packages/experimental/tool-session-query/tests/sqlite-integration.spec.ts +++ b/packages/experimental/tool-session-query/tests/sqlite-integration.spec.ts @@ -33,8 +33,8 @@ function fakeAgent(session: Session): Agent { return { id: session.id, session } as unknown as Agent } -function registerTurnBoundary(ctx: Context): void { - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) +async function registerTurnBoundary(ctx: Context): Promise { + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) } describe('tool-session-query with the real SQLite provider', () => { @@ -45,7 +45,7 @@ describe('tool-session-query with the real SQLite provider', () => { contexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) + await registerTurnBoundary(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) @@ -118,7 +118,7 @@ describe('tool-session-query with the real SQLite provider', () => { contexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) + await registerTurnBoundary(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) diff --git a/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts b/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts index 3d11ad3f85..c5dd7fddfc 100644 --- a/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts @@ -31,6 +31,7 @@ import SessionQueryEngine, { } from '@deepseek-ai/dsh-session-query' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import type { Config as ToolSessionQueryConfig } from '@deepseek-ai/dsh-tool-session-query' import * as ToolSessionQuery from '@deepseek-ai/dsh-tool-session-query' declare module '@deepseek-ai/dsh-llm' { @@ -207,12 +208,12 @@ interface Mounted { call(name: string, args: unknown, options?: { agent?: Agent; signal?: AbortSignal }): Promise } -function registerTurnBoundary(ctx: Context): void { - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) +async function registerTurnBoundary(ctx: Context): Promise { + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) } async function mount( - config: ToolSessionQuery.Config = {}, + config: ToolSessionQueryConfig = {}, callerCwd: string | null = '/work', enforceTimeout = false, ): Promise { @@ -220,7 +221,7 @@ async function mount( activeContexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) + await registerTurnBoundary(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) if (enforceTimeout) await ctx.plugin(TimeoutPolicy) @@ -329,14 +330,14 @@ describe('registration and schemas', () => { it('fails invalid direct config before registering anything', async () => { const mounted = await mount() for (const maxSearchResults of [0, 1.5, Number.NaN]) { - expect(() => { ToolSessionQuery.apply(mounted.ctx, { maxSearchResults }) }) - .toThrow('maxSearchResults') + await expect(ToolSessionQuery.apply(mounted.ctx, { maxSearchResults })) + .rejects.toThrow('maxSearchResults') } for (const searchTimeoutMs of [0, 1.5, Number.POSITIVE_INFINITY, MAX_TIMER_DELAY_MS + 1]) { - expect(() => { ToolSessionQuery.apply(mounted.ctx, { searchTimeoutMs }) }) - .toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`) + await expect(ToolSessionQuery.apply(mounted.ctx, { searchTimeoutMs })) + .rejects.toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`) } - expect(() => { ToolSessionQuery.apply(new Context(), {}) }).toThrow() + await expect(ToolSessionQuery.apply(new Context(), {})).rejects.toThrow() }) it('expresses the complete Node timer range in the Loader config schema', () => { @@ -1652,7 +1653,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => expect(text(result)).toContain('Result cap reached') }) - it('rejects current-session search without the turnBoundary fold', async () => { + it('rejects current-session search before any step has started', async () => { const ctx = new Context() activeContexts.push(ctx) await ctx.plugin(SessionStore) @@ -1669,7 +1670,8 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => signal: new AbortController().signal, agent: fakeAgent(session), }) - expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_NO_CURRENT_STEP') + expect(ctx.sessionProjections.checkpoint(session).turnBoundary).toBeDefined() + expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_FILTER') }) it('preserves base results when a title read fails, annotates the code, and logs the full error', async () => { diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index f495097df2..6d8c8e9698 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -841,7 +841,7 @@ describe('sandbox escalation API (write/edit)', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write' }) await ctx.plugin(SandboxingFakeFs) await ctx.plugin(FsPolicy) diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 48a02e485c..cbc0344b95 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -5,7 +5,7 @@ */ import { randomUUID } from 'node:crypto' -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' import type { ZodType } from 'zod' @@ -14,7 +14,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionLogOffset } from '@deepseek-ai/dsh-session' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import { applyGoalEvent, @@ -247,15 +247,21 @@ export class GoalService extends TypertRemoteService { private readonly resolved: ResolvedConfig private readonly runtimeStates = new WeakMap() + private projection!: ProjectionHandle + constructor(ctx: Context, config: Config = {}) { super(ctx, 'goals') this.resolved = { defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256), } + } + + protected async [Service.init](): Promise { + const ctx = this.ctx + this.projection = await ctx.sessionProjections.register(goalProjectionDefinition) ctx.on('agent/created', ({ agent }) => { this.setActivation(agent.session, 'disarmed') }) - ctx.sessionProjections.register(goalProjectionDefinition) ctx.on('session/event', (session, event) => { if (event.type !== 'goal/change') return const runtime = this.runtimeState(session) @@ -474,8 +480,7 @@ export class GoalService extends TypertRemoteService { /** Read the current durable projection maintained by the registry. */ private state(session: Session): GoalProjection | null { - const state = this.ctx.sessionProjections.stateOf(session, 'goal') - if (state === undefined) throw new Error('goal projection is not registered') + const state = this.projection.stateOf(session) if (state.failure !== null) throw new Error(state.failure) return state.current } @@ -497,9 +502,7 @@ export class GoalService extends TypertRemoteService { const runtime = this.runtimeState(session) if (runtime.activation === activation) return runtime.activation = activation - const state = this.ctx.sessionProjections.stateOf(session, 'goal') - /* v8 ignore next -- static inject requires the projection registry before this service activates. */ - if (state === undefined) return + const state = this.projection.stateOf(session) if (state.failure !== null) return const goal = this.view(state.current, runtime) this.ctx.emit('goal/activation-changed', { diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index fe9c2d0030..462a0f59fa 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -1,3 +1,4 @@ +import { Service } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' @@ -174,8 +175,11 @@ describe('GoalService creation and replay', () => { await ctx.plugin(SessionProjectionRegistry) const stub = stubAgent('goal-direct-construction') await ctx.agents.register(stub.agent) - const goals = new GoalService(ctx) - await new Promise(resolve => setImmediate(resolve)) + class DirectGoalService extends GoalService { + initialize(): Promise { return this[Service.init]() } + } + const goals = new DirectGoalService(ctx) + await goals.initialize() expect(goals.create(stub.agent, { objective: 'direct' })).toMatchObject({ objective: 'direct', maxGoalRounds: 256, }) @@ -277,8 +281,8 @@ describe('GoalService creation and replay', () => { await fiber.dispose() expect(ctx.get('goals')).toBeUndefined() - expect(ctx.sessionProjections.stateOf(stub.session, 'goal')).toBeUndefined() - expect(() => first.get(stub.agent)).toThrow('goal projection is not registered') + expect(ctx.sessionProjections.checkpoint(stub.session).goal?.val).toBeUndefined() + expect(first.get(stub.agent)).toMatchObject({ id: goal.id }) await ctx.plugin(GoalService) expect(ctx.goals).not.toBe(first) diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 227da38ad8..64071e4038 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -235,9 +235,10 @@ describe('goal projection unit', () => { const bench = await harness(true) bench.ctx.goals.create(bench.agent, { objective: 'poisoned replay' }) const failure = 'goal replay failed at session event 0: invalid restored goal stream' - const state = bench.ctx.sessionProjections.stateOf(bench.session, 'goal') + const handle = await bench.ctx.sessionProjections.register(goalProjectionDefinition) + const state = handle.stateOf(bench.session) expect(state).toBeDefined() - Object.assign(state!, { failure }) + Object.assign(state, { failure }) await expect(agentEvents(bench.ctx, bench.agent).serial('agent/created', { source: 'resume' })) .resolves.toBeUndefined() diff --git a/packages/goal/tool-goal/README.i18n.yaml b/packages/goal/tool-goal/README.i18n.yaml index 492ae8374e..1d97f69a1b 100644 --- a/packages/goal/tool-goal/README.i18n.yaml +++ b/packages/goal/tool-goal/README.i18n.yaml @@ -30,8 +30,8 @@ en: 4e9fdb5a89f4baa3 zh: 50d182467ae1f3a7 /deepseek-ai-dsh-tool-goal/understand-the-implementation/design: - en: a30f36299c8107f7 - zh: 65e6e1e19aeb5281 + en: 905e5e105efdf7a8 + zh: 3704176a160aabb1 /deepseek-ai-dsh-tool-goal/understand-the-implementation/source-map: en: 4d5c648787409868 zh: b4938bfe660f18aa diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 158446e156..9ab458268d 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -69,6 +69,7 @@ This section explains how the tools enforce authority and render output; the obs ### Design - **Authority at execution.** Every call resolves the exact live agent, its inherited `AgentRegistry` initiator, running status, and an open turn; `create`, `edit`, `pause`, and `resume` additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. A durable paused goal fails the `resume` action with `GOAL_TOOL_RESUME_PAUSED`; the user-facing command or Web control owns that transition. Durable fork lineage does not demote a resumed root; live subagent ownership does. +- **Current-turn input projection.** A host-only projection retains human-input presence and admitted goal-round identifiers, resets at each turn boundary, and reconstructs these facts from committed events when registered. - **Host attestation of human input.** `{ kind: 'user' }` is assigned by `Agent.followup()` and `steer()` when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority. - **System-prompt guidance with the configured threshold.** The package registers one `tool:goal` system-prompt section whose fixed text interpolates `blockedAfterConsecutiveRounds`; the same value is the hard lower bound enforced at execution. - **Wrap-up context for terminal rounds.** A successful autonomous `complete` or `blocked` defers a closing `` or `` instruction so the model addresses the user once before the turn ends; direct-human mutations never defer this context. diff --git a/packages/goal/tool-goal/README.zh.md b/packages/goal/tool-goal/README.zh.md index 1ec0a8e1f8..9e868061c2 100644 --- a/packages/goal/tool-goal/README.zh.md +++ b/packages/goal/tool-goal/README.zh.md @@ -69,6 +69,7 @@ kind: "package-reference" ### 设计 - **执行时权限。** 每次调用都解析精确活跃 agent、其继承的 `AgentRegistry` initiator、running 状态与开放轮次;`create`、`edit`、`pause` 和 `resume` 还要求运行时根 agent 的当前轮次中存在已接受的 `{ kind: 'user' }` 消息或 steering 事件。持久的 paused goal 会让 `resume` 以 `GOAL_TOOL_RESUME_PAUSED` 失败;面向用户的命令或 Web 控件拥有该转换。持久 fork 谱系不会降低已恢复根 agent 的等级;活跃 subagent 所有权会降低。 +- **当前轮输入投影。** 仅供 Host 使用的投影保存是否有人类输入及已接纳的目标轮次标识,在每个轮次边界重置,并在注册时从已提交事件重建这些事实。 - **人类输入的宿主证明。** `Agent.followup()` 与 `steer()` 会在调用方省略 source 时分配 `{ kind: 'user' }`,因此插件、调度器与其他非人类生产方必须传入自己的 source,不能继承人类权限。 - **带配置阈值的系统提示词指引。** 本包注册一个 `tool:goal` 系统提示词章节,其固定文本插入 `blockedAfterConsecutiveRounds`;同一数值就是执行时强制执行的硬下限。 - **终局 Round 的结束上下文。** 成功的自主 `complete` 或 `blocked` 会延后一条 `` 或 `` 结束指令,让模型在轮次结束前向用户做一次交代;人类直接变更绝不会延后该上下文。 diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index e5c0e9d8e7..65a9e48f4b 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -39,7 +39,8 @@ "@deepseek-ai/dsh-session-projection": "workspace:*" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:~" + "@deepseek-ai/schemastery": "workspace:~", + "zod": "4.4.3" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:~", diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index eadc0bd8f9..6214005230 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -2,17 +2,57 @@ import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { GoalView } from '@deepseek-ai/dsh-goal' +import { GoalId, type GoalView } from '@deepseek-ai/dsh-goal' import { HarnessError } from '@deepseek-ai/dsh-llm' -import type { SessionEvent, SessionSeq } from '@deepseek-ai/dsh-session' import type { ToolRunContext } from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition, ProjectionHandle } from '@deepseek-ai/dsh-session-projection' +import { z as zod } from 'zod' -/** The calling agent plus the immutable event cut and open-turn start seq used for authority checks. */ +const turnAuthoritySchema = zod.object({ + open: zod.boolean(), + directHuman: zod.boolean(), + goalRounds: zod.array(zod.object({ + goalId: zod.string().transform(GoalId), + revision: zod.number().int().positive(), + round: zod.number().int().positive(), + })), +}) + +/** Accepted input authority for the currently open turn, reset at both turn boundaries. */ +type TurnAuthority = zod.infer + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Input sources admitted in the current turn for goal-tool authorization. */ + goalToolAuthority: TurnAuthority + } +} + +const turnAuthorityProjection = { + key: 'goalToolAuthority', + stateVersion: 1, + stateSchema: turnAuthoritySchema, + init: (): TurnAuthority => ({ open: false, directHuman: false, goalRounds: [] }), + apply: (state, event) => { + if (event.type === 'turn/start' || event.type === 'turn/end') { + return { open: event.type === 'turn/start', directHuman: false, goalRounds: [] } + } + if (!state.open || event.type !== 'user/message') return state + const source = event.data.source + if (source.kind === 'user') return state.directHuman ? state : { ...state, directHuman: true } + if (source.kind === 'goal') { + const { goalId, revision, round } = source + return { ...state, goalRounds: [...state.goalRounds, { goalId, revision, round }] } + } + // Other message producers do not grant goal-tool authority. + return state + }, +} satisfies ProjectionDefinition<'goalToolAuthority', TurnAuthority> + +/** The authenticated caller and immutable authority facts captured for one tool call. */ export interface GoalToolExecution { readonly agent: Agent - readonly events: readonly SessionEvent[] - readonly openTurnStartSeq: SessionSeq + readonly authority: TurnAuthority } /** Hard authority granted to one state-changing call. */ @@ -25,51 +65,44 @@ function reject(message: string, code = 'GOAL_TOOL_AUTHORITY_REQUIRED'): never { throw new HarnessError(message, code) } -/** Resolve the immutable event cut and open-turn boundary without copying the turn suffix. */ -function openTurnEvents( - ctx: Context, - agent: Agent, -): Pick { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. - const events = agent.session.snapshotEvents() - const boundary = ctx.sessionProjections.stateOf(agent.session, 'turnBoundary') - if (boundary === undefined || boundary.openTurnStartSeq === null) { - reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') - } - return { events, openTurnStartSeq: boundary.openTurnStartSeq } -} +/** Resolves goal-tool authority from the live driver and its committed turn. */ +export class GoalToolExecutionResolver { + private authority!: ProjectionHandle -/** - * Resolve and authenticate the calling agent and its driver boundary. - * @param ctx - Context carrying the live agent registry. - * @param exec - Tool execution metadata supplied by the registry. - * @returns The authenticated agent, immutable event cut, and open-turn boundary. - */ -export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolExecution { - const agent = exec.agent - if (agent === undefined) { - return reject('goal tools require a calling agent', 'GOAL_TOOL_AGENT_REQUIRED') - } - if (ctx.agents.get(agent.id) !== agent || agent.status !== 'running' - || ctx.agents.currentInitiator() !== agent) { - return reject( - 'goal tools require the exact live calling agent inside its active driver', - 'GOAL_TOOL_DRIVER_REQUIRED', - ) - } - return { agent, ...openTurnEvents(ctx, agent) } -} + /** @param ctx - Context carrying the live agent registry and session projections. */ + constructor(private readonly ctx: Context) {} -/** Whether the captured open turn contains an event accepted by `predicate`. */ -function someOpenTurnEvent( - execution: GoalToolExecution, - predicate: (event: SessionEvent) => boolean, -): boolean { - for (let seq = execution.openTurnStartSeq + 1; seq < execution.events.length; seq += 1) { - const event = execution.events[seq] - if (event !== undefined && predicate(event)) return true + /** + * Acquire projection readers before exposing dependent operations. + * @returns when all required registrations are ready. + */ + async initialize(): Promise { + this.authority = await this.ctx.sessionProjections.register(turnAuthorityProjection) + } + + /** + * Authenticate the caller and capture its current turn's accepted input sources. + * @param exec - Tool execution carrying the live calling agent. + * @returns the caller and projected authority facts. + */ + resolve(exec: ToolRunContext): GoalToolExecution { + const agent = exec.agent + if (agent === undefined) { + return reject('goal tools require a calling agent', 'GOAL_TOOL_AGENT_REQUIRED') + } + if (this.ctx.agents.get(agent.id) !== agent || agent.status !== 'running' + || this.ctx.agents.currentInitiator() !== agent) { + return reject( + 'goal tools require the exact live calling agent inside its active driver', + 'GOAL_TOOL_DRIVER_REQUIRED', + ) + } + const authority = this.authority.stateOf(agent.session) + if (!authority.open) { + reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') + } + return { agent, authority } } - return false } /** @@ -79,17 +112,14 @@ function someOpenTurnEvent( */ function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean { if (!ctx.agents.roots().includes(execution.agent)) return false - return someOpenTurnEvent(execution, event => - event.type === 'user/message' && event.data.source.kind === 'user') + return execution.authority.directHuman } /** Whether this turn is the current goal's exact admitted round. */ function isMatchingGoalRound(execution: GoalToolExecution, goal: GoalView): boolean { - return someOpenTurnEvent(execution, event => event.type === 'user/message' - && event.data.source.kind === 'goal' - && event.data.source.goalId === goal.id - && event.data.source.revision === goal.revision - && event.data.source.round === goal.roundsStarted) + return execution.authority.goalRounds.some(round => round.goalId === goal.id + && round.revision === goal.revision + && round.round === goal.roundsStarted) } /** diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 9f47f1a77d..a364dec967 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -20,12 +20,13 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' import { completionAuthority, - goalToolExecution, + GoalToolExecutionResolver, requireDirectHuman, } from './authority.ts' import { renderWrapupContext } from './wrapup.ts' export const name = 'tool-goal' + export const inject = ['agents', 'goals', 'tools', 'systemPrompt', 'sessionProjections'] /** Model policy and hard lower bounds for goal-state updates. */ @@ -184,7 +185,9 @@ function present(title: string, kind: 'read' | 'other', rawInput?: unknown): Gen } /** Register the three Codex-shaped goal tools and their shared policy section. */ -export function apply(ctx: Context, config: Config): void { +export async function apply(ctx: Context, config: Config): Promise { + const executionResolver = new GoalToolExecutionResolver(ctx) + await executionResolver.initialize() const resolved = resolveConfig(config) ctx.systemPrompt.section({ name: 'tool:goal', @@ -198,7 +201,7 @@ export function apply(ctx: Context, config: Config): void { parameters: {}, output: GOAL_OUTPUT, execute(_args, exec) { - const execution = goalToolExecution(ctx, exec) + const execution = executionResolver.resolve(exec) return Promise.resolve(goalValue(ctx.goals.get(execution.agent))) }, presentCall: () => present('Read current goal', 'read'), @@ -220,7 +223,7 @@ export function apply(ctx: Context, config: Config): void { }, output: GOAL_OUTPUT, execute(args, exec) { - const execution = goalToolExecution(ctx, exec) + const execution = executionResolver.resolve(exec) requireDirectHuman(ctx, execution) const goal = ctx.goals.create(execution.agent, { objective: args.objective, @@ -253,7 +256,7 @@ export function apply(ctx: Context, config: Config): void { }, output: GOAL_OUTPUT, execute(args, exec) { - const execution = goalToolExecution(ctx, exec) + const execution = executionResolver.resolve(exec) const ref = goalRef(args.goal_id, args.revision) const replacements = { ...hasText(args.objective) ? { objective: args.objective } : {}, diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index ecc7822bce..0bb6a91a48 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -3,7 +3,6 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus, Inbox } from '@deepseek-ai/dsh-agent' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' @@ -19,6 +18,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import * as GoalTools from '@deepseek-ai/dsh-tool-goal' import * as toolGoal from '@deepseek-ai/dsh-tool-goal' import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -104,9 +104,8 @@ async function harness(config: toolGoal.Config = {}) { await ctx.plugin(SystemPrompt) await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(GoalService) - const fiber = await ctx.plugin(toolGoal, config) + const fiber = await ctx.plugin(GoalTools, config) const root = stubAgent(`goal-tool-root-${Math.random()}`, undefined, ctx) await ctx.agents.register(root.agent) return { ctx, fiber, root } @@ -202,25 +201,27 @@ describe('goal tool registration and presentation', () => { it('fails invalid direct config before registering anything', async () => { const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) await ctx.plugin(GoalService) - expect(() => { - toolGoal.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 }) - }).toThrow( + await expect(GoalTools.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 })).rejects.toThrow( 'blockedAfterConsecutiveRounds must be a positive safe integer', ) expect(ctx.tools.get('get_goal')).toBeUndefined() }) - it('resolves the direct-apply default before registration', async () => { + it('resolves the direct-constructor default before registration', async () => { const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) await ctx.plugin(GoalService) - toolGoal.apply(ctx, {}) + await GoalTools.apply(ctx, {}) const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal') expect(section?.text).toContain('at least 3 consecutive goal rounds') }) @@ -331,7 +332,27 @@ describe('goal tool execution authority', () => { expect(malformed.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') }) - it('accepts direct human steering in a goal-sourced root turn', async () => { + it('does not carry human authority into a later non-human turn', async () => { + const { ctx, root } = await harness() + const turn = openTurn(root, { kind: 'user' }) + await execute(ctx, 'get_goal', {}, root.agent) + closeTurn(root, turn) + openTurn(root, { kind: 'test' }) + const result = await execute(ctx, 'create_goal', { objective: 'unauthorized' }, root.agent) + expect(result.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + expect(ctx.goals.get(root.agent)).toBeUndefined() + }) + + it('reconstructs current-turn authority when the tools mount after input was committed', async () => { + const { ctx, root, fiber } = await harness() + await fiber.dispose() + openTurn(root, { kind: 'user' }) + await ctx.plugin(GoalTools, {}) + const result = await execute(ctx, 'create_goal', { objective: 'accepted before tool load' }, root.agent) + expect(resultGoal(result)).toMatchObject({ objective: 'accepted before tool load' }) + }) + + it.each([1, 2])('accepts %i direct human steering messages in a goal-sourced root turn', async (messageCount) => { const { ctx, root } = await harness() const humanTurn = openTurn(root, { kind: 'user' }) const created = ctx.goals.create(root.agent, { objective: 'steer me' }) @@ -339,10 +360,12 @@ describe('goal tool execution authority', () => { openTurn(root, { kind: 'goal', goalId: created.id, revision: created.revision, round: 1, }) - root.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'pause now' }], - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) + for (let index = 0; index < messageCount; index++) { + root.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'pause now' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + } const paused = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'pause', }, root.agent) diff --git a/packages/interaction/permission-presets/README.i18n.yaml b/packages/interaction/permission-presets/README.i18n.yaml index 949ada7106..46968ede01 100644 --- a/packages/interaction/permission-presets/README.i18n.yaml +++ b/packages/interaction/permission-presets/README.i18n.yaml @@ -39,14 +39,14 @@ en: b01849a5fa73aae9 zh: cbd7fcf50117502b /deepseek-ai-dsh-permission-presets/understand-the-implementation/read-side-and-custom: - en: c9ca792038f44654 - zh: 83cc2641c800c114 + en: 76deeb5fbcdf7525 + zh: e96ad7e107947c23 /deepseek-ai-dsh-permission-presets/understand-the-implementation/session-pinning-and-blank-reuse: en: 893cea88bce22903 zh: 2431e19ced670511 /deepseek-ai-dsh-permission-presets/understand-the-implementation/catalog-projection-and-optional-command: - en: 2e79bec74274b054 - zh: 256526ab1b4b55e9 + en: 341f80202edd66a3 + zh: bd8ebb9d011b547f /deepseek-ai-dsh-permission-presets/further-exploration: en: ca27c3e438cffdc5 zh: ae5d25ae55e5d503 diff --git a/packages/interaction/permission-presets/README.md b/packages/interaction/permission-presets/README.md index e21a41a053..d1179a9926 100644 --- a/packages/interaction/permission-presets/README.md +++ b/packages/interaction/permission-presets/README.md @@ -86,7 +86,7 @@ The observable behavior is covered in [Use this package](#use-this-package); thi ### Read side and `custom` -`current(session)` reads the required `permissions` projection, whose unit folds the three whole-value knob events over the composition defaults (`ctx.shell.sandboxMode` and the approval config). The host state also retains whether `session/end-seed` has occurred, so session pinning distinguishes an explicitly empty restored seed from a genuinely fresh session without rescanning the log. A still-matching last selection wins shared-bundle ties, and a recorded Auto selection also matches the `never` approval policy; otherwise the first configured match wins; otherwise the derived `CUSTOM_PRESET` is returned. A missing projection key fails explicitly. +`current(session)` reads the required `permissions` projection, whose unit folds the three whole-value knob events over the composition defaults (`ctx.shell.sandboxMode` and the approval config). The host state also retains whether `session/end-seed` has occurred, so session pinning distinguishes an explicitly empty restored seed from a genuinely fresh session without rescanning the log. A still-matching last selection wins shared-bundle ties, and a recorded Auto selection also matches the `never` approval policy; otherwise the first configured match wins; otherwise the derived `CUSTOM_PRESET` is returned. The service acquires its state reader during activation. `optionOf(name)` returns a configured entry, the live Auto entry, or the display-only `custom` entry. It throws only when the name matches none of those; a withdrawn Auto entry is unavailable. @@ -96,7 +96,7 @@ Mounting pins every live and future session: a genuinely fresh session gains the ### Catalog, projection, and optional command -The service requires `ctx.sessionProjections` and registers a `permissions` projection containing only `currentValue`. Its process-level `catalog()` Remote returns one complete selectable snapshot. Registering or removing Auto emits the payload-free `permission-presets/catalog-changed` notification, so clients subscribe first and then re-read the catalog without appending a Session event, publishing a Session projection frame, or changing Session sequence. The `/permission` command registers only when a `ctx.commands` registry is composed. Calls that derive the current preset or pin an initial selection fail explicitly when the projection key is absent. +The service requires `ctx.sessionProjections` and awaits registration of `permissions`; its client view contains only `currentValue`. Its process-level `catalog()` Remote returns one complete selectable snapshot. Registering or removing Auto emits the payload-free `permission-presets/catalog-changed` notification, so clients subscribe first and then re-read the catalog without appending a Session event, publishing a Session projection frame, or changing Session sequence. The `/permission` command registers only when a `ctx.commands` registry is composed. Calls that derive the current preset or pin an initial selection use the retained projection handle. diff --git a/packages/interaction/permission-presets/README.zh.md b/packages/interaction/permission-presets/README.zh.md index 0477b4197e..6afa0a0163 100644 --- a/packages/interaction/permission-presets/README.zh.md +++ b/packages/interaction/permission-presets/README.zh.md @@ -86,7 +86,7 @@ kind: "package-reference" ### 读取侧与 `custom` -`current(session)` 读取必需的 `permissions` 投影;该单元在组合默认值(`ctx.shell.sandboxMode` 与审批配置)之上折叠三个全量值旋钮事件。host 状态还会保留 `session/end-seed` 是否已经出现,使会话固定无需重扫日志即可区分显式为空的恢复 seed 与真正的新会话。仍匹配的最近选择在共享捆绑时胜出,已记录的 Auto 选择在 `never` 审批策略下也匹配;否则配置表中的第一个匹配项胜出;否则返回推导出的 `CUSTOM_PRESET`。投影 key 缺失时会显式失败。 +`current(session)` 读取必需的 `permissions` 投影;该单元在组合默认值(`ctx.shell.sandboxMode` 与审批配置)之上折叠三个全量值旋钮事件。host 状态还会保留 `session/end-seed` 是否已经出现,使会话固定无需重扫日志即可区分显式为空的恢复 seed 与真正的新会话。仍匹配的最近选择在共享捆绑时胜出,已记录的 Auto 选择在 `never` 审批策略下也匹配;否则配置表中的第一个匹配项胜出;否则返回推导出的 `CUSTOM_PRESET`。该服务在激活期间取得状态读取句柄。 `optionOf(name)` 返回配置条目、存活的 Auto 条目或仅供显示的 `custom` 条目。只有名称不匹配这三者时才抛错;已撤回的 Auto 条目不可用。 @@ -96,7 +96,7 @@ kind: "package-reference" ### 目录、投影与可选命令 -该服务要求 `ctx.sessionProjections`,并注册一个只含 `currentValue` 的 `permissions` 投影。进程级 `catalog()` Remote 返回一份完整的可选快照。注册或移除 Auto 会发出无 payload 的 `permission-presets/catalog-changed` 通知,因此客户端先订阅再重新读取目录;该过程不会追加 Session 事件、发布 Session 投影帧或改变 Session 序列。`/permission` 命令仅在组合了 `ctx.commands` 注册表时注册。派生当前预设或固定初始选择的调用会在投影 key 缺失时显式失败。 +该服务要求 `ctx.sessionProjections`,并等待 `permissions` 注册完成;其客户端视图只含 `currentValue`。进程级 `catalog()` Remote 返回一份完整的可选快照。注册或移除 Auto 会发出无 payload 的 `permission-presets/catalog-changed` 通知,因此客户端先订阅再重新读取目录;该过程不会追加 Session 事件、发布 Session 投影帧或改变 Session 序列。`/permission` 命令仅在组合了 `ctx.commands` 注册表时注册。派生当前预设或固定初始选择的调用使用保留的投影句柄。 diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts index 8135d74282..a3df0f8d74 100644 --- a/packages/interaction/permission-presets/src/index.ts +++ b/packages/interaction/permission-presets/src/index.ts @@ -15,7 +15,7 @@ import type {} from '@deepseek-ai/dsh-settings' import type { Volatile } from '@deepseek-ai/cordis' -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand' import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' @@ -29,7 +29,7 @@ import type {} from '@deepseek-ai/dsh-shell' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { APPROVAL_POLICIES, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' // Type-only: resolves the required projection service and optional settings/command children. -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-commands' import type { PermissionCatalog, PermissionSelection, PresetOption } from './types.ts' @@ -204,6 +204,8 @@ export class PermissionPresetService extends TypertRemoteService { private autoAdmit: (() => void) | undefined private defaultSettings: () => PermissionSettings + private projection!: ProjectionHandle + constructor(ctx: Context, config: Config) { super(ctx, 'permissionPresets') @@ -230,11 +232,14 @@ export class PermissionPresetService extends TypertRemoteService { if (!Object.hasOwn(this.presets, defaultPreset)) throw new Error(`permission: unknown default preset "${defaultPreset}"`) return { defaultPreset } } + } + protected async [Service.init](): Promise { + const ctx = this.ctx const selectionSchema = zod.object({ currentValue: zod.string().min(1), }) as zod.ZodType - ctx.sessionProjections.register({ + this.projection = await ctx.sessionProjections.register({ key: 'permissions', stateVersion: 2, stateSchema: permissionStateSchema, @@ -326,8 +331,7 @@ export class PermissionPresetService extends TypertRemoteService { } private permissionState(session: Session): PermissionProjectionState { - const state = this.ctx.sessionProjections.stateOf(session, 'permissions') - if (state === undefined) throw new Error('permission: permissions session projection is not registered') + const state = this.projection.stateOf(session) return state } diff --git a/packages/interaction/permission-presets/tests/permission-presets.spec.ts b/packages/interaction/permission-presets/tests/permission-presets.spec.ts index 10de7a4f31..6641d2c259 100644 --- a/packages/interaction/permission-presets/tests/permission-presets.spec.ts +++ b/packages/interaction/permission-presets/tests/permission-presets.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore, { Session, @@ -65,17 +65,17 @@ describe('permission preset fold', () => { it('folds the latest preset selection and steps over unrelated events', async () => { const ctx = await mounted() const session = freshSession('sess-fold') - const presetOf = () => ctx.sessionProjections.stateOf(session, 'permissions')?.preset ?? null - expect(presetOf()).toBeNull() + const presetOf = () => ctx.sessionProjections.checkpoint(session).permissions?.val + expect(presetOf()).toMatchObject({ preset: null }) session.append('permission/preset', { preset: 'danger-full-access' }) session.append('permission/preset', { preset: 'workspace-write' }) - expect(presetOf()).toBe('workspace-write') + expect(presetOf()).toMatchObject({ preset: 'workspace-write' }) // The knob fold steps over non-preset events to the latest selection. session.append('sandbox/mode', { mode: 'read-only' }) - expect(presetOf()).toBe('workspace-write') + expect(presetOf()).toMatchObject({ preset: 'workspace-write' }) const seeded = Session.create(SessionId('sess-fold-seeded'), []) - expect(ctx.sessionProjections.stateOf(seeded, 'permissions')?.seeded).toBe(true) + expect(ctx.sessionProjections.checkpoint(seeded).permissions?.val).toMatchObject({ seeded: true }) }) }) @@ -85,13 +85,6 @@ describe('PermissionPresetService', () => { expect(ctx.get('permissionPresets')).toBeUndefined() }) - it('fails when the permissions projection key is absent', async () => { - const ctx = await mounted() - vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined) - expect(() => ctx.permissionPresets.current(freshSession('missing-permission-projection-key'))) - .toThrow('permission: permissions session projection is not registered') - }) - it('advertises the preset table in declaration order and resolves bundles', async () => { const ctx = await mounted() expect(ctx.permissionPresets.names).toEqual(['workspace-write', 'danger-full-access']) diff --git a/packages/interaction/user-questions/src/index.ts b/packages/interaction/user-questions/src/index.ts index 06cb67b8b6..1a93231646 100644 --- a/packages/interaction/user-questions/src/index.ts +++ b/packages/interaction/user-questions/src/index.ts @@ -7,16 +7,16 @@ * @module @deepseek-ai/dsh-user-questions */ -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent' import { createUserMessage, HarnessError, type MessageId, type ToolCallId, type UserMessage } from '@deepseek-ai/dsh-llm' import type { Session } from '@deepseek-ai/dsh-session' import { scopeTarget } from '@deepseek-ai/dsh-scope' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import type { Agent } from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import z from '@deepseek-ai/schemastery' -import { userQuestionProjectionDefinition } from './projection.ts' +import { userQuestionProjectionDefinition, type UserQuestionProjectionState } from './projection.ts' import { TimedQuestionWait } from './timed-wait.ts' declare module '@deepseek-ai/cordis' { @@ -78,14 +78,12 @@ type QueuedReply = { messageId: MessageId; claimedTurn?: number } /** `ctx.userQuestions`: validation plus the scoped answerer waterfall. */ export class UserQuestionService extends TypertRemoteService { static Config = z.object({}) + private projection: ProjectionHandle | undefined private readonly waits = new Map>() private readonly queuedReplies = new WeakMap>() constructor(ctx: Context) { super(ctx, 'userQuestions') - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register(userQuestionProjectionDefinition) - }) ctx.effect(() => () => { for (const calls of this.waits.values()) { for (const wait of calls.values()) wait.close(abortedQuestion()) @@ -128,6 +126,14 @@ export class UserQuestionService extends TypertRemoteService { calls.delete(callId) } + protected async [Service.init](): Promise { + const binding = this.ctx.inject(['sessionProjections'], async (ctx) => { + this.projection = await ctx.sessionProjections.register(userQuestionProjectionDefinition) + return () => { this.projection = undefined } + }) + if (this.ctx.get('sessionProjections') !== undefined) await binding + } + private assertLiveRoot(agent: Agent): void { const agents = this.ctx.get('agents') if (agents === undefined || agents.get(agent.id) !== agent) { @@ -144,8 +150,7 @@ export class UserQuestionService extends TypertRemoteService { } private continued(agent: Agent): readonly PendingUserQuestion[] { - const state = this.ctx.get('sessionProjections')?.stateOf(agent.session, 'userQuestions') - return (state?.questions.active ?? []).filter(question => question.state === 'continued') + return this.projection?.stateOf(agent.session).questions.active.filter(question => question.state === 'continued') ?? [] } /** diff --git a/packages/interaction/user-questions/tests/user-questions.spec.ts b/packages/interaction/user-questions/tests/user-questions.spec.ts index f0f744259a..0cbb2a5031 100644 --- a/packages/interaction/user-questions/tests/user-questions.spec.ts +++ b/packages/interaction/user-questions/tests/user-questions.spec.ts @@ -895,7 +895,7 @@ describe('late replies', () => { error: { name: 'SessionFormatError', code: TOOL_OUTCOME_UNKNOWN }, }, { surfaceOp: 'append' }) - expect(ctx.sessionProjections.stateOf(agent.session, 'userQuestions')?.questions).toEqual({ active: [], settled: [] }) + expect(ctx.sessionProjections.checkpoint(agent.session).userQuestions?.val).toMatchObject({ questions: { active: [], settled: [] } }) expect(ctx.userQuestions.answer(agent, timedCallId, { answers: [{ id: 'scope', selected: [] }] })).toBe(false) expect(agent.steer).not.toHaveBeenCalled() await ctx.fiber.dispose() diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 89ffc14c40..60cca5ba2f 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -19,6 +19,7 @@ export type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts' export { RetryId } from './brand.ts' export const name = 'llm-retry' + export const inject = ['agents', 'sessionProjections'] /** This policy executor has no config; providers own `retryPolicy`. */ @@ -95,12 +96,6 @@ function cancellableDelay(delayMs: number, signal: AbortSignal): Promise { validateConfig(config) - ctx.sessionProjections.register({ + const projection = await ctx.sessionProjections.register({ key: 'llmRetry', stateVersion: 1, stateSchema: llmRetryStateSchema, @@ -217,7 +219,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna } const policyKey = retryPolicyKey(policy) - const retryState = ctx.sessionProjections.stateOf(agent.session, 'llmRetry') as LlmRetryState + const retryState = projection.stateOf(agent.session) const previous = retryState[retryStateKey(provider, policyKey)] const previousRetry = previous?.retry ?? 0 if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next() diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 3adb88de05..43ead12c94 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -20,6 +20,7 @@ import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import type { Config as retryConfig, RetryInternals as retryRetryInternals } from '../src/index.ts' import * as retry from '../src/index.ts' type ScriptEntry = Error | Iterable | AsyncIterable @@ -103,7 +104,7 @@ async function harness( adapter: ScriptedAdapter, policies: Readonly> = { mock: normalConfig() }, beforeRetry?: (ctx: Context) => void, - internals: retry.RetryInternals = {}, + internals: retryRetryInternals = {}, ): Promise<{ ctx: Context; retryFiber: Fiber; disposeAdapter: () => void }> { const ctx = new Context() await ctx.plugin(LlmRuntime) @@ -114,8 +115,8 @@ async function harness( await ctx.plugin(AgentRegistry) beforeRetry?.(ctx) adapter.configureRetryPolicies(policies) - const retryFiber = await ctx.plugin(Object.assign((inner: Context) => { - retry.apply(inner, {}, internals) + const retryFiber = await ctx.plugin(Object.assign(async (inner: Context) => { + await retry.apply(inner, {}, internals) }, { inject: retry.inject })) await ctx.plugin(AgentLoop, { agents: [] }) const disposeAdapter = ctx.llm.registerAdapter(['mock', 'other'], adapter) @@ -1047,23 +1048,21 @@ describe('provider-routed retry policy', () => { expect(vi.getTimerCount()).toBe(0) }) - it('rejects retry policy configured on the executor instead of a provider', () => { - expectTypeOf<{}>().toExtend() - expectTypeOf<{ retryPolicy: { mode: 'always' } }>().not.toExtend() + it('rejects retry policy configured on the executor instead of a provider', async () => { + expectTypeOf<{}>().toExtend() + expectTypeOf<{ retryPolicy: { mode: 'always' } }>().not.toExtend() const ctx = new Context() - // `apply` registers its projection unit first; the registry is a required - // injection, so the direct-apply path must carry it too. new SessionProjectionRegistry(ctx) - expect(() => { - retry.apply(ctx, { retryPolicy: { mode: 'always' } } as unknown as retry.Config) - }).toThrow(/retryPolicy belongs under each provider/) + await expect(async () => { + await retry.apply(ctx, { retryPolicy: { mode: 'always' } } as unknown as retryConfig) + }).rejects.toThrow(/retryPolicy belongs under each provider/) }) - it('rejects unknown executor config', () => { + it('rejects unknown executor config', async () => { const ctx = new Context() new SessionProjectionRegistry(ctx) - expect(() => { - retry.apply(ctx, { retryPolciy: {} } as unknown as retry.Config) - }).toThrow(/unknown key "retryPolciy"/) + await expect(async () => { + await retry.apply(ctx, { retryPolciy: {} } as unknown as retryConfig) + }).rejects.toThrow(/unknown key "retryPolciy"/) }) }) diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index f721d26219..7c1ac24432 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -38,6 +38,7 @@ import type { MeterSurfaceNode } from './surface-fold.ts' import { priceSurface } from './route-pricing.ts' export type * from './types.ts' +export { contextPressureProjectionDefinition } from './usage-projection.ts' // Module-edge re-export: forces the emitted index.d.ts to import the // projection-unit modules, so their SessionProjectionStateMap augmentations load // in aggregate programs that only import the package root. @@ -110,10 +111,13 @@ export class TokenMeter extends Service { constructor(ctx: Context, config: TokenMeterConfig = {}) { super(ctx, 'tokenMeter') validateConfigKeys(config) + } - ctx.sessionProjections.register(tokenUsageProjectionDefinition) - ctx.sessionProjections.register(contextPressureProjectionDefinition) - ctx.sessionProjections.register(contextBreakdownProjectionDefinition) + protected async [Service.init](): Promise { + const ctx = this.ctx + await ctx.sessionProjections.register(tokenUsageProjectionDefinition) + await ctx.sessionProjections.register(contextPressureProjectionDefinition) + await ctx.sessionProjections.register(contextBreakdownProjectionDefinition) // Readers catch up independently, while eager observation bounds ordinary // read latency without creating state for sessions no consumer has read. diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index d6fee3cb04..8eba5db571 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -370,8 +370,9 @@ describe('contextBreakdown session projection', () => { const first = appendUser(session, 'the first of many messages') for (let index = 0; index < 24; index += 1) appendUser(session, `message number ${index} with some text`) const last = appendUser(session, 'the last message before compaction') + const projection = await ctx.sessionProjections.register(contextBreakdownProjectionDefinition) const state = () => { - const current = ctx.sessionProjections.stateOf(session, 'contextBreakdown') + const current = projection.stateOf(session) if (current === undefined) throw new Error('registered context breakdown has no state') return current } diff --git a/packages/llm/token-meter/tests/route-pricing.spec.ts b/packages/llm/token-meter/tests/route-pricing.spec.ts index bf11f1b039..08959f288e 100644 --- a/packages/llm/token-meter/tests/route-pricing.spec.ts +++ b/packages/llm/token-meter/tests/route-pricing.spec.ts @@ -1,5 +1,5 @@ import { imageOffloadProjection } from '@deepseek-ai/dsh-compaction-image-offload/projection' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { LlmRuntime, LlmAdapter, createMessage, createToolResultMessage, createUserMessage, projectFilesToText, ToolCallId, @@ -76,6 +76,7 @@ interface Harness { async function harness(pricing: (model: string) => LlmImageRequestPricing | undefined): Promise { const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) new SessionProjectionRegistry(ctx) ctx.provide('attachments', { fileHostPath: (ref: FileAttachmentRef) => `/host/${ref.name}`, @@ -85,7 +86,8 @@ async function harness(pricing: (model: string) => LlmImageRequestPricing | unde } as never) const llm = new LlmRuntime(ctx) llm.registerAdapter(['mock'], new PricingAdapter(pricing)) - const meter = new TokenMeter(ctx) + await ctx.plugin(TokenMeter) + const meter = ctx.tokenMeter return { ctx, meter, session: Session.create(SessionId('route-priced'), undefined, undefined, undefined, [imageOffloadProjection]) } } diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index f207905573..818fa45921 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -106,8 +106,7 @@ function appendSuccessfulCall( function meter(config: TokenMeterConfig = {}): TokenMeter { const ctx = new Context() - // The registry is a required injection of the service (its three projection - // units register in the constructor); mount it synchronously. + // Pure measurement tests use the service without mounting its projection contributions. new SessionProjectionRegistry(ctx) return new TokenMeter(ctx, config) } @@ -137,8 +136,13 @@ describe('TokenMeter configuration and registration', () => { await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(TokenMeter) expect(ctx.get('tokenMeter')).toBeInstanceOf(TokenMeter) + const session = ctx.sessions.create() + expect(Object.keys(ctx.sessionProjections.checkpoint(session)).sort()) + .toEqual(['contextBreakdown', 'contextPressure', 'tokenUsage']) await fiber.dispose() expect(ctx.get('tokenMeter')).toBeUndefined() + expect(ctx.sessionProjections.checkpoint(session)).toEqual({}) + await ctx.fiber.dispose() }) }) diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index fcd9bc5201..5123d81ae9 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/README.i18n.yaml @@ -39,8 +39,8 @@ en: 0acc88b042a99380 zh: e4cf66b77f2fc7ae /deepseek-ai-dsh-plan-mode/understand-the-implementation/durable-state-and-step-boundary-appends: - en: ff946ab59a7b4a0f - zh: b1c79937770f57ad + en: 24166a02b6e81553 + zh: 24313c2153c9a6a6 /deepseek-ai-dsh-plan-mode/understand-the-implementation/the-plan-command: en: a7bf35c2a9da52bc zh: dea9f4b3a41f1a6d @@ -48,8 +48,8 @@ en: 56798d0f45fb5775 zh: 282e4a522ce6dae0 /deepseek-ai-dsh-plan-mode/understand-the-implementation/session-projection-unit: - en: 74cd6bdf50af51f5 - zh: 67609cf7202ececb + en: 42189cbdbd1ef615 + zh: 711142761cc29491 /deepseek-ai-dsh-plan-mode/understand-the-implementation/source-map: en: fa97e858495a8105 zh: 8be1fbd7e420ff8a diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 35084ddb60..dce61a5e38 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -82,7 +82,7 @@ Plan mode is a product package, not a capability seam: there is no swappable bac ### Durable state and step-boundary appends -The package persists one log-only whole-value event, `plan/mode`, and the last logged value is the state. A mode change appends immediately when no turn is open; during an open turn it stays pending until the next accepted in-turn pre-step — the only append point while an agent runs — and an append failure cannot block the turn. The `set`/`get` service methods and their exact return states live in [`src/index.ts`](src/index.ts) and read the registered `plan` projection; the first dependent access fails explicitly when the registry or key is absent. +The package persists one log-only whole-value event, `plan/mode`, and the last logged value is the state. A mode change appends immediately when no turn is open; during an open turn it stays pending until the next accepted in-turn pre-step — the only append point while an agent runs — and an append failure cannot block the turn. The `set`/`get` service methods and their exact return states live in [`src/index.ts`](src/index.ts) and read the retained `plan` projection handle, acquired during activation. ### The `/plan` command @@ -94,7 +94,7 @@ The command child activates only when a commands service is composed. It maps ba ### Session projection unit -When `ctx.sessionProjections` is composed, the package registers the `plan` unit through optional injection. The unit turns logged `/plan` command runs into a candidate target, commits the logged state on `plan/mode`, and derives `{ active, pending }` for `view`, where `pending` is true only while an unsettled or successful selection differs from the logged state — a pure replay quantity recoverable from the log alone. The key merges into `SessionProjectionMap` from [`src/types.ts`](src/types.ts); the framework drives the unit, and unloading the plugin fiber unregisters the key. Plan-mode reads require this unit and the `turnBoundary` unit, and fail explicitly if the registry or either key is absent. +The package requires `ctx.sessionProjections` and awaits registration of the `plan` and `turnBoundary` units during activation. The unit turns logged `/plan` command runs into a candidate target, commits the logged state on `plan/mode`, and derives `{ active, pending }` for `view`, where `pending` is true only while an unsettled or successful selection differs from the logged state — a pure replay quantity recoverable from the log alone. The key merges into `SessionProjectionMap` from [`src/types.ts`](src/types.ts); the framework drives the unit, and unloading the plugin fiber unregisters the key. State reads use the retained handles and remain available after unregistration. ### Source map diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 9d7023df69..b325573434 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -82,7 +82,7 @@ agent 完成计划后,会以 markdown 形式、从标题开头书写计划并 ### 持久状态与步骤边界追加 -本包持久化一条仅记日志、整值替换的事件 `plan/mode`,最后一条已记录值即为状态。没有轮次开启时,模式变更会立即追加;轮次开启期间,它保持待生效,直到下一个被接受的轮内 pre-step——agent 运行时唯一的追加点——且追加失败不能阻塞轮次。`set`/`get` 服务方法及其确切返回状态见 [`src/index.ts`](src/index.ts),并读取已注册的 `plan` 投影;注册表或 key 缺失时,第一次依赖它们的访问会显式失败。 +本包持久化一条仅记日志、整值替换的事件 `plan/mode`,最后一条已记录值即为状态。没有轮次开启时,模式变更会立即追加;轮次开启期间,它保持待生效,直到下一个被接受的轮内 pre-step——agent 运行时唯一的追加点——且追加失败不能阻塞轮次。`set`/`get` 服务方法及其确切返回状态见 [`src/index.ts`](src/index.ts),并通过激活期间取得并保留的 `plan` 投影句柄读取状态。 ### `/plan` 命令 @@ -94,7 +94,7 @@ agent 完成计划后,会以 markdown 形式、从标题开头书写计划并 ### 会话投影单元 -组合了 `ctx.sessionProjections` 时,本包通过可选注入注册 `plan` 单元。该单元把已记录的 `/plan` 命令运行转为候选目标,在 `plan/mode` 上提交已记录状态,并为 `view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true——这是仅凭日志即可恢复的纯回放量。key 由 [`src/types.ts`](src/types.ts) 的声明合并加入 `SessionProjectionMap`;框架负责驱动该单元,卸载插件 fiber 会注销该 key。plan-mode 读取要求该单元与 `turnBoundary` 单元存在;注册表或任一 key 缺失时都会显式失败。 +本包要求 `ctx.sessionProjections`,并在激活期间等待 `plan` 与 `turnBoundary` 单元注册完成。该单元把已记录的 `/plan` 命令运行转为候选目标,在 `plan/mode` 上提交已记录状态,并为 `view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true——这是仅凭日志即可恢复的纯回放量。key 由 [`src/types.ts`](src/types.ts) 的声明合并加入 `SessionProjectionMap`;框架负责驱动该单元,卸载插件 fiber 会注销该 key。状态读取使用保留的句柄,注销后仍然可用。 ### 源码地图 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index a2385491ef..6f05c60d1d 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -25,6 +25,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' import { z as zod } from 'zod' import type { ZodType } from 'zod' +import { turnBoundaryProjectionDefinition, type TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' @@ -33,7 +34,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import { UserQuestionError, type AskUserQuestionAnswer } from '@deepseek-ai/dsh-user-questions' import type { CommandDefinitionId, CommandId } from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-session-projection' -import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition, ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { PlanProjection, PlanUnitState } from './types.ts' declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { @@ -177,6 +178,8 @@ export class PlanModeController extends Service { /** Validated deployment-owned guidance. */ private readonly section: string + private projection!: ProjectionHandle + private turnBoundary!: ProjectionHandle /** * Latest selection per session awaiting the next accepted in-turn pre-step. @@ -188,6 +191,12 @@ export class PlanModeController extends Service { constructor(ctx: Context, config: PlanModeConfig = { section: '' }) { super(ctx, 'planMode') this.section = resolveConfig(config).section + } + + protected async [Service.init](): Promise { + const ctx = this.ctx + this.projection = await ctx.sessionProjections.register(planProjectionDefinition) + this.turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) let disposed = false // Pre-step is outside Session.append publication, so it can append the // log-only mode event inside an open turn without re-entering the session. @@ -223,7 +232,6 @@ export class PlanModeController extends Service { }, }) - ctx.sessionProjections.register(planProjectionDefinition) // The command child activates only when a command registry is composed. ctx.inject(['commands'], (commandCtx) => { @@ -377,8 +385,7 @@ export class PlanModeController extends Service { } private hasOpenTurn(session: Session): boolean { - const state = this.ctx.sessionProjections.stateOf(session, 'turnBoundary') - if (state === undefined) throw new Error('plan-mode requires the turnBoundary session projection') + const state = this.turnBoundary.stateOf(session) return state.openTurnStartSeq !== null } @@ -388,8 +395,7 @@ export class PlanModeController extends Service { /** Read the required plan projection state or fail at the first service access. */ private planState(session: Session): PlanUnitState { - const state = this.ctx.sessionProjections.stateOf(session, 'plan') - if (state === undefined) throw new Error('plan-mode requires the plan session projection') + const state = this.projection.stateOf(session) return state } diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 5c39a792e8..1068721786 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -56,8 +56,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { /** Read the plan unit that backs the service in this full composition. */ function planActive(ctx: Context, agent: Agent): boolean { - const state = ctx.sessionProjections.stateOf(agent.session, 'plan') - if (state === undefined) throw new Error('plan projection is not registered') + const state = ctx.planMode.get(agent) return state.active } diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 6024269b6d..2ba26b9a09 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -89,7 +89,7 @@ function foldPlanMode(events: readonly SessionEvent[], end = events.length): boo async function mountProjectionSeam(ctx: Context): Promise { await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) } async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise { @@ -231,25 +231,6 @@ describe('ctx.planMode: get/set', () => { expect(ctx.get('planMode')).toBeUndefined() }) - it('fails when the required plan projection key is absent', async () => { - const ctx = await setup() - const agent = await agentWithSession(ctx, 'missing-plan-projection') - vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined) - expect(() => ctx.planMode.get(agent)).toThrow('plan-mode requires the plan session projection') - }) - - it('registers plan state directly but requires turnBoundary state', async () => { - const ctx = new Context() - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - const agent = await agentWithSession(ctx, 'missing-plan-projection-keys') - const planMode = new PlanModeController(ctx, PLAN_CONFIG) - await new Promise(resolve => setImmediate(resolve)) - expect(planMode.get(agent)).toEqual({ active: false }) - expect(() => planMode.set(agent, true)).toThrow('plan-mode requires the turnBoundary session projection') - }) - it('reads the folded state', async () => { const ctx = await setup() const agent = await agentWithSession(ctx) diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index 3c2fc36b5c..99c2b538ea 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -118,22 +118,22 @@ describe('plan projection unit', () => { const bench = await harness(true) commitPlanMode(bench.session, true, 0) expect(bench.values().plan).toEqual({ active: true, pending: false }) - expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBeNull() + expect(bench.ctx.sessionProjections.checkpoint(bench.session).plan?.val).toMatchObject({ activeAtLastHeader: null }) bench.session.append('request/header', { header: { config: { provider: 'test', model: 'test-model' } }, reason: 'initial', }) expect(bench.values().plan).toEqual({ active: true, pending: false }) - expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBe(true) + expect(bench.ctx.sessionProjections.checkpoint(bench.session).plan?.val).toMatchObject({ activeAtLastHeader: true }) commitPlanMode(bench.session, false, 1) expect(bench.values().plan).toEqual({ active: false, pending: false }) - expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBe(true) + expect(bench.ctx.sessionProjections.checkpoint(bench.session).plan?.val).toMatchObject({ activeAtLastHeader: true }) bench.session.append('request/header', { header: { config: { provider: 'test', model: 'test-model' } }, reason: 'change', }) expect(bench.values().plan).toEqual({ active: false, pending: false }) - expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBe(false) + expect(bench.ctx.sessionProjections.checkpoint(bench.session).plan?.val).toMatchObject({ activeAtLastHeader: false }) }) it('has no plan key when plan-mode is not composed', async () => { diff --git a/packages/preset/agent-preset-registry/src/index.ts b/packages/preset/agent-preset-registry/src/index.ts index 0500922904..9f870203b5 100644 --- a/packages/preset/agent-preset-registry/src/index.ts +++ b/packages/preset/agent-preset-registry/src/index.ts @@ -1,5 +1,7 @@ /** Declarative Agent capability sets, activation and session binding. */ -import { Context } from '@deepseek-ai/cordis' +import { turnBoundaryProjectionDefinition, type TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' +import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { bindScopeParent, createScope, scopeOf, scopeParentOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' @@ -55,6 +57,7 @@ export class AgentPresetRegistry extends TypertRemoteService { default: z.string().required(), selectedDefault: z.string().volatile(), }) + private turnBoundary!: ProjectionHandle private readonly owner: Context private readonly definitions = new Map() private readonly generations = new Map() @@ -64,7 +67,12 @@ export class AgentPresetRegistry extends TypertRemoteService { constructor(ctx: Context, public config: Config) { super(ctx, 'agentPresets') this.owner = ctx - ctx.sessionProjections.register(agentPresetProjectionDefinition) + } + + protected async [Service.init](): Promise { + const ctx = this.owner + await ctx.sessionProjections.register(agentPresetProjectionDefinition) + this.turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) }) ctx.on('session/event', (session, event) => { if (event.type === 'agent-preset/selected') ctx.emit('agent-preset/selected', session.id, event.data.agentPreset) @@ -346,8 +354,8 @@ export class AgentPresetRegistry extends TypertRemoteService { @Remote('select') async select(agent: Agent, agentPreset: string): Promise { const turn = (this.switches.get(agent.id) ?? Promise.resolve()).then(async () => { - const boundary = this.owner.sessionProjections.stateOf(agent.session, 'turnBoundary') - if (boundary !== undefined && (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0)) { + const boundary = this.turnBoundary.stateOf(agent.session) + if (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0) { throw new RemoteError('agent-preset/locked', 'This session has already started', { sessionId: agent.id, agentPreset }) } const preset = await this.recompose(agent.ctx, agentPreset) diff --git a/packages/preset/agent-preset-registry/tests/registry.spec.ts b/packages/preset/agent-preset-registry/tests/registry.spec.ts index 04ab9c0096..471998f689 100644 --- a/packages/preset/agent-preset-registry/tests/registry.spec.ts +++ b/packages/preset/agent-preset-registry/tests/registry.spec.ts @@ -108,7 +108,7 @@ describe('declarative preset revisions', () => { await declare(ctx, contribution('minimal')) const agent = await agentOn(ctx, 'selection') expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal') - expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBe('minimal') + expect(ctx.sessionProjections.checkpoint(agent.session).agentPreset?.val).toBe('minimal') agent.session.append('turn/start', { turn: 1 }) await expect(ctx.agentPresets.select(agent, 'standard')).rejects.toThrow('already started') }) @@ -276,7 +276,7 @@ it('serializes competing blank-session selections and recovers after a failed se ctx.agentPresets.select(agent, 'absent'), ctx.agentPresets.select(agent, 'minimal'), ctx.agentPresets.select(agent, 'standard'), ]) expect(results.map(row => row.status)).toEqual(['rejected', 'fulfilled', 'fulfilled']) - expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBe('standard') + expect(ctx.sessionProjections.checkpoint(agent.session).agentPreset?.val).toBe('standard') }) it('retains a newly acquired generation when a previous activation is replaced', async () => { @@ -305,7 +305,7 @@ it('does not lose the durable selection when a tools observer rejects notificati const agent = await agentOn(ctx, 'observer') ctx.on('tools/change', () => { throw new Error('observer refused') }) expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal') - expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBe('minimal') + expect(ctx.sessionProjections.checkpoint(agent.session).agentPreset?.val).toBe('minimal') }) it('inventories failed conditional entries without executing their expressions again', async () => { diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index e97796db5c..c966e6a4ea 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -27,7 +27,7 @@ import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { Session } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-system-prompt' export { SANDBOX_MODES, setSandboxMode } from './session-mode.ts' @@ -122,6 +122,8 @@ export class SandboxPolicyService extends Service { readonly defaultMode: SandboxMode /** The absolute `workspace-write` fallback root for calls without a session cwd. */ readonly workspaceRoot: string + private projection!: ProjectionHandle + constructor(ctx: Context, config: Config) { super(ctx, 'sandboxPolicy') // schemastery (static Config) already filled `mode`; the cast records that @@ -129,8 +131,11 @@ export class SandboxPolicyService extends Service { // the process cwd is real branching, resolved absolute either way. this.defaultMode = config.mode as SandboxMode this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd()) + } - ctx.sessionProjections.register({ + protected async [Service.init](): Promise { + const ctx = this.ctx + this.projection = await ctx.sessionProjections.register({ key: 'sandboxMode', stateVersion: 1, stateSchema: sandboxModeStateSchema, @@ -176,7 +181,7 @@ export class SandboxPolicyService extends Service { * @returns the last logged mode, or `undefined` without one. */ overrideOf(session: Session): SandboxMode | undefined { - return this.ctx.sessionProjections.stateOf(session, 'sandboxMode') ?? undefined + return this.projection.stateOf(session) ?? undefined } } diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index a6ed4d9fb4..e2279e7fd7 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -235,10 +235,10 @@ describe('the sandbox/mode session kit', () => { it('the sandboxMode projection folds to the last switch, or null without one', async () => { const ctx = await mounted() const session = Session.create(SessionId('sess-fold')) - expect(ctx.sessionProjections.stateOf(session, 'sandboxMode')).toBeNull() + expect(ctx.sessionProjections.checkpoint(session).sandboxMode?.val).toBeNull() setSandboxMode(session, 'workspace-write') setSandboxMode(session, 'read-only') - expect(ctx.sessionProjections.stateOf(session, 'sandboxMode')).toBe('read-only') + expect(ctx.sessionProjections.checkpoint(session).sandboxMode?.val).toBe('read-only') }) it('setSandboxMode appends exactly one sandbox/mode event per switch', () => { diff --git a/packages/session/session-stats/src/index.ts b/packages/session/session-stats/src/index.ts index 54679b6e78..6439070f01 100644 --- a/packages/session/session-stats/src/index.ts +++ b/packages/session/session-stats/src/index.ts @@ -14,16 +14,16 @@ import { sessionStatsProjectionDefinition } from './projection.ts' export type * from './types.ts' -/** Cordis plugin name. */ export const name = 'session-stats' -/** The projection registry is the plugin's whole purpose; without it the fiber stays pending. */ + export const inject = ['sessionProjections'] /** * Register the `sessionStats` unit; the registration is an effect on this * plugin's fiber, so unloading removes the key. * @param ctx - registrant context carrying the projection registry. + * @returns when the plugin is ready. */ -export function apply(ctx: Context): void { - ctx.sessionProjections.register(sessionStatsProjectionDefinition) +export async function apply(ctx: Context): Promise { + await ctx.sessionProjections.register(sessionStatsProjectionDefinition) } diff --git a/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts b/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts index 35c297eddc..92565e1cc9 100644 --- a/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts +++ b/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts @@ -76,7 +76,7 @@ async function harness( await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SessionTitleService, TITLE_CONFIG) const adapter = new RecordingAdapter(script, reasoningEfforts) ctx.llm.registerAdapter(['title-route'], adapter) @@ -249,7 +249,7 @@ describe('first-prompt LLM title provider', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SessionTitleService, TITLE_CONFIG) // Delegate to the real registration while capturing it, to reach the guard // the service never produces. diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 6a7e9acdde..3d5dc3d7e0 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -3,6 +3,7 @@ * @module @deepseek-ai/dsh-session-title */ +import { turnBoundaryProjectionDefinition, type TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' import { Context, FiberState, Service, type Fiber } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' @@ -17,8 +18,7 @@ import type { } from '@deepseek-ai/dsh-session' import { SessionSeq } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-projection' -import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' -import type {} from '@deepseek-ai/dsh-agent' +import type { ProjectionDefinition, ProjectionHandle } from '@deepseek-ai/dsh-session-projection' export type { SessionTitleEventData, SessionTitleModelIdentity, @@ -306,6 +306,8 @@ export class SessionTitleService extends Service { maxTitleBytes: z.number().step(1).min(1).required(), }) + private turnBoundary!: ProjectionHandle + private titleInput!: ProjectionHandle private readonly config: ResolvedConfig private readonly ownerFiber: Fiber private registration: ProviderRegistration | undefined @@ -328,6 +330,11 @@ export class SessionTitleService extends Service { throw new Error('session-title: fallbackMaxBytes must not exceed maxTitleBytes') } this.config = deepFreeze({ ...value }) + } + + protected async [Service.init](): Promise { + const ctx = this.ctx + this.turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) ctx.effect(() => async () => { this.lifetime.abort(new Error('session-title service disposed')) @@ -341,9 +348,9 @@ export class SessionTitleService extends Service { this.work.clear() }, 'sessionTitle lifecycle') - ctx.sessionProjections.register(titleProjectionDefinition) + await ctx.sessionProjections.register(titleProjectionDefinition) - ctx.sessionProjections.register<'titleInput', TitleInputState>({ + this.titleInput = await ctx.sessionProjections.register<'titleInput', TitleInputState>({ key: 'titleInput', stateVersion: 3, stateSchema: titleInputStateSchema, @@ -549,7 +556,7 @@ export class SessionTitleService extends Service { const state = session === undefined ? undefined : this.work.get(session) const pending = state?.pending if (session === undefined || state === undefined || pending === undefined) return - const boundary = this.ctx.sessionProjections.stateOf(session, 'turnBoundary')?.lastStepBoundary + const boundary = this.turnBoundary.stateOf(session).lastStepBoundary const route = session.requestHeader()?.config if (boundary?.kind !== 'start' || route?.provider !== options.provider @@ -730,7 +737,7 @@ export class SessionTitleService extends Service { } private titleInputOf(session: Session): TitleInputState { - return this.ctx.sessionProjections.stateOf(session, 'titleInput') as TitleInputState + return this.titleInput.stateOf(session) } /** Queue detached service work and retain it through service disposal. */ diff --git a/packages/session/session-title/tests/projection.spec.ts b/packages/session/session-title/tests/projection.spec.ts index df2b5f1f91..784df3c69d 100644 --- a/packages/session/session-title/tests/projection.spec.ts +++ b/packages/session/session-title/tests/projection.spec.ts @@ -90,10 +90,8 @@ describe('title projection unit', () => { } await ctx.plugin(SessionTitleService, CONFIG) - const state = ctx.sessionProjections.stateOf(session, 'titleInput') - expect(state?.count).toBe(5_000) - expect(state?.first?.text).toBe('message 0') - expect(state?.lastSeq).toBe(session.seq - 1) + const state = ctx.sessionProjections.checkpoint(session).titleInput?.val + expect(state).toMatchObject({ count: 5_000, first: { text: 'message 0' }, lastSeq: session.seq - 1 }) expect(ctx.sessionProjections.checkpoint(session).titleInput).toBeDefined() }) diff --git a/packages/session/session-title/tests/provider.spec.ts b/packages/session/session-title/tests/provider.spec.ts index 8f22a930fc..f08002e145 100644 --- a/packages/session/session-title/tests/provider.spec.ts +++ b/packages/session/session-title/tests/provider.spec.ts @@ -452,7 +452,7 @@ describe('SessionTitleService Provider lifecycle', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SessionTitleService, CONFIG) const requests: SessionTitleProviderRequest[] = [] ctx.sessionTitle.register({ @@ -508,7 +508,7 @@ describe('SessionTitleService Provider lifecycle', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SessionTitleService, CONFIG) const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise => ({ title: 'Unexpected title', diff --git a/packages/session/session-title/tsconfig.json b/packages/session/session-title/tsconfig.json index 5755ac55e2..e056eda2bd 100644 --- a/packages/session/session-title/tsconfig.json +++ b/packages/session/session-title/tsconfig.json @@ -31,7 +31,6 @@ }, { "path": "../../core/agent" - } ] } diff --git a/packages/session/session-turn-outline/src/index.ts b/packages/session/session-turn-outline/src/index.ts index 0105e7c186..ab2c5f667e 100644 --- a/packages/session/session-turn-outline/src/index.ts +++ b/packages/session/session-turn-outline/src/index.ts @@ -14,16 +14,16 @@ import { turnOutlineProjectionDefinition } from './projection.ts' export type * from './types.ts' -/** Cordis plugin name. */ export const name = 'session-turn-outline' -/** The projection registry is the plugin's whole purpose; without it the fiber stays pending. */ + export const inject = ['sessionProjections'] /** * Register the `turnOutline` unit; the registration is an effect on this * plugin's fiber, so unloading removes the key. * @param ctx - registrant context carrying the projection registry. + * @returns when the plugin is ready. */ -export function apply(ctx: Context): void { - ctx.sessionProjections.register(turnOutlineProjectionDefinition) +export async function apply(ctx: Context): Promise { + await ctx.sessionProjections.register(turnOutlineProjectionDefinition) } diff --git a/packages/session/session-turn-outline/tests/projection.spec.ts b/packages/session/session-turn-outline/tests/projection.spec.ts index c01acf8aa5..8c1d26bc96 100644 --- a/packages/session/session-turn-outline/tests/projection.spec.ts +++ b/packages/session/session-turn-outline/tests/projection.spec.ts @@ -97,7 +97,7 @@ describe('turn outline projection unit', () => { appendPrompt(session, 'prompt') appendAssistant(session, 1, 1, 'streamed but unsettled') expect(outlineOf(ctx, session)[0]?.response).toBe('') - expect(ctx.sessionProjections.stateOf(session, 'turnOutline')?.draft).toBe('streamed but unsettled') + expect(ctx.sessionProjections.checkpoint(session).turnOutline?.val).toMatchObject({ draft: 'streamed but unsettled' }) endTurn(session, 1) expect(outlineOf(ctx, session)[0]?.response).toBe('streamed but unsettled') }) diff --git a/packages/session/working-directory/src/index.ts b/packages/session/working-directory/src/index.ts index 7346feb274..60c8ad9c2a 100644 --- a/packages/session/working-directory/src/index.ts +++ b/packages/session/working-directory/src/index.ts @@ -11,7 +11,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-fs' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/cordis' { @@ -72,14 +72,19 @@ export class WorkingDirectoryService extends Service { private readonly operations = new Set>() private readonly lifecycle = new AbortController() + private projection!: ProjectionHandle + constructor(ctx: Context, config: Config) { super(ctx, 'workingDirectory') const directory = config.defaultDirectory ?? process.cwd() if (!isAbsolute(directory)) throw new Error('working-directory: defaultDirectory must be absolute') this.defaultDirectory = directory + } + protected async [Service.init](): Promise { + const ctx = this.ctx const directorySchema = zod.string().min(1).refine(isAbsolute).nullable() - ctx.sessionProjections.register({ + this.projection = await ctx.sessionProjections.register({ key: 'workingDirectory', stateVersion: 1, stateSchema: directorySchema, @@ -120,7 +125,7 @@ export class WorkingDirectoryService extends Service { * @returns its effective absolute directory. */ get(session: Session): string { - return this.ctx.sessionProjections.stateOf(session, 'workingDirectory') ?? this.defaultDirectory + return this.projection.stateOf(session) ?? this.defaultDirectory } /** @@ -139,7 +144,7 @@ export class WorkingDirectoryService extends Service { control.throwIfAborted() agent.ctx.fiber.assertActive() if (info?.type === 'directory') { - if (this.ctx.sessionProjections.stateOf(agent.session, 'workingDirectory') === null) { + if (this.projection.stateOf(agent.session) === null) { agent.session.append('working-directory/change', { cwd: current }) } return current diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 1d01640cd0..1f2eb7d2e8 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -211,7 +211,7 @@ async function setupSandboxed(withApproval = false) { await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(RecordingSandboxExecutor) if (withApproval) await ctx.plugin(ApprovalService) diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index d033c52535..aaa82411c5 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -228,7 +228,7 @@ async function setupSandboxed(withApproval = false) { // The loop's turnBoundary unit (the open-turn fold) is not mounted in this // bench — the loop itself is not composed. Register its open-turn fold so // the approval service's turn-enclosure gate reads the seeded log shape. - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(ConfiningFakeBash) if (withApproval) await ctx.plugin(ApprovalService) diff --git a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts index cd30075474..2e348c865f 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts @@ -15,8 +15,8 @@ export const inject = ['agents', 'subagentModelSelection'] */ export function apply(ctx: Context, config: Config): void { const install = (agent: Agent): void => { - agent.ctx.inject(ToolSubagent.inject, (runtimeCtx) => { - ToolSubagent.apply(runtimeCtx, { + agent.ctx.inject(ToolSubagent.inject, async (runtimeCtx) => { + await ToolSubagent.apply(runtimeCtx, { provider: config.provider, modelSelectionSettings: true, ...(config.toolName === undefined ? {} : { toolName: config.toolName }), diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index c4b1781f57..891e860b16 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -878,6 +878,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('requires an explicit absolute Harness home for nested dsh runtimes', async () => { const ctx = new Context() await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await expect(ctx.plugin(sdk, { providerName: 'sdk', @@ -898,6 +899,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { ])('rejects an invalid $field at load', async ({ field, override }) => { const ctx = new Context() await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await expect(ctx.plugin(sdk, { providerName: 'sdk', diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index dfb659c93b..c4fef66681 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -18,8 +18,8 @@ en: 924b8c15f9529028 zh: 9fb0c399b5a79e3e /deepseek-ai-dsh-subagent/use-this-package/enabling-delegation: - en: 388ae5427c869173 - zh: aff830f4986cf1b3 + en: 025605eddae02478 + zh: 5659cba4340735d8 /deepseek-ai-dsh-subagent/use-this-package/delegation-settings: en: 86c982223fab1a3a zh: 3d220d8ed84eab9a diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 2442af74c1..77e2e5cab9 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -31,7 +31,7 @@ This package is the contract every delegation setup shares. You enable it by mou ### Enabling delegation -Mount the service with a provider and the delegation tool. The provider registers under its configured name; each tool row selects one provider. A minimal setup: +Mount the service with `@deepseek-ai/dsh-session-projection`, a provider, and the delegation tool; the service waits for the projection registry before initializing. The provider registers under its configured name; each tool row selects one provider. A minimal setup: ```yaml - name: '@deepseek-ai/dsh-subagent' diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 7dccf24550..6506779e83 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" ### 启用委派 -挂载服务、提供方与委派工具。提供方按配置名称注册,每个工具行选择一个提供方。最小配置如下: +一起挂载本服务、`@deepseek-ai/dsh-session-projection`、提供方与委派工具;本服务等待投影注册表可用后才初始化。提供方按配置名称注册,每个工具行选择一个提供方。最小配置如下: ```yaml - name: '@deepseek-ai/dsh-subagent' diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 5a802075d1..5645775408 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -94,9 +94,6 @@ "@deepseek-ai/dsh-session-persistence": { "optional": true }, - "@deepseek-ai/dsh-session-projection": { - "optional": true - }, "@deepseek-ai/dsh-session-query": { "optional": true }, diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index edce6b7ae5..7484cdd5dc 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -29,7 +29,7 @@ */ import type { Volatile } from '@deepseek-ai/cordis' -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-working-directory' import type {} from '@deepseek-ai/dsh-attachment' @@ -194,7 +194,7 @@ export class SubagentRuntime extends TypertRemoteService { maxDepth: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(1).volatile(), maxActiveSubagents: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(8).volatile(), }) - static inject = ['workingDirectory'] + static inject = ['workingDirectory', 'sessionProjections'] private providers = new Map() private manager: SubagentManager | undefined /** @@ -222,16 +222,18 @@ export class SubagentRuntime extends TypertRemoteService { if (this.manager === manager) this.manager = undefined }, 'subagents.managerBinding()') }) - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register(subagentCatalogProjectionDefinition) - projectionCtx.sessionProjections.register(subagentTimingProjectionDefinition) - projectionCtx.sessionProjections.register(subagentIdentityProjectionDefinition) - }) // Archive admission: this runtime is the owner that knows which live // children descend from a Session and how a parent stops them. ctx.inject(['agents'], (agentsCtx: Context) => { installSubagentArchiveAdmission(agentsCtx) }) } + protected async [Service.init](): Promise { + const { sessionProjections } = this.ctx + await sessionProjections.register(subagentCatalogProjectionDefinition) + await sessionProjections.register(subagentTimingProjectionDefinition) + await sessionProjections.register(subagentIdentityProjectionDefinition) + } + /** * Resolve a delegation tool's depth policy against the current user setting. * @param configured - Explicit tool limit, or provider-managed for external delegation. diff --git a/packages/subagent/subagent/tests/catalog.spec.ts b/packages/subagent/subagent/tests/catalog.spec.ts index c242f1c423..ac576639fa 100644 --- a/packages/subagent/subagent/tests/catalog.spec.ts +++ b/packages/subagent/subagent/tests/catalog.spec.ts @@ -79,7 +79,7 @@ describe('subagent catalog projection', () => { try { await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(subagentCatalogProjectionDefinition) + await ctx.sessionProjections.register(subagentCatalogProjectionDefinition) const parent = ctx.sessions.create(header.id) const changes = vi.fn() ctx.sessionProjections.onChanged(changes) @@ -196,7 +196,7 @@ describe('subagent catalog projection', () => { try { await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(subagentCatalogProjectionDefinition) + await ctx.sessionProjections.register(subagentCatalogProjectionDefinition) const events = [ fact(0, 'valid-child', 0, { mode: 'one-shot' }), { type: 'subagent/catalog', seq: SessionSeq(1), time: 0, data } as unknown as SessionEvent, diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index e44c170dae..9a764ad01d 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -80,7 +80,7 @@ function policyEvents(events: readonly SessionEvent[]) { } function foldedSandboxMode(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown { - return ctx.sessionProjections.stateOf(Session.create(id, events), 'sandboxMode') + return ctx.sandboxPolicy.overrideOf(Session.create(id, events)) ?? null } function foldedApprovalPolicy(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown { diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index e4ee8e6c89..d7c4477c5b 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -1,3 +1,4 @@ +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts' import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' @@ -100,6 +101,7 @@ async function setupWith( await ctx.plugin(AgentLoop, { agents: [] }) if (options.sessionQuery !== false) await ctx.plugin(TestSessionQuery) await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) subagentConfigs.set(ctx, await liveConfig(ctx, SubagentRuntime, options.maxActiveSubagents === undefined ? {} : { maxActiveSubagents: options.maxActiveSubagents })) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) @@ -262,6 +264,7 @@ describe('continuable activation capacity', () => { const ctx = new Context() try { await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await expect(ctx.plugin(SubagentRuntime, { maxActiveSubagents })).rejects.toThrow() } finally { await ctx.fiber.dispose() @@ -272,6 +275,7 @@ describe('continuable activation capacity', () => { const ctx = new Context() try { await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) const live = await liveConfig(ctx, SubagentRuntime, { maxDepth: 4 }) subagentConfigs.set(ctx, live) const fiber = live.fiber @@ -3861,6 +3865,7 @@ describe('continuable errors', () => { }) await ctx.plugin(AgentLoop, { agents: [] }) await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) const serviceFiber = await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/subagent/subagent/tests/control.spec.ts b/packages/subagent/subagent/tests/control.spec.ts index b6df7c53ed..16263f5b10 100644 --- a/packages/subagent/subagent/tests/control.spec.ts +++ b/packages/subagent/subagent/tests/control.spec.ts @@ -1,3 +1,4 @@ +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts' // The browser-facing prompt and interrupt controls and their stable failure // codes. Session Controller owns catalog observation and transport. @@ -25,6 +26,7 @@ const IMAGE_REF = { attachmentId: 'att', mediaType: 'image/png', bytes: 2, width async function bench(live?: Record) { const ctx = new Context() await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) if (live !== undefined) { ctx.provide('agents', { diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index dd5cf76799..f9983b580c 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -1,3 +1,4 @@ +import { subagentCatalogProjectionDefinition } from '../src/catalog.ts' import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts' import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' @@ -49,6 +50,7 @@ async function setup( await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TestSessionQuery) await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(SubagentFork, { providerName: 'fork' }) @@ -191,6 +193,7 @@ describe('SubagentRuntime.listChildren', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(TestSessionQuery) await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const ancestor = ctx.sessions.create(SessionId('catalog-ancestor')) ancestor.append('turn/start', { turn: 1 }) @@ -221,6 +224,7 @@ describe('SubagentRuntime.listChildren', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(TestSessionQuery) await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const parent = ctx.sessions.create(SessionId('chunk-parent')) for (let index = 0; index < 1_001; index += 1) { @@ -235,7 +239,8 @@ describe('SubagentRuntime.listChildren', () => { }) } const listSessions = vi.spyOn(ctx.sessionQuery, 'listSessions') - const state = ctx.sessionProjections.stateOf(parent, 'subagentCatalog') + const projection = await ctx.sessionProjections.register(subagentCatalogProjectionDefinition) + const state = projection.stateOf(parent) const chunkLengths: number[] = [] for (let chunk = state?.head; chunk !== undefined; chunk = chunk.previous) { chunkLengths.push(chunk.values.length) @@ -253,6 +258,7 @@ describe('SubagentRuntime.listChildren', () => { const withoutProjection = new Context() await withoutProjection.plugin(SessionStore) await mountWorkingDirectoryFixture(withoutProjection) + await withoutProjection.plugin(SessionProjectionRegistry) await withoutProjection.plugin(SubagentRuntime) const parent = withoutProjection.sessions.create(SessionId('parent')) await expect(withoutProjection.subagents.listChildren(parent.id)).rejects.toMatchObject({ @@ -441,6 +447,7 @@ describe('SubagentRuntime.listDescendants', () => { it('fails before reading when the Session store or query service is absent', async () => { const ctx = new Context() await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await expect(ctx.subagents.listDescendants(SessionId('root'))).rejects.toMatchObject({ code: 'SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE' }) await ctx.plugin(SessionStore) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 8025ac0060..30a6cfc932 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -70,8 +70,6 @@ const contexts = new WeakMap() async function service(withAgents = true): Promise<{ ctx: Context; subagents: SubagentRuntime }> { const ctx = new Context() - // The registry is a required injection of SubagentRuntime (its projection - // units register in the constructor). if (withAgents) { await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) @@ -119,7 +117,7 @@ describe('SubagentRuntime', () => { await fiber.dispose() - expect(ctx.sessionProjections.stateOf(parent, 'subagentCatalog')).toBeUndefined() + expect(ctx.sessionProjections.snapshot(parent).values.subagentCatalog).toBeUndefined() }) it('registers, lists, looks up, starts, and removes providers', async () => { diff --git a/packages/subagent/subagent/tests/timing-projection.spec.ts b/packages/subagent/subagent/tests/timing-projection.spec.ts index 40b21431ec..dd8af27c69 100644 --- a/packages/subagent/subagent/tests/timing-projection.spec.ts +++ b/packages/subagent/subagent/tests/timing-projection.spec.ts @@ -38,12 +38,14 @@ describe('subagent timing projection', () => { expect(subagentIdentityProjectionDefinition.wire.view(state)).toEqual({ mode: 'one-shot', seq: 1 }) }) - it('registers with the optional session projection registry', async () => { + it('waits for the session projection registry and releases its projections on unload', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) await mountWorkingDirectoryFixture(ctx) - const serviceFiber = await ctx.plugin(SubagentRuntime) + const pending = ctx.plugin(SubagentRuntime) + expect(ctx.get('subagents')).toBeUndefined() + await ctx.plugin(SessionProjectionRegistry) + const serviceFiber = await pending const before = ctx.sessionProjections.snapshot(ctx.sessions.create()).values expect(before.subagentTiming).toEqual({ settledMs: 0 }) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 968b68ebf3..11df129bb1 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -33,11 +33,12 @@ import { registerListSubagentModels } from './list-models.ts' import type {} from './model-selection-settings.ts' import { recordSubagentModelSelection, - subagentModelSelectionProjectionDefinition, subagentModelSelectionPolicy, + subagentModelSelectionProjectionDefinition, } from './model-selection-state.ts' export const name = 'tool-subagent' + export const inject = ['tools', 'subagents', 'systemPrompt', 'sessionProjections'] // Definition identity keeps shared guidance scoped to the actual visible tools, @@ -159,8 +160,9 @@ function providerWording(inheritsConversation: boolean): { description: string; * @param ctx - Context that owns the registrations. * @param config - delegation-tool configuration. * @param session - unpublished Session supplied by a direct Agent setup; omit for a standing composition. + * @returns when the plugin is ready. */ -export function apply(ctx: Context, config: Config, session?: Session): void { +export async function apply(ctx: Context, config: Config, session?: Session): Promise { // Direct apply() bypasses Schemastery's numeric constraints. A direct-apply // omission stays capless (the schema default only runs through the loader). if (config.maxDepth !== 'provider-managed') assertSubagentMaxDepth(config.maxDepth) @@ -171,7 +173,7 @@ export function apply(ctx: Context, config: Config, session?: Session): void { const toolName = config.toolName ?? 'subagent' const modelSelectionCapable = config.modelSelectionSettings === true - ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + const modelSelectionProjection = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) const assertSubagentProviderConfiguration = (subagentProvider: SubagentProvider): void => { if (ctx.subagents.resolveMaxDepth(config.maxDepth) !== undefined && !subagentProvider.capabilities.depthLimit) { @@ -401,7 +403,7 @@ export function apply(ctx: Context, config: Config, session?: Session): void { const freshSession = target.firstLiveSeq === 0 // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. && target.eventAt(SessionSeq(0))?.type !== 'session/end-seed' - let allowedModels = subagentModelSelectionPolicy(ctx.sessionProjections, target) + let allowedModels = subagentModelSelectionPolicy(modelSelectionProjection, target) if (allowedModels === undefined) { const parentId = target.header.origin === 'subagent' ? target.header.parentSession @@ -414,14 +416,14 @@ export function apply(ctx: Context, config: Config, session?: Session): void { const parent = sessions.get(parentId) allowedModels = parent === undefined ? undefined - : subagentModelSelectionPolicy(ctx.sessionProjections, parent) + : subagentModelSelectionPolicy(modelSelectionProjection, parent) } else if (freshSession) { const current = settings.current() allowedModels = current.enabled ? current.allowedModels : undefined } } if (allowedModels !== undefined) { - recordSubagentModelSelection(ctx.sessionProjections, target, allowedModels) + recordSubagentModelSelection(modelSelectionProjection, target, allowedModels) } return allowedModels === undefined ? undefined : { routes: allowedModels } } diff --git a/packages/subagent/tool-subagent/src/model-selection-state.ts b/packages/subagent/tool-subagent/src/model-selection-state.ts index d3b04b828b..9b51f384f7 100644 --- a/packages/subagent/tool-subagent/src/model-selection-state.ts +++ b/packages/subagent/tool-subagent/src/model-selection-state.ts @@ -2,7 +2,7 @@ import { z as zod } from 'zod' import type { Session } from '@deepseek-ai/dsh-session' -import type SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import { assertAllowedModelRoutes, type AllowedModelRoute } from './model-selection.ts' @@ -52,29 +52,29 @@ export const subagentModelSelectionProjectionDefinition = { /** * Read the exact route list captured for a model-selectable definition. - * @param projections - registry that owns the policy projection. + * @param projection - registered policy state reader. * @param session - session whose durable decision is read. * @returns a detached route list, or undefined for the fixed-route definition. */ export function subagentModelSelectionPolicy( - projections: Pick, + projection: Pick, 'stateOf'>, session: Session, ): AllowedModelRoute[] | undefined { - return projections.stateOf(session, 'subagentModelSelectionPolicy')?.map(route => ({ ...route })) + return projection.stateOf(session)?.map(route => ({ ...route })) } /** * Append the route policy once, before its definition can reach a model request. - * @param projections - registry that owns the policy projection. + * @param projection - registered policy state reader. * @param session - session receiving the model-selectable definition. * @param allowedModels - exact routes the definition may select explicitly. */ export function recordSubagentModelSelection( - projections: Pick, + projection: Pick, 'stateOf'>, session: Session, allowedModels: readonly AllowedModelRoute[], ): void { - if (subagentModelSelectionPolicy(projections, session) !== undefined) return + if (subagentModelSelectionPolicy(projection, session) !== undefined) return session.append('subagent/model-selection-policy', { allowedModels: allowedModels.map(route => ({ ...route })), }) diff --git a/packages/subagent/tool-subagent/tests/harness.ts b/packages/subagent/tool-subagent/tests/harness.ts index 422d65ba0b..7547d77216 100644 --- a/packages/subagent/tool-subagent/tests/harness.ts +++ b/packages/subagent/tool-subagent/tests/harness.ts @@ -8,6 +8,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import SubagentRuntime from '@deepseek-ai/dsh-subagent' import { SessionId } from '@deepseek-ai/dsh-session' import * as mock from './scripted-provider.ts' +import type { Config as toolConfig } from '../src/index.ts' import * as tool from '../src/index.ts' import SubagentModelSelectionConfig from '../src/model-selection-settings.ts' @@ -32,7 +33,7 @@ const setupProviders = new WeakMap { - const fiber = agentCtx.inject(tool.inject, (runtimeCtx) => { - tool.apply(runtimeCtx, { ...config, modelSelectionSettings: true }, agent.session) + const fiber = agentCtx.inject(tool.inject, async (runtimeCtx) => { + await tool.apply(runtimeCtx, { ...config, modelSelectionSettings: true }, agent.session) }) await fiber.await() }, diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 31ab63b973..a20bbaa404 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -104,7 +104,7 @@ describe('SubagentModelSelectionConfig', () => { selectionConfigs.set(ctx, await liveConfig(ctx, SubagentModelSelectionConfig)) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) // A stored policy the service cannot apply fails when it is read, not when the profile row is written. await selectionConfigs.get(ctx)!.update({ allowedModels: [...ALLOWED_MODELS, ...ALLOWED_MODELS] }) @@ -122,13 +122,14 @@ describe('SubagentModelSelectionConfig', () => { const invalid = Session.create(SessionId('empty-policy')) invalid.append('subagent/model-selection-policy', { allowedModels: [] }) - expect(() => subagentModelSelectionPolicy(ctx.sessionProjections, invalid)).toThrow('requires at least one route') + const policy = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + expect(() => subagentModelSelectionPolicy(policy, invalid)).toThrow('requires at least one route') const malformed = Session.create(SessionId('malformed-policy')) malformed.append('subagent/model-selection-policy', { allowedModels: [{ provider: 1, model: 'fast-model' }], } as never) - expect(() => subagentModelSelectionPolicy(ctx.sessionProjections, malformed)) + expect(() => subagentModelSelectionPolicy(policy, malformed)) .toThrow('requires non-empty provider and model ids') await ctx.fiber.dispose() }) @@ -137,14 +138,15 @@ describe('SubagentModelSelectionConfig', () => { const ctx = await boot() const disabled = await createAgent(ctx, 'disabled') expect(selectable(ctx, disabled)).toBe(false) - expect(subagentModelSelectionPolicy(ctx.sessionProjections, disabled.session)).toBeUndefined() + const policy = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + expect(subagentModelSelectionPolicy(policy, disabled.session)).toBeUndefined() await selectionConfigs.get(ctx)!.update({ enabled: true, allowedModels: ALLOWED_MODELS, }) const enabled = await createAgent(ctx, 'enabled') - expect(subagentModelSelectionPolicy(ctx.sessionProjections, enabled.session)).toEqual(ALLOWED_MODELS) + expect(subagentModelSelectionPolicy(policy, enabled.session)).toEqual(ALLOWED_MODELS) expect(selectable(ctx, enabled)).toBe(true) expect(selectable(ctx, disabled)).toBe(false) @@ -171,8 +173,8 @@ describe('SubagentModelSelectionConfig', () => { sessionId: SessionId('direct-agent-setup'), setup: async (agentCtx, agent) => { prepared = agent - const fiber = agentCtx.inject(tool.inject, (runtimeCtx) => { - tool.apply(runtimeCtx, { + const fiber = agentCtx.inject(tool.inject, async (runtimeCtx) => { + await tool.apply(runtimeCtx, { provider: 'spawn', modelSelectionSettings: true, }, agent.session) @@ -336,7 +338,8 @@ describe('SubagentModelSelectionConfig', () => { meta: { parentSession: parent.id, origin: 'subagent' }, }) expect(selectable(ctx, child)).toBe(true) - expect(subagentModelSelectionPolicy(ctx.sessionProjections, child.session)).toEqual(ALLOWED_MODELS) + const policy = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + expect(subagentModelSelectionPolicy(policy, child.session)).toEqual(ALLOWED_MODELS) const orphan = await createAgent(ctx, 'orphan', { meta: { parentSession: SessionId('missing-parent'), origin: 'subagent' }, @@ -355,11 +358,11 @@ describe('SubagentModelSelectionConfig', () => { }) const resumedEmpty = await createAgent(ctx, 'resumed-empty', { seed: [] }) expect(selectable(ctx, resumedEmpty)).toBe(false) - expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedEmpty.session)).toBeUndefined() + expect(subagentModelSelectionPolicy(policy, resumedEmpty.session)).toBeUndefined() const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.snapshotEvents() }) expect(selectable(ctx, resumedDisabled)).toBe(false) - expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedDisabled.session)).toBeUndefined() + expect(subagentModelSelectionPolicy(policy, resumedDisabled.session)).toBeUndefined() await ctx.fiber.dispose() }) @@ -368,22 +371,22 @@ describe('SubagentModelSelectionConfig', () => { await mountAgentLoopTestDependencies(withoutSettings) await mountWorkingDirectoryFixture(withoutSettings) await withoutSettings.plugin(SubagentRuntime) - expect(() => { - tool.apply(withoutSettings, { + await expect(async () => { + await tool.apply(withoutSettings, { provider: 'missing', modelSelectionSettings: true, maxDepth: 'provider-managed', }) - }).toThrow('requires @deepseek-ai/dsh-tool-subagent/model-selection-settings') + }).rejects.toThrow('requires @deepseek-ai/dsh-tool-subagent/model-selection-settings') await withoutSettings.fiber.dispose() const withoutAgent = await boot(false) - expect(() => { - tool.apply(withoutAgent, { + await expect(async () => { + await tool.apply(withoutAgent, { provider: 'spawn', modelSelectionSettings: true, }) - }).toThrow('requires a scoped preset Context') + }).rejects.toThrow('requires a scoped preset Context') await withoutAgent.fiber.dispose() }) @@ -405,13 +408,13 @@ describe('SubagentModelSelectionConfig', () => { parentSession: SessionId('missing-parent'), }) - expect(() => { - tool.apply(ctx, { + await expect(async () => { + await tool.apply(ctx, { provider: 'missing', modelSelectionSettings: true, maxDepth: 'provider-managed', }, child) - }).toThrow('child model-selection inheritance requires the Session registry') + }).rejects.toThrow('child model-selection inheritance requires the Session registry') } finally { await ctx.fiber.dispose() } diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index ea090fa0a3..193b7bece0 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -18,6 +18,7 @@ import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { loadStoredSession } from '../../subagent/tests/persistence-helpers.ts' import * as mock from './scripted-provider.ts' +import type { Config as toolConfig } from '../src/index.ts' import * as tool from '../src/index.ts' import { SessionId } from '@deepseek-ai/dsh-session' import { @@ -325,7 +326,7 @@ describe('dsh-tool-subagent', () => { }, }) // Direct apply with only `provider` — no toolName, no agentOptions. - tool.apply(ctx, { maxDepth: 'provider-managed', provider: 'bare' }) + await tool.apply(ctx, { maxDepth: 'provider-managed', provider: 'bare' }) await new Promise(r => setTimeout(r, 10)) expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true) @@ -347,7 +348,7 @@ describe('dsh-tool-subagent', () => { // Tool first: no provider yet — the tool must be absent, not broken. // Direct apply (schema bypass): also covers the waiting-note's default // toolName fallback, which validated config pre-fills. - tool.apply(ctx, { provider: 'mock' }) + await tool.apply(ctx, { provider: 'mock' }) expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false) // Backend arrives (as a delayed sibling fiber would): the tool appears. await mock.mountScriptedProvider(ctx, { name: 'mock', reply: 'late but fine' }) @@ -360,7 +361,7 @@ describe('dsh-tool-subagent', () => { const ctx = await projectedContext() await mountWorkingDirectoryFixture(ctx) await ctx.plugin(SubagentRuntime) - tool.apply(ctx, { + await tool.apply(ctx, { provider: 'later-continuable', maxDepth: 'provider-managed', }) @@ -575,7 +576,6 @@ describe('dsh-tool-subagent', () => { expect(typeof unwrapped.apply).toBe('function') expect(unwrapped.Config).toBeDefined() }) - it('passes persona/toolFilter/maxDepth config through to the start request', async () => { let seen: { persona?: string; toolFilter?: unknown; maxDepth?: number } | undefined const ctx = await projectedContext() @@ -621,14 +621,14 @@ describe('dsh-tool-subagent', () => { .rejects.toThrow() }) - it('validates maxDepth when apply() is invoked directly without Schemastery', async () => { + it('validates maxDepth during initialization without Schemastery', async () => { const ctx = await projectedContext() - expect(() => { - tool.apply(ctx, { + await expect(async () => { + await tool.apply(ctx, { provider: 'unused', maxDepth: Number.NaN, }) - }).toThrow('subagent maxDepth must be a non-negative safe integer') + }).rejects.toThrow('subagent maxDepth must be a non-negative safe integer') }) it('a partial toolFilter (deny only) does not materialize an empty allow-list (deny-all trap)', async () => { @@ -806,7 +806,7 @@ describe('dsh-tool-subagent local activation', () => { return {} }, }) - tool.apply(ctx, { + await tool.apply(ctx, { provider: 'gated', toolName: 'subagent_gated', maxDepth: 3, @@ -850,7 +850,7 @@ describe('dsh-tool-subagent local activation', () => { describe('depth budget configuration', () => { /** Mount the tool over a request-capturing provider with full capabilities. */ - async function captureSetup(config: Omit = {}) { + async function captureSetup(config: Omit = {}) { const requests: SubagentStartRequest[] = [] const ctx = await projectedContext() await mountWorkingDirectoryFixture(ctx) diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts index 131e75b553..4b05eeb668 100644 --- a/packages/terminal/terminal-bash/src/index.ts +++ b/packages/terminal/terminal-bash/src/index.ts @@ -24,13 +24,12 @@ export type { Config as TerminalLocalConfig } from './config.ts' /** Cordis plugin name. */ export const name = 'terminal-bash' -/** Required services: terminal registry, shared confinement policy, projection registry, and process substrate. */ -export const inject = ['terminals', 'sandboxPolicy', 'sessionProjections', 'subprocess', 'workingDirectory'] +/** Required services: terminal registry, shared confinement policy, process substrate, and working directory. */ +export const inject = ['terminals', 'sandboxPolicy', 'subprocess', 'workingDirectory'] interface SandboxModeFenceState { pty: Context['terminals'] sandboxPolicy: Context['sandboxPolicy'] - sessionProjections: Context['sessionProjections'] } const sandboxModeFences = new WeakMap() @@ -40,20 +39,18 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void { if (existing !== undefined) { existing.pty = ctx.terminals existing.sandboxPolicy = ctx.sandboxPolicy - existing.sessionProjections = ctx.sessionProjections return } const state: SandboxModeFenceState = { pty: ctx.terminals, sandboxPolicy: ctx.sandboxPolicy, - sessionProjections: ctx.sessionProjections, } sandboxModeFences.set(owner, state) owner.ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] if (session !== owner.session || event.type !== 'sandbox/mode') return - const folded = state.sessionProjections.stateOf(session, 'sandboxMode') ?? null + const folded = state.sandboxPolicy.overrideOf(session) ?? null const currentMode = folded ?? state.sandboxPolicy.defaultMode if (event.data.mode === currentMode || !state.pty.hasOwnerActivity(owner)) return throw new Error( diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index a68c798321..9ed1aeeaeb 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -624,7 +624,7 @@ describe('terminal-bash plugin shape', () => { const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(ptyLocal) as Record expect(unwrapped.name).toBe('terminal-bash') - expect(unwrapped.inject).toEqual(['terminals', 'sandboxPolicy', 'sessionProjections', 'subprocess', 'workingDirectory']) + expect(unwrapped.inject).toEqual(['terminals', 'sandboxPolicy', 'subprocess', 'workingDirectory']) expect(unwrapped.Config).toBeDefined() }) diff --git a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts index 932912b4e4..7cdc154c72 100644 --- a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts +++ b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts @@ -110,7 +110,7 @@ describe('dsh-agent-loop-testkit', () => { time: 1, data: { target: 'next-turn', start: 99, inserted: [] }, }]) - expect(() => ctx.sessionProjections.stateOf(invalid, 'inbox')) + expect(() => ctx.sessionProjections.checkpoint(invalid).inbox?.val) .toThrow(/invalid persisted inbox splice/) expect(harness.claim(agent, 'next-turn', 3)).toEqual([step, turn]) expect(claimed).toEqual([ diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index f345045c93..5521005375 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -20,6 +20,7 @@ import type {} from '@deepseek-ai/dsh-session-projection' export type * from './types.ts' export const name = 'tool-todo' + export const inject = ['tools', 'sessionProjections'] /** The valid {@link TodoItem} statuses, as a runtime set for input narrowing. */ @@ -113,14 +114,15 @@ const todosProjectionSchema: ZodType = zod.union([ * `ctx.sessionProjections`. * @param ctx - registrant context carrying the tool and session-projection registries. * @param config - deployment's explicit todo policy. + * @returns when the plugin is ready. */ -export function apply(ctx: Context, config: Config): void { +export async function apply(ctx: Context, config: Config): Promise { const allowParallel = config.allowParallelInProgress // Standing-plan fold: latest whole todo/write list, cleared by the next // turn/start (turn/end keeps the finished checklist visible); null before the // first write or after a later turn begins; every other event returns the // same state reference. - ctx.sessionProjections.register<'todos', TodoItem[] | null>({ + await ctx.sessionProjections.register<'todos', TodoItem[] | null>({ key: 'todos', stateSchema: todosProjectionSchema, init: () => null, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a9b79453b..3656df5da8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6783,6 +6783,10 @@ importers: version: link:../../core/system-prompt packages/core/agent: + dependencies: + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ @@ -9836,6 +9840,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ From 7ac8f92b700468f324d894b39e4e1d0d7dc028c6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 12:03:37 +0800 Subject: [PATCH 03/37] test: adapt application and benchmark fixtures to async activation --- ...09-28-session-corpus-performance.i18n.yaml | 4 +-- .../2026-09-28-session-corpus-performance.md | 2 ++ ...026-09-28-session-corpus-performance.zh.md | 2 ++ apps/cli/tests/web-agent-presets.e2e.ts | 6 ++-- apps/web/tests/agent-preset-selection.e2e.ts | 6 ++-- apps/web/tests/blank-writer-reuse.e2e.ts | 2 +- .../session-archive-active/dialog.expected.md | 2 +- apps/web/tests/session-archive-active.e2e.ts | 2 +- apps/web/tests/subagent-interrupt.e2e.ts | 4 +-- .../session-corpus/projection-list.worker.ts | 7 ++-- .../session-corpus/session-controller.ts | 32 +++++++++++++++++++ .../session-corpus/session-corpus.worker.ts | 7 ++-- .../session-open/session-open.worker.ts | 8 ++--- 13 files changed, 62 insertions(+), 22 deletions(-) create mode 100644 benchmarks/session-corpus/session-controller.ts diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml index 38607ee2df..4a98c2f555 100644 --- a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml @@ -18,8 +18,8 @@ en: 1310a89c351e5bfe zh: e6386ae10b893c2d /agent-note-performance-baselines-for-large-session-corpora/decision/measured-endpoints: - en: b4d81575ac574f6f - zh: f4f291ab7ce83094 + en: 11937dfa4a349a19 + zh: b91a136890fe92e8 /agent-note-performance-baselines-for-large-session-corpora/calibration-evidence: en: f934c95748dcbfe0 zh: df6a68286c21510f diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md index 233491ebe0..2efe66ead2 100644 --- a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md @@ -37,6 +37,8 @@ The projection cache is part of the list endpoint: each cold row carries cached Workers are compiled plain-Node processes, each started fresh against a seeded root. They compose the production Session store, projection registry, JSONL persistence, SQLite Session query with `openAt: first-search` on an in-memory index, JSON storage and projection cache, the Web projection set, AgentLoop, and the real `SessionController`. Transport lookups, upload registration, default model selection, and the Workspace registry are inert providers, because no measured path depends on them. +The shared corpus and projection-list controller fixture awaits controller activation and supplies the fixed attachment image policy. Its snapshots therefore include the production `imageLimits` unit; attachment I/O remains outside these workloads. Event corpora, measurement endpoints, and budgets are unchanged. + | Case | Corpus | Samples | Endpoint | |---|---|---:|---| | List | 3,000 | 3 | Host boot; first `session.list`; repeated `session.list` in the same process | diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md index 4ed0283df7..f964d640b8 100644 --- a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md @@ -37,6 +37,8 @@ Status: implemented worker 是编译后的纯 Node 进程,每次都针对已播种根目录全新启动。它们组合生产 Session store、投影注册表、JSONL 持久化、使用 `openAt: first-search` 和内存索引的 SQLite Session query、JSON 存储和投影缓存、Web 投影集合、AgentLoop 以及真实的 `SessionController`。传输查找、上传注册、默认模型选择和 Workspace 注册表使用惰性提供者,因为没有被测路径依赖它们。 +语料与 projection-list 共用的控制器夹具等待控制器激活,并提供固定附件图片策略。因此快照包含生产 `imageLimits` 单元;附件 I/O 仍不在这些负载中。事件语料、测量端点和预算保持不变。 + | 用例 | 语料 | 样本数 | 终点 | |---|---|---:|---| | 列表 | 3,000 | 3 | Host 启动;首次 `session.list`;同一进程中重复的 `session.list` | diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index db0d8e26e3..bf1b9ffb9e 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -1,3 +1,4 @@ +import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import { createRequire } from 'node:module' import { randomUUID } from 'node:crypto' import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' @@ -730,7 +731,7 @@ describe('a switch survives the session', () => { // The header keeps the creation fact; the log carries what it runs. expect(handle.agent.session.header.agentPreset).toBe('standard') - expect(ctx.sessionProjections.stateOf(handle.agent.session, 'agentPreset')).toBe('minimal') + expect(ctx.sessionProjections.checkpoint(handle.agent.session).agentPreset?.val).toBe('minimal') } finally { await handle.dispose() } @@ -745,7 +746,8 @@ describe('a forked session', () => { meta: { agentPreset: 'minimal' }, setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), }) - const inherited = ctx.sessionProjections.stateOf(parent.agent.session, 'agentPreset') ?? undefined + const projection = await ctx.sessionProjections.register(agentPresetProjectionDefinition) + const inherited = projection.stateOf(parent.agent.session) ?? undefined const child = await ctx.agents.create({ sessionId: SessionId('preset-fork-child'), seed: [], diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 6fc12aba02..d695e52818 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -465,7 +465,7 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without expect(await composer.innerText()).toBe(draft) expect(await picker.getAttribute('aria-haspopup')).toBe('menu') expect(await page.getByRole('dialog', { name: 'Add plugin', exact: true }).count()).toBe(0) - expect(scaffold.ctx.sessionProjections.stateOf(creator.session, 'agentPreset')).toBe('cordis') + expect(scaffold.ctx.sessionProjections.checkpoint(creator.session).agentPreset?.val).toBe('cordis') expect(scaffold.ctx.agentPresets.composedPreset(creator.ctx)).toBe('cordis') expect(scaffold.ctx.tools.schemas(creator).map(tool => tool.name)) .toEqual(expect.arrayContaining(['cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager'])) @@ -490,9 +490,9 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without const next = scaffold.ctx.agents.list().find(agent => agent.id !== creator.id)! expect(next.id).not.toBe(creator.id) expect(next.session.header.agentPreset).toBe('standard') - expect(scaffold.ctx.sessionProjections.stateOf(next.session, 'agentPreset')).toBe('standard') + expect(scaffold.ctx.sessionProjections.checkpoint(next.session).agentPreset?.val).toBe('standard') expect(scaffold.ctx.agentPresets.composedPreset(next.ctx)).toBe('standard') - expect(scaffold.ctx.sessionProjections.stateOf(creator.session, 'agentPreset')).toBe('cordis') + expect(scaffold.ctx.sessionProjections.checkpoint(creator.session).agentPreset?.val).toBe('cordis') await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor() expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: developerTools }) expect(scaffold.ctx.agentPresets.defaultId).toBe('standard') diff --git a/apps/web/tests/blank-writer-reuse.e2e.ts b/apps/web/tests/blank-writer-reuse.e2e.ts index 99b2be07e3..74cb108982 100644 --- a/apps/web/tests/blank-writer-reuse.e2e.ts +++ b/apps/web/tests/blank-writer-reuse.e2e.ts @@ -72,7 +72,7 @@ describe.each([false, true])('web e2e: selected blank writer held: %s', (held) = if (!held) { expect(selectedId).toBe(HELD) const reclaimed = scaffold.ctx.agents.get(HELD)! - expect(scaffold.ctx.sessionProjections.stateOf(reclaimed.session, 'plan')).toMatchObject({ active: true }) + expect(scaffold.ctx.sessionProjections.checkpoint(reclaimed.session).plan?.val).toMatchObject({ active: true }) await expect(scaffold.ctx.sessionPersistence.open(HELD, 'write')) .rejects.toMatchObject({ name: 'SessionAlreadyOwnedError' }) } diff --git a/apps/web/tests/expected/session-archive-active/dialog.expected.md b/apps/web/tests/expected/session-archive-active/dialog.expected.md index 2a6f4d638f..4604467eeb 100644 --- a/apps/web/tests/expected/session-archive-active/dialog.expected.md +++ b/apps/web/tests/expected/session-archive-active/dialog.expected.md @@ -3,7 +3,7 @@ - button "Close" - paragraph: “ARCHIVE_ACTIVE run the long command” still has work in progress. Archiving stops it first; you can restore the session later from the “All conversations (show archived)” filter in the sidebar, and the stopped work will not resume on its own. - list "Work that will be stopped": - - listitem: The turn in progress - listitem: "1 background job: node -e \"const fs=require('node:fs'); fs.writeFileSync('started.txt', 'started'); setInterval(() => { fs.appendFileSync('heartbeat.txt', '.') }, 200); setTimeout(() => { fs.writeFileSync('finished.txt', 'finished') }, 60000)\"" + - listitem: The turn in progress - button "Cancel" - button "Stop and archive" diff --git a/apps/web/tests/session-archive-active.e2e.ts b/apps/web/tests/session-archive-active.e2e.ts index d192d3d207..8360f937d8 100644 --- a/apps/web/tests/session-archive-active.e2e.ts +++ b/apps/web/tests/session-archive-active.e2e.ts @@ -162,7 +162,7 @@ describe.skipIf(MODE === 'record')('web e2e: archiving a running Session stops i // A foreground Bash command is a registered job from its start, so the // Host reports it beside the turn; stopping the turn kills it. await expect.poll(() => dialog.getByRole('list', { name: 'Work that will be stopped' }).locator('li').allTextContents()) - .toEqual(['The turn in progress', `1 background job: ${WAIT_COMMAND}`]) + .toEqual([`1 background job: ${WAIT_COMMAND}`, 'The turn in progress']) // Asking is not archiving: the Host set is untouched, the turn and its // Bash child keep running, and the row stays in the sidebar. expect([...scaffold.ctx.workspaceRegistry.archivedSessionIds]).toEqual([]) diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts index 7a35bcfcb9..a1b1284a19 100644 --- a/apps/web/tests/subagent-interrupt.e2e.ts +++ b/apps/web/tests/subagent-interrupt.e2e.ts @@ -43,8 +43,8 @@ async function remote( } /** POST one generated Session Remote unary through the API Gateway carrier. */ -function sessionRemote(scaffold: WebScaffold, method: string, request: unknown): Promise> { - return remote(scaffold, `session/${method}`, { request }) +async function sessionRemote(scaffold: WebScaffold, method: string, request: unknown): Promise> { + return await remote(scaffold, `session/${method}`, { request }) } /** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */ diff --git a/benchmarks/session-corpus/projection-list.worker.ts b/benchmarks/session-corpus/projection-list.worker.ts index 78a60b5c06..c00f9dd771 100644 --- a/benchmarks/session-corpus/projection-list.worker.ts +++ b/benchmarks/session-corpus/projection-list.worker.ts @@ -7,7 +7,7 @@ import { monitorEventLoopDelay, performance } from 'node:perf_hooks' import { scheduler, setTimeout as delay } from 'node:timers/promises' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' -import SessionController from '@deepseek-ai/dsh-api-session-controller' +import type SessionController from '@deepseek-ai/dsh-api-session-controller' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, type SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -20,6 +20,7 @@ import { z } from 'zod' import { generationLogPath, sessionDir, toHeaderLine } from '../../packages/session/session-persistence-jsonl/src/format.ts' import { compressZstdFrame } from '../../packages/session/session-persistence-jsonl/src/zstd.ts' import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' +import { createBenchmarkSessionController } from './session-controller.ts' const KEY = 'syntheticProjectionList' const CWD = '/synthetic-projection-benchmark' @@ -171,7 +172,7 @@ async function mountHost(ctx: Context, root: string, workload: typeof WORKLOADS[ await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register({ + await ctx.sessionProjections.register({ key: KEY, stateVersion: 1, stateSchema, init: header => makeState(Number(header.id.slice('projection-bench-'.length)), workload.baseRows), apply: state => state, @@ -200,7 +201,7 @@ async function mountHost(ctx: Context, root: string, workload: typeof WORKLOADS[ ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never) ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'synthetic', model: 'generated-model' }) } as never) ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) - return new SessionController(ctx, { nativeOpen: false, ...(listWorkSliceMs === undefined ? {} : { listWorkSliceMs }) }, { canOpenPath: () => false }) + return createBenchmarkSessionController(ctx, { nativeOpen: false, ...(listWorkSliceMs === undefined ? {} : { listWorkSliceMs }) }) } async function memory(): Promise { diff --git a/benchmarks/session-corpus/session-controller.ts b/benchmarks/session-corpus/session-controller.ts new file mode 100644 index 0000000000..f5b3cac97e --- /dev/null +++ b/benchmarks/session-corpus/session-controller.ts @@ -0,0 +1,32 @@ +/** Session-controller initialization for synthetic corpus workers without attachment I/O. */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import SessionController, { type Config } from '@deepseek-ai/dsh-api-session-controller' + +class BenchmarkSessionController extends SessionController { + initialize(): Promise { + return this[Service.init]() + } +} + +/** + * Initialize the controller against the worker's partial Host services. + * @param ctx - worker context owning service disposal. + * @param config - native-opener and list-scheduling settings. + * @returns controller ready for list, search, and fork calls. + */ +export async function createBenchmarkSessionController(ctx: Context, config: Config): Promise { + ctx.provide('attachments', { + imageLimits: { + maxImageBytes: 5 * 1024 * 1024, + maxImagesPerMessage: 20, + maxMessageImageBytes: 100 * 1024 * 1024, + maxImagePixels: 40_000_000, + maxImageDimension: 2000, + mediaTypes: ['image/png'], + }, + } as never) + const controller = new BenchmarkSessionController(ctx, config, { canOpenPath: () => false }) + await controller.initialize() + return controller +} diff --git a/benchmarks/session-corpus/session-corpus.worker.ts b/benchmarks/session-corpus/session-corpus.worker.ts index 8dabac10ca..ec56b55e44 100644 --- a/benchmarks/session-corpus/session-corpus.worker.ts +++ b/benchmarks/session-corpus/session-corpus.worker.ts @@ -8,7 +8,7 @@ import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' -import SessionController from '@deepseek-ai/dsh-api-session-controller' +import type SessionController from '@deepseek-ai/dsh-api-session-controller' import { currentSessionMessageProjections } from '@deepseek-ai/dsh-session-format-catalog/message-projections' import { Session, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -27,6 +27,7 @@ import TokenMeter from '@deepseek-ai/dsh-token-meter' import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' import { serializeRecord } from '../../packages/storage/storage-json/src/format.ts' import { ANCHOR_COUNT } from './corpus-shape.ts' +import { createBenchmarkSessionController } from './session-controller.ts' import { anchorHeader, corpusSessionId, @@ -140,7 +141,7 @@ async function mountHost(root: string): Promise<{ readonly ctx: Context; readonl await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 200, writeIntervalMs: 5_000 }) // Content search is opt-in; enabled deployments defer the in-memory index to the first search. await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:', openAt: 'first-search' }) - ctx.sessionProjections.register(agentPresetProjectionDefinition) + await ctx.sessionProjections.register(agentPresetProjectionDefinition) await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 }) await ctx.plugin(SessionStatsPlugin) await ctx.plugin(SessionTurnOutlinePlugin) @@ -152,7 +153,7 @@ async function mountHost(root: string): Promise<{ readonly ctx: Context; readonl ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never) ctx.provide('agentDefaultModel', { currentSelection: () => BENCH_MODEL } as never) ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) - const controller = new SessionController(ctx, { nativeOpen: false }, { canOpenPath: () => false }) + const controller = await createBenchmarkSessionController(ctx, { nativeOpen: false }) return { ctx, controller } } diff --git a/benchmarks/session-open/session-open.worker.ts b/benchmarks/session-open/session-open.worker.ts index b1e2bfec1d..91a37ac8ae 100644 --- a/benchmarks/session-open/session-open.worker.ts +++ b/benchmarks/session-open/session-open.worker.ts @@ -30,7 +30,7 @@ import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline import TokenMeter from '@deepseek-ai/dsh-token-meter' // These Host-only adapters have no public Node export and are compiled into the benchmark worker. import { SessionHistoryController } from '../../packages/api/session-controller/src/history.ts' -import { installModelSelectionProjection } from '../../packages/api/session-controller/src/model-selection-projection.ts' +import { modelSelectionProjection } from '../../packages/api/session-controller/src/model-selection-projection.ts' import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' import { SYNTHETIC_SESSION_ID } from './session-open.constants.ts' @@ -130,9 +130,9 @@ function memoryDelta( async function installProjectionSet(ctx: Context, agentLoopOwnsBoundary: boolean): Promise { // Mirrors projection owners mounted by the base and web-app bundles without timing profile boot. - if (!agentLoopOwnsBoundary) ctx.sessionProjections.register(turnBoundaryProjectionDefinition) - ctx.sessionProjections.register(agentPresetProjectionDefinition) - installModelSelectionProjection(ctx) + if (!agentLoopOwnsBoundary) await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(agentPresetProjectionDefinition) + await ctx.sessionProjections.register(modelSelectionProjection) await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, From ce5c38791ef531459916df9c6f4c48609fd8b4dc Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 12:03:37 +0800 Subject: [PATCH 04/37] docs: update projection lifecycle guidance and generated catalogs --- ...ession-projection-mandatory-seam.i18n.yaml | 12 ++--- ...08-19-session-projection-mandatory-seam.md | 12 ++--- ...19-session-projection-mandatory-seam.zh.md | 12 ++--- ...-projection-registration-handles.i18n.yaml | 19 +++++++ ...session-projection-registration-handles.md | 31 +++++++++++ ...sion-projection-registration-handles.zh.md | 31 +++++++++++ AGENTS.md | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/config-catalog.md | 30 +++++------ docs/config-catalog.zh.md | 30 +++++------ docs/defensive-patterns/remote-and-api.md | 8 +-- docs/defensive-patterns/remote-and-api.zh.md | 8 +-- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 8 +-- docs/persistence-catalog.md | 4 +- docs/persistence-catalog.zh.md | 4 +- docs/persistence-schema.json | 2 +- docs/subsystems/plan.i18n.yaml | 4 +- docs/subsystems/plan.md | 2 +- docs/subsystems/plan.zh.md | 2 +- docs/subsystems/session-projection.i18n.yaml | 12 ++--- docs/subsystems/session-projection.md | 52 +++++++++++-------- docs/subsystems/session-projection.zh.md | 52 +++++++++++-------- .../guide.i18n.yaml | 16 ++++++ .../claude-code-mods-projections/guide.md | 17 ++++++ .../claude-code-mods-projections/guide.zh.md | 17 ++++++ .../subagent-activations/guide.i18n.yaml | 8 +-- .../subagent-activations/guide.md | 6 ++- .../subagent-activations/guide.zh.md | 6 ++- .../guide.i18n.yaml | 16 ++++++ .../session-projection-handles/guide.md | 21 ++++++++ .../session-projection-handles/guide.zh.md | 21 ++++++++ .../extensions/tool-cordis/src/api-catalog.ts | 24 ++++----- scripts/gen-config-catalog.spec.ts | 25 +++++++++ scripts/gen-config-catalog.ts | 2 +- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 5 ++ 40 files changed, 386 insertions(+), 148 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md create mode 100644 .agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md create mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.md create mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.zh.md create mode 100644 docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md create mode 100644 docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml index 24f76c7cb0..f391fe518e 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml @@ -6,14 +6,14 @@ en: 41f30d473ff53ce2 zh: 6851c4414f5331c1 /agent-note-session-projections-as-a-required-reader-seam/problem: - en: c0c779c717cad546 - zh: 5d2916ac86bf5d84 + en: fca00eeb7a8d9abd + zh: d7a9766f5322dff9 /agent-note-session-projections-as-a-required-reader-seam/decision: - en: a1c50d2f43334a88 - zh: d7736994733454b4 + en: dd550c8f4c2aa263 + zh: 9e60b0e465697e77 /agent-note-session-projections-as-a-required-reader-seam/alternatives-considered: en: aea3da939c90d240 zh: 21e91c481cdd23b7 /agent-note-session-projections-as-a-required-reader-seam/consequences: - en: 73325513ec70fbb2 - zh: 610bf34b9f9db420 + en: ab45eb87f7c22cdb + zh: e65a97991a45164f diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md index e1c6835311..5ab32c0b2c 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md @@ -6,19 +6,17 @@ English | [中文](2026-08-19-session-projection-mandatory-seam.zh.md) ## Problem -An optional projection registry lets a plugin whose host behavior reads projected state activate without that state. Unless the reader rejects the missing registry or key, the host behavior or subagent catalog fields can silently disappear. Batch-only reads also materialize every client view when a consumer needs one host value. Some contributor sites intentionally retain optional `ctx.inject` registration, so their readers need an explicit missing-state rule. +An optional projection registry lets a plugin whose host behavior reads projected state activate without that state. Unless the reader rejects the missing registry or key, the host behavior or subagent catalog fields can silently disappear. Batch-only reads also materialize every client view when a consumer needs one host value. ## Decision This decision builds on the split between host projection state and client views in [Session projection state and client views](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md). -Every host reader treats the projection registry and its required key as mandatory state. A plugin either declares `sessionProjections` as a required injection or resolves the registry and key explicitly and throws on the first dependent access. Official compositions mount the registry before those plugins. `ApiProxyService` follows the required-injection form; the lower-level `createApiProxy` factory remains tolerant for isolated tests and diagnostics. +Every host reader treats the projection registry and its required key as mandatory state. Plugins declare `sessionProjections` as a required injection and await acquisition before exposing readers. Official compositions mount the registry before those plugins. `ApiProxyService` follows the required-injection form; the lower-level `createApiProxy` factory remains tolerant for isolated tests and diagnostics. -A domain contributor may register its unit through `ctx.inject(['sessionProjections'], ...)`. Optional registration controls the child lifecycle only; it does not authorize a reader to substitute a default when the registry or key is absent. +Readers acquire state through [awaited registration and retained handles](2026-10-12-session-projection-registration-handles.md). That decision supersedes optional first-access acquisition and returning a bare disposer. It preserves the required-state rule here: a missing registry cannot silently produce default host state. Client views still contain only consumed fields; typed handle reads provide richer host state. -The registry provides `stateOf(session, key)` for one typed host state and keeps `snapshot()` for batch carriers. Client views contain only consumed fields; host readers use `stateOf` for richer state. - -`onChanged` publishes client-visible value changes only. Unit registration and removal remain effect-scoped registry lifecycle; `register()` returns the exact Cordis disposer so a composite domain owner can finish cleanup against projected state before removing its unit. Registration changes do not create a second Host event stream or client tombstone protocol. A later authoritative history or list baseline reflects the active key set. +`onChanged` publishes client-visible value changes only. Registration changes do not create a second Host event stream or client tombstone protocol. A later authoritative history or list baseline reflects the active key set. ## Alternatives considered @@ -30,6 +28,6 @@ The registry provides `stateOf(session, key)` for one typed host state and keeps ## Consequences -- Missing projection composition fails either during activation or at the first dependent host access; it never degrades to a default value. +- Missing projection composition fails during activation; it never degrades to a default value. - Host consumers avoid repeated whole-registry snapshots and log scans. - Wire payloads exclude host-only fields and per-key watermark wrappers; ordinary baselines communicate the active key set. diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md index 5d6a5169a5..0ab43fd5c0 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md @@ -6,19 +6,17 @@ Status: implemented ## 问题 -可选的投影注册表会让 host 行为读取投影状态的插件在缺少该状态时仍然激活。除非读取方拒绝缺失的注册表或 key,否则 host 行为或 subagent 目录字段可能静默消失。只有批量读取也会在消费方只需要一个 host 值时物化每个客户端 view。部分贡献位置有意保留可选的 `ctx.inject` 注册形式,因此其读取方需要明确的缺失状态规则。 +可选的投影注册表会让 host 行为读取投影状态的插件在缺少该状态时仍然激活。除非读取方拒绝缺失的注册表或 key,否则 host 行为或 subagent 目录字段可能静默消失。只有批量读取也会在消费方只需要一个 host 值时物化每个客户端 view。 ## 决策 本决策建立在[会话投影的 host 状态与客户端视图](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md)所定义的拆分之上。 -每个 host 读取方都把投影注册表及其所需 key 视为必需状态。插件要么把 `sessionProjections` 声明为必需注入,要么显式解析注册表与 key,并在第一次依赖它们的访问时抛错。正式组合在这些插件之前挂载注册表。`ApiProxyService` 采用必需注入形式;较低层的 `createApiProxy` factory 对隔离测试和诊断保持容错。 +每个 host 读取方都把投影注册表及其所需 key 视为必需状态。插件将 `sessionProjections` 声明为必需注入,并在暴露读取方前等待获取完成。正式组合在这些插件之前挂载注册表。`ApiProxyService` 采用必需注入形式;较低层的 `createApiProxy` factory 对隔离测试和诊断保持容错。 -领域贡献方可以通过 `ctx.inject(['sessionProjections'], ...)` 注册单元。可选注册只控制子功能生命周期;它并不允许读取方在注册表或 key 缺失时替换为默认值。 +读取方通过[等待注册与保留句柄](2026-10-12-session-projection-registration-handles.zh.md)获取状态。该决策取代可选首次访问获取和返回裸释放函数的方式,保留这里的必需状态规则:缺少注册表不能静默产生默认 host 状态。客户端视图仍只包含消费字段;类型化句柄读取提供更丰富的 host 状态。 -注册表提供 `stateOf(session, key)` 来读取一个类型化 host 状态,并为批量 carrier 保留 `snapshot()`。客户端 view 只包含消费方使用的字段;host 读取方通过 `stateOf` 取得更丰富的状态。 - -`onChanged` 只发布客户端可见值的变化。单元注册和移除仍是绑定 effect 的注册表生命周期;`register()` 返回 Cordis 的原始 disposer,使组合式领域 owner 可以先依据投影状态完成清理,再移除自身单元。注册变化不会创建第二条 Host 事件流或客户端 tombstone 协议。后续权威 history 或 list 基线会反映活跃 key 集。 +`onChanged` 只发布客户端可见值的变化。注册变化不会创建第二条 Host 事件流或客户端 tombstone 协议。后续权威 history 或 list 基线会反映活跃 key 集。 ## 考虑过的替代方案 @@ -30,6 +28,6 @@ Status: implemented ## 后果 -- 缺少投影组合会在激活期间或第一次依赖它的 host 访问时失败,绝不会降级为默认值。 +- 缺少投影组合会在激活期间失败,绝不会降级为默认值。 - host 消费方避免重复的全注册表快照和日志扫描。 - 协议负载排除 host 内部字段和逐 key 水位包装;普通基线会传达活跃 key 集。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml new file mode 100644 index 0000000000..c22458657b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-12-session-projection-registration-handles.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md +/agent-note-asynchronous-projection-registration-and-retained-readers: + en: 693349255afbaec8 + zh: 13bcbf3f40b93b2b +/agent-note-asynchronous-projection-registration-and-retained-readers/problem: + en: 47ba548b869be190 + zh: c2e5038a56fe741c +/agent-note-asynchronous-projection-registration-and-retained-readers/decision: + en: 19064b5aecb26f57 + zh: 075bb87c6a5299ae +/agent-note-asynchronous-projection-registration-and-retained-readers/alternatives-considered: + en: ee5b0cd715ed1fef + zh: 6b1478dcde21916d +/agent-note-asynchronous-projection-registration-and-retained-readers/consequences: + en: 4b5e4bb446872545 + zh: 9fb2cc8ae77fb823 diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md new file mode 100644 index 0000000000..ea16e10828 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md @@ -0,0 +1,31 @@ +# Agent Note: Asynchronous projection registration and retained readers + +Status: implemented + +English | [中文](2026-10-12-session-projection-registration-handles.zh.md) + +## Problem + +Host readers coupled to registry key lookup lose access when a plugin unloads, although an Agent may still need projected state while its asynchronous cleanup drains. Repeated batch snapshots materialize unrelated client views. A synchronous registration API also makes every consumer assume that acquisition can never require asynchronous initialization. + +## Decision + +`SessionProjectionRegistry.register` returns a promise for a typed handle. Consumers await it during activation before exposing dependent operations. Function plugins retain their namespace exports and use asynchronous `apply`; services use their initialization hook. Definitions remain with their domain owner, and each reader registers the required definition instead of looking up a possibly absent key. This supersedes the optional first-access acquisition and disposer-return details in [the mandatory-reader decision](2026-08-19-session-projection-mandatory-seam.md), while retaining its mandatory-state and cropped-client-view rules. + +A handle owns one reference to a shared definition and its per-Session cells. Its disposer releases global participation exactly once; the final release removes the unit from live drives, snapshots, and checkpoints. Its reader remains usable after release, folding unseen events with the original definition. A later registration at a different state version does not replace an older handle's definition. Registration checks owner liveness after its asynchronous handoff and rejects if the owner unloaded before receiving the handle. + +AgentLoop acquires inbox and turn-boundary handles once before publishing its factory. Each Agent receives those readers during synchronous construction. Cleanup can use them after the loop fiber removes global registrations, so projection release need not wait for every Agent teardown. This preserves the existing creation/setup sequence without per-Agent registration or an extra initialization state. + +## Alternatives considered + +**Keep registry-level lookup and delay unregistering.** This couples unrelated plugin disposal order to every reader's final access. Retained readers make cleanup independent of that order. + +**Return a synchronous handle.** This preserves callers' immediate reads but commits the acquisition API to synchronous initialization. Awaited acquisition leaves that timing explicit while state reads remain synchronous after readiness. + +**Register the same units for each Agent.** The definitions already contain per-Session cells. Additional references and asynchronous Agent initialization add a creation race without providing independent data ownership. + +**Convert function plugins to classes.** Cordis already awaits promises returned by `apply`. Preserving namespace exports avoids a published entry-point and diagnostic-name break. + +## Consequences + +Consumers must migrate acquisition and retain handles. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown. diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md new file mode 100644 index 0000000000..28974a237b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 异步投影注册与保留读取句柄 + +Status: implemented + +[English](2026-10-12-session-projection-registration-handles.md) | 中文 + +## Problem + +依赖注册表键查询的 Host 读取方会在插件卸载时失去访问能力,但 Agent 可能仍需在异步清理排空期间读取投影状态。重复的批量快照会具象化无关 Client 视图。同步注册 API 也使每个消费者假定获取过程永远不需要异步初始化。 + +## Decision + +`SessionProjectionRegistry.register` 返回类型化句柄的 Promise。消费者在激活期间等待它,再暴露依赖操作。函数插件保留命名空间导出并使用异步 `apply`;服务使用初始化钩子。定义仍归领域拥有方,每个读取方注册所需定义,不再查询可能不存在的键。这取代了[必需读取方决策](2026-08-19-session-projection-mandatory-seam.zh.md)中的可选首次访问获取方式与返回释放函数的细节,同时保留必需状态和裁剪 Client 视图的规则。 + +句柄拥有共享定义及其逐 Session 单元的一次引用。其释放函数恰好一次地解除全局参与;最后一次释放会从实时推进、快照和检查点中移除单元。读取句柄在释放后仍可使用原定义折叠未读取事件。后续注册的其他状态版本不会替换旧句柄的定义。注册在异步交接后检查 owner 是否存活;如果 owner 在收到句柄前卸载,注册拒绝。 + +AgentLoop 在发布工厂前只获取一次 inbox 与轮次边界句柄。每个 Agent 在同步构造时收到这些读取句柄。loop fiber 移除全局注册后,清理过程仍可使用它们,因此投影释放无需等待每个 Agent teardown。这保留既有创建与 setup 顺序,无需逐 Agent 注册或增加初始化状态。 + +## Alternatives considered + +**保留注册表级查询并延迟注销。** 这使无关插件的释放顺序依赖每个读取方的最后一次访问。保留读取句柄使清理不再依赖该顺序。 + +**返回同步句柄。** 这保留消费者的立即读取,却将获取 API 固定为同步初始化。等待获取过程使时序显式,同时就绪后的状态读取仍同步。 + +**为每个 Agent 注册相同单元。** 定义已经包含逐 Session 单元。额外引用和异步 Agent 初始化只会引入创建竞态,不提供独立数据所有权。 + +**将函数插件改为类。** Cordis 已等待 `apply` 返回的 Promise。保留命名空间导出可避免发布入口与诊断名称的破坏性变更。 + +## Consequences + +消费者必须迁移获取方式并保留句柄。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。 diff --git a/AGENTS.md b/AGENTS.md index 724faca086..f830b6d8ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,7 +130,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and - Packages use `@deepseek-ai/dsh-`; vendor is [rescoped](docs/rescope.md) and `private: true`. Harness packages declare `@deepseek-ai/cordis` in `peerDependencies`/`devDependencies`. Workspace dependency sections use DSH `workspace:*`, vendor/native `workspace:~` ([rules](.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). - ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. -- **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. +- **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer, directly or through its documented registration handle. - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`. Public service methods document parameters and non-void returns. `SessionEventMap` members are required-on-read unless the envelope carries `ignorable: true`. Bump `SESSION_FORMAT_VERSION` only when existing discriminators cannot prevent unsafe interpretation; same-version changes require safe reader behavior ([review](.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.md)). - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default. - **Waterfall listeners MUST call `next()`** to delegate; returning without it short-circuits the chain ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)). diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 8206f3e6dc..5b82618b52 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -27,8 +27,8 @@ en: e6402b28c0ed15fa zh: 6c07be9047b42056 /deepseek-harness-architecture/session-log: - en: a2e35c43438fec6d - zh: e27bdac8e9feff32 + en: 35fcd01052084a07 + zh: e16743dac147cb6e /deepseek-harness-architecture/capability-seams: en: a5bc20c3f2a1b370 zh: 91773a281410b2f4 diff --git a/docs/architecture.md b/docs/architecture.md index 03d88148c7..b821b9fa92 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -123,7 +123,7 @@ Session consumers know only the current logical format. Header-only `stat` and ` **Model-visible means logged.** Every model request must be reconstructable from the log. New model-visible inputs require session events. Plugins changing existing message content register [pure message projections](subsystems/session.md#plugin-owned-message-projections); detached readers supply the same definitions explicitly. Tool changes are capability-independent; [Session tool history](../packages/core/session/README.md) supplies provider declarations. -**Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, host consumers read one typed state with `stateOf()`, and carriers batch cropped client views with `snapshot()`. A host reader either requires this service during activation or fails explicitly when the registry or required key is absent. Contributors may retain `ctx.inject(['sessionProjections'], ...)` registration without silently defaulting a missing host value. The agent loop registers shared `turnBoundary` state for its readers ([decision](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md)). +**Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, and carriers batch cropped client views with `snapshot()`. Host readers require the registry during activation, await registration, and retain a handle for synchronous state reads. Releasing registration removes global participation; retained readers continue folding Session events. The shared `turnBoundary` definition belongs to `dsh-agent` ([registration lifecycle](../packages/session/session-projection/README.md)). [Working directories](subsystems/working-directory.md) supply user context and execution paths without changing original project identity, sandbox write roots, or existing process directories. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 870fa1c996..403c4f3063 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -127,7 +127,7 @@ Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list` **模型可见即已记录。** 每个模型请求都必须能从日志重建。新增模型可见输入需要会话事件。修改现有消息内容的插件注册[纯消息投影](subsystems/session.zh.md#plugin-owned-message-projections),独立读取器显式传入相同的处理器。 工具变更不依赖能力;[Session 工具历史](../packages/core/session/README.zh.md)提供提供方声明。 -**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,host 消费方通过 `stateOf()` 读取单个类型化状态,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。host 读取方要么在激活时要求该服务,要么在注册表或必需 key 缺席时明确失败。贡献方可以保留 `ctx.inject(['sessionProjections'], ...)` 注册,但不能为缺失的 host 值静默提供默认值。agent loop 为读取方注册共享的 `turnBoundary` 状态([决策](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md))。 +**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。host 读取方在激活时要求注册表,等待注册完成,并保留句柄以同步读取状态。释放注册会移除全局参与关系;已有读取方仍可继续折叠 Session 事件。共享的 `turnBoundary` 定义归 `dsh-agent` 所有([注册生命周期](../packages/session/session-projection/README.zh.md))。 [工作目录](subsystems/working-directory.zh.md) 提供用户上下文和执行路径,不改变原始项目标识、沙箱写入根目录或已有进程目录。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 00bad2b532..eb2a6a1ea2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -92,7 +92,7 @@ export interface Config { - `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) · `Volatile` (`@deepseek-ai/cosmokit`) -- `source`: [`packages/core/agent-loop/src/index.ts:303`](../packages/core/agent-loop/src/index.ts) +- `source`: [`packages/core/agent-loop/src/index.ts:254`](../packages/core/agent-loop/src/index.ts) ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -220,7 +220,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` - `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:80`](../packages/api/session-controller/src/index.ts) +- `source`: [`packages/api/session-controller/src/index.ts:79`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ @@ -950,7 +950,7 @@ export interface StagehandModelConfig { ## `@deepseek-ai/dsh-experimental-claude-code-mods` -- `inject`: `workingDirectory` +- `inject`: `workingDirectory` · `sessionProjections` - `source`: [`packages/experimental/claude-code-mods/src/index.ts:53`](../packages/experimental/claude-code-mods/src/index.ts) ```ts config-catalog @@ -1458,7 +1458,7 @@ export interface HmrConfig extends ChokidarOptions { ## `@deepseek-ai/dsh-hooks-claude-code` - `inject`: `shell` · `sessionProjections` · `workingDirectory` -- `source`: [`packages/experimental/hooks-claude-code/src/index.ts:51`](../packages/experimental/hooks-claude-code/src/index.ts) +- `source`: [`packages/experimental/hooks-claude-code/src/index.ts:53`](../packages/experimental/hooks-claude-code/src/index.ts) ```ts config-catalog /** Plugin config: where the CC hook config lives + substitution roots. */ @@ -1497,7 +1497,7 @@ export interface Config { ## `@deepseek-ai/dsh-hooks-codex` - `inject`: `shell` · `sessionProjections` · `workingDirectory` -- `source`: [`packages/experimental/hooks-codex/src/index.ts:50`](../packages/experimental/hooks-codex/src/index.ts) +- `source`: [`packages/experimental/hooks-codex/src/index.ts:53`](../packages/experimental/hooks-codex/src/index.ts) ```ts config-catalog /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ @@ -2098,7 +2098,7 @@ export interface ReplayModelConfig { ## `@deepseek-ai/dsh-llm-retry` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/llm/llm-retry/src/index.ts:25`](../packages/llm/llm-retry/src/index.ts) +- `source`: [`packages/llm/llm-retry/src/index.ts:26`](../packages/llm/llm-retry/src/index.ts) ```ts config-catalog /** This policy executor has no config; providers own `retryPolicy`. */ @@ -2372,7 +2372,7 @@ export interface Config { ## `@deepseek-ai/dsh-plan-mode` - `inject`: `tools` · `systemPrompt` · `sessionProjections` -- `source`: [`packages/plan/plan-mode/src/index.ts:69`](../packages/plan/plan-mode/src/index.ts) +- `source`: [`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts) ```ts config-catalog /** Deployment-owned plan guidance. */ @@ -3259,7 +3259,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ## `@deepseek-ai/dsh-subagent` -- `inject`: `workingDirectory` +- `inject`: `workingDirectory` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) - `source`: [`packages/subagent/subagent/src/index.ts:184`](../packages/subagent/subagent/src/index.ts) @@ -3511,7 +3511,7 @@ export interface Config { ## `@deepseek-ai/dsh-terminal-bash` -- `inject`: `terminals` · `sandboxPolicy` · `sessionProjections` · `subprocess` · `workingDirectory` +- `inject`: `terminals` · `sandboxPolicy` · `subprocess` · `workingDirectory` - `source`: [`packages/terminal/terminal-bash/src/config.ts:10`](../packages/terminal/terminal-bash/src/config.ts) ```ts config-catalog @@ -3572,7 +3572,7 @@ export type ShellDialect = 'bash' | 'pwsh' ## `@deepseek-ai/dsh-time-context` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/context/time-context/src/index.ts:56`](../packages/context/time-context/src/index.ts) +- `source`: [`packages/context/time-context/src/index.ts:55`](../packages/context/time-context/src/index.ts) ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ @@ -3591,7 +3591,7 @@ export interface Config { ## `@deepseek-ai/dsh-tmux-context` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/context/tmux-context/src/index.ts:47`](../packages/context/tmux-context/src/index.ts) +- `source`: [`packages/context/tmux-context/src/index.ts:45`](../packages/context/tmux-context/src/index.ts) ```ts config-catalog /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ @@ -3753,7 +3753,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-goal` - `inject`: `agents` · `goals` · `tools` · `systemPrompt` · `sessionProjections` -- `source`: [`packages/goal/tool-goal/src/index.ts:32`](../packages/goal/tool-goal/src/index.ts) +- `source`: [`packages/goal/tool-goal/src/index.ts:33`](../packages/goal/tool-goal/src/index.ts) ```ts config-catalog /** Model policy and hard lower bounds for goal-state updates. */ @@ -3921,7 +3921,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-session-query` - `inject`: `tools` · `systemPrompt` · `sessionQuery` · `sessionProjections` -- `source`: [`packages/experimental/tool-session-query/src/index.ts:28`](../packages/experimental/tool-session-query/src/index.ts) +- `source`: [`packages/experimental/tool-session-query/src/index.ts:26`](../packages/experimental/tool-session-query/src/index.ts) ```ts config-catalog /** Deployment-owned search count and timeout bounds. */ @@ -3977,7 +3977,7 @@ export interface Config { - `inject`: `tools` · `subagents` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.md) -- `source`: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) +- `source`: [`packages/subagent/tool-subagent/src/index.ts:49`](../packages/subagent/tool-subagent/src/index.ts) ```ts config-catalog /** Config: which registered provider this tool delegates to, plus child defaults. */ @@ -4054,7 +4054,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-todo` - `inject`: `tools` · `sessionProjections` -- `source`: [`packages/todo/tool-todo/src/index.ts:29`](../packages/todo/tool-todo/src/index.ts) +- `source`: [`packages/todo/tool-todo/src/index.ts:30`](../packages/todo/tool-todo/src/index.ts) ```ts config-catalog /** Model-facing todo tool configuration. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 3d17bcb06a..aacdae8e58 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -94,7 +94,7 @@ export interface Config { - `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md) · `Volatile` (`@deepseek-ai/cosmokit`) -- `source`: [`packages/core/agent-loop/src/index.ts:303`](../packages/core/agent-loop/src/index.ts) +- `source`: [`packages/core/agent-loop/src/index.ts:254`](../packages/core/agent-loop/src/index.ts) ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -222,7 +222,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` - `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:80`](../packages/api/session-controller/src/index.ts) +- `source`: [`packages/api/session-controller/src/index.ts:79`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ @@ -952,7 +952,7 @@ export interface StagehandModelConfig { ## `@deepseek-ai/dsh-experimental-claude-code-mods` -- `inject`: `workingDirectory` +- `inject`: `workingDirectory` · `sessionProjections` - `source`: [`packages/experimental/claude-code-mods/src/index.ts:53`](../packages/experimental/claude-code-mods/src/index.ts) ```ts config-catalog @@ -1460,7 +1460,7 @@ export interface HmrConfig extends ChokidarOptions { ## `@deepseek-ai/dsh-hooks-claude-code` - `inject`: `shell` · `sessionProjections` · `workingDirectory` -- `source`: [`packages/experimental/hooks-claude-code/src/index.ts:51`](../packages/experimental/hooks-claude-code/src/index.ts) +- `source`: [`packages/experimental/hooks-claude-code/src/index.ts:53`](../packages/experimental/hooks-claude-code/src/index.ts) ```ts config-catalog /** Plugin config: where the CC hook config lives + substitution roots. */ @@ -1499,7 +1499,7 @@ export interface Config { ## `@deepseek-ai/dsh-hooks-codex` - `inject`: `shell` · `sessionProjections` · `workingDirectory` -- `source`: [`packages/experimental/hooks-codex/src/index.ts:50`](../packages/experimental/hooks-codex/src/index.ts) +- `source`: [`packages/experimental/hooks-codex/src/index.ts:53`](../packages/experimental/hooks-codex/src/index.ts) ```ts config-catalog /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ @@ -2100,7 +2100,7 @@ export interface ReplayModelConfig { ## `@deepseek-ai/dsh-llm-retry` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/llm/llm-retry/src/index.ts:25`](../packages/llm/llm-retry/src/index.ts) +- `source`: [`packages/llm/llm-retry/src/index.ts:26`](../packages/llm/llm-retry/src/index.ts) ```ts config-catalog /** This policy executor has no config; providers own `retryPolicy`. */ @@ -2374,7 +2374,7 @@ export interface Config { ## `@deepseek-ai/dsh-plan-mode` - `inject`: `tools` · `systemPrompt` · `sessionProjections` -- `source`: [`packages/plan/plan-mode/src/index.ts:69`](../packages/plan/plan-mode/src/index.ts) +- `source`: [`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts) ```ts config-catalog /** Deployment-owned plan guidance. */ @@ -3261,7 +3261,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ## `@deepseek-ai/dsh-subagent` -- `inject`: `workingDirectory` +- `inject`: `workingDirectory` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) - `source`: [`packages/subagent/subagent/src/index.ts:184`](../packages/subagent/subagent/src/index.ts) @@ -3513,7 +3513,7 @@ export interface Config { ## `@deepseek-ai/dsh-terminal-bash` -- `inject`: `terminals` · `sandboxPolicy` · `sessionProjections` · `subprocess` · `workingDirectory` +- `inject`: `terminals` · `sandboxPolicy` · `subprocess` · `workingDirectory` - `source`: [`packages/terminal/terminal-bash/src/config.ts:10`](../packages/terminal/terminal-bash/src/config.ts) ```ts config-catalog @@ -3574,7 +3574,7 @@ export type ShellDialect = 'bash' | 'pwsh' ## `@deepseek-ai/dsh-time-context` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/context/time-context/src/index.ts:56`](../packages/context/time-context/src/index.ts) +- `source`: [`packages/context/time-context/src/index.ts:55`](../packages/context/time-context/src/index.ts) ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ @@ -3593,7 +3593,7 @@ export interface Config { ## `@deepseek-ai/dsh-tmux-context` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/context/tmux-context/src/index.ts:47`](../packages/context/tmux-context/src/index.ts) +- `source`: [`packages/context/tmux-context/src/index.ts:45`](../packages/context/tmux-context/src/index.ts) ```ts config-catalog /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ @@ -3755,7 +3755,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-goal` - `inject`: `agents` · `goals` · `tools` · `systemPrompt` · `sessionProjections` -- `source`: [`packages/goal/tool-goal/src/index.ts:32`](../packages/goal/tool-goal/src/index.ts) +- `source`: [`packages/goal/tool-goal/src/index.ts:33`](../packages/goal/tool-goal/src/index.ts) ```ts config-catalog /** Model policy and hard lower bounds for goal-state updates. */ @@ -3923,7 +3923,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-session-query` - `inject`: `tools` · `systemPrompt` · `sessionQuery` · `sessionProjections` -- `source`: [`packages/experimental/tool-session-query/src/index.ts:28`](../packages/experimental/tool-session-query/src/index.ts) +- `source`: [`packages/experimental/tool-session-query/src/index.ts:26`](../packages/experimental/tool-session-query/src/index.ts) ```ts config-catalog /** Deployment-owned search count and timeout bounds. */ @@ -3979,7 +3979,7 @@ export interface Config { - `inject`: `tools` · `subagents` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.zh.md) -- `source`: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) +- `source`: [`packages/subagent/tool-subagent/src/index.ts:49`](../packages/subagent/tool-subagent/src/index.ts) ```ts config-catalog /** Config: which registered provider this tool delegates to, plus child defaults. */ @@ -4056,7 +4056,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-todo` - `inject`: `tools` · `sessionProjections` -- `source`: [`packages/todo/tool-todo/src/index.ts:29`](../packages/todo/tool-todo/src/index.ts) +- `source`: [`packages/todo/tool-todo/src/index.ts:30`](../packages/todo/tool-todo/src/index.ts) ```ts config-catalog /** Model-facing todo tool configuration. */ diff --git a/docs/defensive-patterns/remote-and-api.md b/docs/defensive-patterns/remote-and-api.md index 25281e96fb..df98cdde2a 100644 --- a/docs/defensive-patterns/remote-and-api.md +++ b/docs/defensive-patterns/remote-and-api.md @@ -92,16 +92,18 @@ The owning package decides and registers its unit, and the controller only reads ```ts ignore-check // packages/workspace//src/index.ts: the rule lives with the feature it serves. -ctx.sessionProjections.register({ +export const durablePageProjectionDefinition = { key: 'durablePage', stateSchema: durablePageState, init: () => ({ durable: false }), apply: applyDurablePage, stateVersion: 1, -}) +} +await ctx.sessionProjections.register(durablePageProjectionDefinition) // packages/api/session-controller: generic orchestration reads the projected value. -const { durable } = ctx.sessionProjections.stateOf(session, 'durablePage') +const durablePage = await ctx.sessionProjections.register(durablePageProjectionDefinition) +const { durable } = durablePage.stateOf(session) ``` The wrong version decides the rule inside the shared layer: diff --git a/docs/defensive-patterns/remote-and-api.zh.md b/docs/defensive-patterns/remote-and-api.zh.md index 1a9c291d9c..79691a940a 100644 --- a/docs/defensive-patterns/remote-and-api.zh.md +++ b/docs/defensive-patterns/remote-and-api.zh.md @@ -92,16 +92,18 @@ type SessionRemote = { list(): ...; rename(id: string): ... } // hand-written ```ts ignore-check // packages/workspace//src/index.ts: the rule lives with the feature it serves. -ctx.sessionProjections.register({ +export const durablePageProjectionDefinition = { key: 'durablePage', stateSchema: durablePageState, init: () => ({ durable: false }), apply: applyDurablePage, stateVersion: 1, -}) +} +await ctx.sessionProjections.register(durablePageProjectionDefinition) // packages/api/session-controller: generic orchestration reads the projected value. -const { durable } = ctx.sessionProjections.stateOf(session, 'durablePage') +const durablePage = await ctx.sessionProjections.register(durablePageProjectionDefinition) +const { durable } = durablePage.stateOf(session) ``` 反例把这条规则的决定放进共享层: diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 0f0d3dbdcf..fd1fcb14a8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:249`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:200`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:69`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 16b47c76cb..ad79919624 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,7 +10,7 @@ | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:249`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:200`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:69`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index e61666b542..342e85df6d 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -144,8 +144,8 @@ en: 4294f78d26ed985b zh: 4294f78d26ed985b /session-persistence-event-catalog/events/plan/plan-mode-log-only: - en: 862175bfe8692ef7 - zh: a8ab123bf674cf77 + en: 9ae5537eeae238de + zh: 6be761c7fd83fdce /session-persistence-event-catalog/events/request: en: a8df82440ebdf5f8 zh: a8df82440ebdf5f8 @@ -1347,8 +1347,8 @@ en: a74e3dfa51c987af zh: 6c3c87974d7a7260 /session-persistence-event-catalog/resolved-persistence-types/active~2: - en: a3de6a6fe9672c7a - zh: a357481e27b3b81f + en: c861888d6239b700 + zh: a9b2271d3c6b8737 /session-persistence-event-catalog/resolved-persistence-types/agentpreset: en: b8a0f8aac9708056 zh: f5f004bd205b41e1 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 3d8b0b77db..eb4ebe099b 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -700,7 +700,7 @@ Source: [`packages/interaction/permission-presets/src/index.ts:59`](../packages/ 'plan/mode': { active: boolean } ``` -Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) ### `request/*` @@ -5556,7 +5556,7 @@ Array of `unknown` (opaque). SHA-256: `20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5` -Sources: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/src/index.ts) +Sources: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 76af401f6b..5b1def70af 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -702,7 +702,7 @@ export type SessionEvent = { 'plan/mode': { active: boolean } ``` -来源:[`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) ### `request/*` @@ -5558,7 +5558,7 @@ SHA-256: `b1664fa89f8eab49a14e7e3168d8bc4da4aa9fd76332f6c4a053d839e5276ae7` SHA-256: `20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5` -来源:[`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 729e58948d..1da9491a5f 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -22518,7 +22518,7 @@ "digest": "20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5", "names": [], "sources": [ - "packages/plan/plan-mode/src/index.ts:52" + "packages/plan/plan-mode/src/index.ts:53" ] }, { diff --git a/docs/subsystems/plan.i18n.yaml b/docs/subsystems/plan.i18n.yaml index e5ce0f0902..a55599bd51 100644 --- a/docs/subsystems/plan.i18n.yaml +++ b/docs/subsystems/plan.i18n.yaml @@ -6,8 +6,8 @@ en: 234f6e4c49714997 zh: add95d36252b572d /plan-mode/logged-state-and-recovery: - en: dd1e8ba31e6fd535 - zh: a22dbccc60c73bc9 + en: 4c83897d7a6d8a67 + zh: 22dcc171a439ed19 /plan-mode/pending-selections-and-the-pre-step-append: en: 8d7e5b7824921ed5 zh: 910fad43593f29f4 diff --git a/docs/subsystems/plan.md b/docs/subsystems/plan.md index beba872f61..56af5f14d2 100644 --- a/docs/subsystems/plan.md +++ b/docs/subsystems/plan.md @@ -8,7 +8,7 @@ Source: [`packages/plan/plan-mode/src/index.ts`](../../packages/plan/plan-mode/s ## Logged state and recovery -`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace [session event](session.md): durable and replayable, never in the model transcript. The optionally registered `plan` unit folds committed mode, command settlement, and the mode recorded at the latest request header. `ctx.planMode` reads that state through `stateOf()`; the first dependent access fails if the registry, `plan` key, or `turnBoundary` key is absent. Clients receive only `{ active, pending }`; resume, fork, and compaction recover both from the log. The complete event declaration is in the [persistence log event catalog](../persistence-catalog.md). +`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace [session event](session.md): durable and replayable, never in the model transcript. The `plan` unit folds committed mode, command settlement, and the mode recorded at the latest request header. `ctx.planMode` requires the registry and awaits registration of `plan` and `turnBoundary` during activation, then reads through their retained handles. Clients receive only `{ active, pending }`; resume, fork, and compaction recover both from the log. The complete event declaration is in the [persistence log event catalog](../persistence-catalog.md). ## Pending selections and the pre-step append diff --git a/docs/subsystems/plan.zh.md b/docs/subsystems/plan.zh.md index 63463f0105..f0bb4a0d99 100644 --- a/docs/subsystems/plan.zh.md +++ b/docs/subsystems/plan.zh.md @@ -8,7 +8,7 @@ ## 已记录状态与恢复 -`plan/mode`(`{ active: boolean }`)是仅记日志、整值替换的[会话事件](session.zh.md):持久且可回放,绝不进入模型 transcript(文本记录)。可选注册的 `plan` 单元折叠已提交模式、命令结算结果和最近一次请求头记录的模式。`ctx.planMode` 通过 `stateOf()` 读取该状态;注册表、`plan` key 或 `turnBoundary` key 缺失时,第一次依赖它们的访问会失败。客户端只接收 `{ active, pending }`;恢复、fork 与压缩(compaction)都能从日志恢复两者。完整事件声明见[持久化日志事件目录](../persistence-catalog.zh.md)。 +`plan/mode`(`{ active: boolean }`)是仅记日志、整值替换的[会话事件](session.zh.md):持久且可回放,绝不进入模型 transcript(文本记录)。`plan` 单元折叠已提交模式、命令结算结果和最近一次请求头记录的模式。`ctx.planMode` 要求注册表存在,并在激活期间等待 `plan` 和 `turnBoundary` 注册完成,之后通过保留的句柄读取状态。客户端只接收 `{ active, pending }`;恢复、fork 与压缩(compaction)都能从日志恢复两者。完整事件声明见[持久化日志事件目录](../persistence-catalog.zh.md)。 ## 待生效选择与 pre-step 追加 diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 8d53d7ed36..5f008d2f02 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -3,14 +3,14 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md /session-projections: - en: a6ab5134ca1771fd - zh: 523039a9d84e3f97 + en: e9330151d3eb579a + zh: 53e47acd1d810da9 /session-projections/the-unit: en: fad897af8b8839ff zh: 3c68c5256973bc39 /session-projections/the-snapshot-and-the-change-feed: - en: a49a57c600c3cbd0 - zh: c92f00b8a9d71bad + en: 630c1041f41f89bf + zh: a3361bdead116cd7 /session-projections/the-registry-ctx-sessionprojections: - en: 50e79c1bc16abb63 - zh: 200ecbf0e4ab19d2 + en: 67fe0f49dee90e25 + zh: c23de3a2803040f0 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 8580b94c4d..5da8044825 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -2,10 +2,27 @@ English | [中文](session-projection.zh.md) -The session-projection seam — a [capability seam](../capability-seams.md) through which domain host plugins serve whole current values of log-derived per-session state to client carriers: the Service Definition and registry ([dsh-session-projection](../../packages/session/session-projection), `ctx.sessionProjections`), domain contributors (each registering one pure unit), and carriers ([dsh-session-controller](../../packages/api/session-controller)'s history tail page and `session/projection` push frame). It is one optional capability, not part of the agent-loop spine. The framework drives, the domain computes: the registry subscribes to `session/event` once and folds every committed event through every unit; domains hold no subscriptions and clients never fold domain events — they receive finished values. Design authority: the [session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md); drive/cache/feed contracts: the [package README](../../packages/session/session-projection/README.md). +The session-projection seam — a [capability seam](../capability-seams.md) through which domain host plugins serve whole current values of log-derived per-session state to client carriers: the Service Definition and registry ([dsh-session-projection](../../packages/session/session-projection), `ctx.sessionProjections`), domain contributors (each registering one pure unit), and carriers ([dsh-session-controller](../../packages/api/session-controller)'s history tail page and `session/projection` push frame). Host state readers require the registry during activation. The framework drives, the domain computes: the registry subscribes to `session/event` once and folds every committed event through every unit; domains hold no subscriptions and clients never fold domain events — they receive finished values. Design authority: the [session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md); drive/cache/feed contracts: the [package README](../../packages/session/session-projection/README.md). Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) +A `ProjectionHandle` retains its synchronous reader after unregistration. `stateOf(session)` folds newly committed events with the original unit; it does not rejoin global snapshots, checkpoints, or notifications. Registration must still finish before the handle can be used. See the [registration lifecycle](../../packages/session/session-projection/README.md). + +```ts type-equiv +/** An effect-owned registration with a reader retained independently of registration. */ +interface ProjectionHandle { + /** + * Read the current state, including after unregistering, by folding new + * Session events with this handle's original unit. Do not mutate the live value. + * @param session - Session whose committed events are folded. + * @returns the projection state at the Session's current cursor. + */ + stateOf(session: Session): S + /** Unregister from global drives and snapshots without invalidating this reader. */ + dispose(): void | Promise +} +``` + ## The unit `SessionProjectionStateMap` is the merge-extensible table of host fold states, while `SessionProjectionMap` retains the client-visible whole values. A domain contributes one `ProjectionDefinition` per state key; a `wire` block makes that key client-visible, and rendering belongs to the slot system, never this layer: @@ -99,11 +116,11 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. A state-reference change computes one cached raw view, and the change feed fires only when that result changes by `Object.is`; an object-valued view must preserve its reference to suppress publication across internal-only state changes. +`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `handle.stateOf(session)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. A state-reference change computes one cached raw view, and the change feed fires only when that result changes by `Object.is`; an object-valued view must preserve its reference to suppress publication across internal-only state changes. ## The registry: `ctx.sessionProjections` -`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect whose disposer rides the calling fiber: an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots, and clients read that as capability absence; a duplicate key with a different `stateVersion` throws, while same-version registrants share one unit and are counted. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. +`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Domains await `register(definition)` during activation and retain its handle. Registration belongs to the calling fiber: its last release removes the key from global drives and snapshots, while retained handles remain readable. Same-key, same-version registrants share one unit; incompatible versions reject registration. @@ -204,26 +221,27 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, checkpoints, and client snapshots; retained handles still fold new events on demand using the original unit. ```ts cordis-catalog /** - * Register one domain's unit. The registration is an effect on the calling - * context's fiber: disposing the fiber (or calling the returned disposer) - * removes the key — and the unit's cached cells — from subsequent drives - * and snapshots. + * Register one domain's unit on the calling context's fiber. Fiber or handle + * disposal releases one reference; the final release removes live participation. + * Retained handles remain readable after release. + * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns the exact disposer that unregisters this unit. + * @returns a ready handle with synchronous state reads and the effect disposer. */ -register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, ): () => void +register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, ): Promise> /** * Register one host-only unit. Its state is omitted from client snapshots * and always checkpointed like every other unit. + * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns the exact disposer that unregisters this unit. + * @returns a ready handle with synchronous state reads and the effect disposer. */ -register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'>, ): () => void +register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'>, ): Promise> /** * Subscribe to the change feed. The registration is an effect on the @@ -233,16 +251,6 @@ register< K extends Exclude void -/** - * Read one unit's current host state after materializing every registered - * unit at the Session cursor. Unrelated wire views are not produced. - * The returned value is live; callers must not mutate it. - * @param session - the session whose state is read. - * @param key - the registered unit key. - * @returns current state, or `undefined` when the key is not registered. - */ -stateOf( session: Session, key: K, ): SessionProjectionStateMap[K] | undefined - /** * One consistent cut over every registered client-visible unit for one session, read from * the watermark cache (missing cells fold lazily over the in-memory log). diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index b98d348a36..c2c952b8fc 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -2,10 +2,27 @@ [English](session-projection.md) | 中文 -会话投影 seam 是一项[能力 seam](../capability-seams.zh.md):领域 host 插件经由它向客户端载体供给按会话的日志派生状态的当前全量值;三方分别是 Service Definition 与注册表([dsh-session-projection](../../packages/session/session-projection),`ctx.sessionProjections`)、领域贡献方(每个领域注册一个纯单元)与载体([dsh-session-controller](../../packages/api/session-controller) 的历史尾页与 `session/projection` 推送帧)。它是一项可选能力,不属于 agent loop(智能体循环)主干。框架负责驱动,领域负责计算:注册表只订阅一次 `session/event`,并把每个已提交事件折叠进每个单元;领域不持有任何订阅,客户端也从不折叠领域事件——它们收到的是成品值。设计权威:[session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md);驱动、缓存与变更流约定:[包 README](../../packages/session/session-projection/README.zh.md)。 +会话投影 seam 是一项[能力 seam](../capability-seams.zh.md):领域 host 插件经由它向客户端载体供给按会话的日志派生状态的当前全量值;三方分别是 Service Definition 与注册表([dsh-session-projection](../../packages/session/session-projection),`ctx.sessionProjections`)、领域贡献方(每个领域注册一个纯单元)与载体([dsh-session-controller](../../packages/api/session-controller) 的历史尾页与 `session/projection` 推送帧)。Host 状态读取方在激活期间要求注册表存在。框架负责驱动,领域负责计算:注册表只订阅一次 `session/event`,并把每个已提交事件折叠进每个单元;领域不持有任何订阅,客户端也从不折叠领域事件——它们收到的是成品值。设计权威:[session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md);驱动、缓存与变更流约定:[包 README](../../packages/session/session-projection/README.zh.md)。 源码:[`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) +`ProjectionHandle` 在注销后仍保留同步 reader。`stateOf(session)` 用原有单元折叠新提交的事件,不会重新加入全局快照、检查点或通知。必须等待注册完成才能使用 handle。参见[注册生命周期](../../packages/session/session-projection/README.zh.md)。 + +```ts type-equiv +/** An effect-owned registration with a reader retained independently of registration. */ +interface ProjectionHandle { + /** + * Read the current state, including after unregistering, by folding new + * Session events with this handle's original unit. Do not mutate the live value. + * @param session - Session whose committed events are folded. + * @returns the projection state at the Session's current cursor. + */ + stateOf(session: Session): S + /** Unregister from global drives and snapshots without invalidating this reader. */ + dispose(): void | Promise +} +``` + ## 投影单元 `SessionProjectionStateMap` 是 host 侧折叠状态的 merge-extensible 类型表,`SessionProjectionMap` 则继续表示客户端可见的全量值。领域为每个状态 key 贡献一个 `ProjectionDefinition`;`wire` 块使该 key 对客户端可见,渲染归 slot 体系管,永远不归本层: @@ -99,11 +116,11 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。state 引用变化时,注册表计算并缓存一次原始 view;只有该结果通过 `Object.is` 判定为变化时才触发变更流,对象 view 若要在仅内部 state 变化时抑制发布就必须保留引用。 +`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`handle.stateOf(session)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。state 引用变化时,注册表计算并缓存一次原始 view;只有该结果通过 `Object.is` 判定为变化时才触发变更流,对象 view 若要在仅内部 state 变化时抑制发布就必须保留引用。 ## 注册表:`ctx.sessionProjections` -`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动权:一份 `session/event` 订阅、对每个已注册单元即时调用 `apply`,以及每会话每单元的水位线(watermark)cell。cell 惰性构建:在事件流过之后才注册的单元,或比注册表更早的会话,都在首次触达(事件或读取)时从 `init` 出发在内存日志上折叠。注册是一个 effect,其 disposer 随调用方 fiber 走:领域插件卸载后,其 key(连同缓存的 cell)从后续驱动与快照中消失,客户端将其读作能力缺失;key 以不同 `stateVersion` 重复时直接 throw,同版本注册方则共享一个单元并被计数。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。 +`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动逻辑:一个 `session/event` 订阅、对所有已注册单元立即执行的 `apply`,以及按会话、按单元保存的水位线缓存。缓存单元惰性构建:若单元在事件产生后才注册,或会话早于注册表存在,第一次访问(事件或读取)会从 `init` 开始折叠内存日志。领域在激活期间等待 `register(definition)`,并保留返回的 handle(句柄)。注册归调用 fiber 所有:最后一次释放会从全局驱动和快照中移除该 key,已有句柄仍可读取。同 key、同版本的注册者共享一个单元;版本不兼容时注册失败。 @@ -204,26 +221,27 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, checkpoints, and client snapshots; retained handles still fold new events on demand using the original unit. ```ts cordis-catalog /** - * Register one domain's unit. The registration is an effect on the calling - * context's fiber: disposing the fiber (or calling the returned disposer) - * removes the key — and the unit's cached cells — from subsequent drives - * and snapshots. + * Register one domain's unit on the calling context's fiber. Fiber or handle + * disposal releases one reference; the final release removes live participation. + * Retained handles remain readable after release. + * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns the exact disposer that unregisters this unit. + * @returns a ready handle with synchronous state reads and the effect disposer. */ -register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, ): () => void +register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, ): Promise> /** * Register one host-only unit. Its state is omitted from client snapshots * and always checkpointed like every other unit. + * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns the exact disposer that unregisters this unit. + * @returns a ready handle with synchronous state reads and the effect disposer. */ -register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'>, ): () => void +register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'>, ): Promise> /** * Subscribe to the change feed. The registration is an effect on the @@ -233,16 +251,6 @@ register< K extends Exclude void -/** - * Read one unit's current host state after materializing every registered - * unit at the Session cursor. Unrelated wire views are not produced. - * The returned value is live; callers must not mutate it. - * @param session - the session whose state is read. - * @param key - the registered unit key. - * @returns current state, or `undefined` when the key is not registered. - */ -stateOf( session: Session, key: K, ): SessionProjectionStateMap[K] | undefined - /** * One consistent cut over every registered client-visible unit for one session, read from * the watermark cache (missing cells fold lazily over the in-memory log). diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.i18n.yaml new file mode 100644 index 0000000000..6d6f45fd15 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.md +/: + en: 2ca1b8bc4d168c3f + zh: 2257728847687363 +/compose-session-projections-for-claude-code-mods: + en: 58c229d213b33d7b + zh: d6d539f921e59af2 +/compose-session-projections-for-claude-code-mods/change: + en: 3a76b4c79776f847 + zh: e9c7d258a2b76611 +/compose-session-projections-for-claude-code-mods/migration: + en: 6b9ecb1a635ccb44 + zh: 978dad0bdcd69d57 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.md new file mode 100644 index 0000000000..3c97f66652 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "The Claude Code mods bridge requires the session projection registry at initialization." +--- + +# Compose session projections for Claude Code mods + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-experimental-claude-code-mods` requires `sessionProjections` to initialize. Custom compositions that omit this service no longer start the bridge. The bridge registers the turn and context-pressure projections before serving mods. + +## Migration + +1. Add `@deepseek-ai/dsh-session-projection` to the `cordis.yml` or profile overlay that composes the bridge, unless it is already composed. +2. Start the profile and confirm the bridge and its mod plugins load successfully. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.zh.md new file mode 100644 index 0000000000..753d5ca266 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.zh.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "Claude Code 模组桥接在初始化时要求会话投影注册表。" +--- + +# 为 Claude Code 模组组合会话投影 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-experimental-claude-code-mods` 需要 `sessionProjections` 才能初始化。未提供此服务的自定义组合不再启动桥接。桥接在服务模组前注册轮次和上下文压力投影。 + +## 迁移 + +1. 在组合桥接的 `cordis.yml` 或配置覆盖文件中添加 `@deepseek-ai/dsh-session-projection`;已组合此服务时无需重复添加。 +2. 启动配置,确认桥接及其模组插件成功加载。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.i18n.yaml index c1938c0f53..9407c83aff 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.i18n.yaml @@ -9,8 +9,8 @@ en: fcf5f06084f989d9 zh: 5e3e6a05786390dd /migrate-delegation-to-managed-subagent-activations/change: - en: 17add29d9f80674e - zh: 4d4050e99f019cca + en: e56eb4dee1cf246a + zh: f5c5c62c815c140d /migrate-delegation-to-managed-subagent-activations/migration: - en: d4269b2c58484a3d - zh: 0ae39476ecd330e6 + en: e8455cb8d6ecf66b + zh: 1b48e06d6aac7d9f diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.md index 5a88ff2ee3..1ef7d07119 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.md +++ b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.md @@ -11,7 +11,9 @@ English | [中文](guide.zh.md) `@deepseek-ai/dsh-tool-subagent` removes `backgroundMode`, `enableRunInBackground`, and the model argument `run_in_background`. Every delegation now returns `{ kind: 'activation', subagentId }` after accepting the child, and the runtime delivers its final answer in a parent completion notice. Foreground results and `{ kind: 'background', jobId }` results are removed; subagents are no longer collected or stopped through `job_output` or `job_kill`. -The published `@deepseek-ai/dsh-subagent-in-process-driver` package is removed. Local providers contribute creation inputs through `prepareContinuable()`; the subagent service owns execution. Spawn and Fork children can receive later messages, including after settlement when persistence and Session query are available. External providers execute once and do not accept follow-up messages. +`@deepseek-ai/dsh-subagent-in-process-driver` is removed. Local providers contribute creation inputs through `prepareContinuable()`; the subagent service owns execution. Spawn and Fork children can receive later messages, including after settlement when persistence and Session query are available. External providers execute once and do not accept follow-up messages. + +Subagent initialization requires `@deepseek-ai/dsh-session-projection`. ## Migration @@ -24,7 +26,7 @@ The published `@deepseek-ai/dsh-subagent-in-process-driver` package is removed. toolName: subagent ``` - Local tool delegation requires Session persistence. Compose it with the [subagent service and provider](../../../../packages/subagent/subagent/README.md#use-this-package). + Mount `@deepseek-ai/dsh-session-projection`. Local tool delegation requires Session persistence. Compose it with the [subagent service and provider](../../../../packages/subagent/subagent/README.md#use-this-package). 2. Remove `run_in_background` from tool calls and update result consumers to read `subagentId`. Collect the completion notice instead of foreground output or a Job result. Use `interrupt_agent` to stop the current execution; use `send_message` only for local children when those control tools are composed. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.zh.md index aa83610496..1f2e80d7cf 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.zh.md @@ -11,7 +11,9 @@ description: "受管理的子 agent activation 替代前台与 Job 后台委派 `@deepseek-ai/dsh-tool-subagent` 移除了 `backgroundMode`、`enableRunInBackground` 和模型参数 `run_in_background`。每次委派现在都会在接纳子 agent(智能体)后返回 `{ kind: 'activation', subagentId }`,运行时通过父级完成通知交付最终答案。前台结果和 `{ kind: 'background', jobId }` 结果已移除;子 agent 不再通过 `job_output` 或 `job_kill` 收集结果或停止。 -已发布的 `@deepseek-ai/dsh-subagent-in-process-driver` 包被移除。本地提供方通过 `prepareContinuable()` 提供创建输入,由子 agent 服务拥有执行过程。Spawn 和 Fork 子级可以接收后续消息;组合了持久化和 Session 查询服务时,结算后也能接收。外部提供方只执行一次,不接受后续消息。 +`@deepseek-ai/dsh-subagent-in-process-driver` 被移除。本地提供方通过 `prepareContinuable()` 提供创建输入,由子 agent 服务拥有执行过程。Spawn 和 Fork 子级可以接收后续消息;组合了持久化和 Session 查询服务时,结算后也能接收。外部提供方只执行一次,不接受后续消息。 + +子 agent 初始化需要 `@deepseek-ai/dsh-session-projection`。 ## 迁移 @@ -24,7 +26,7 @@ description: "受管理的子 agent activation 替代前台与 Job 后台委派 toolName: subagent ``` - 本地工具委派需要 Session 持久化。将其与[子 agent 服务及提供方](../../../../packages/subagent/subagent/README.zh.md#use-this-package)一起组合。 + 挂载 `@deepseek-ai/dsh-session-projection`。本地工具委派需要 Session 持久化。将其与[子 agent 服务及提供方](../../../../packages/subagent/subagent/README.zh.md#use-this-package)一起组合。 2. 从工具调用中删除 `run_in_background`,并将结果消费方改为读取 `subagentId`。收集完成通知,替代前台输出或 Job 结果。组合了相应控制工具时,使用 `interrupt_agent` 停止当前执行;`send_message` 仅用于本地子级。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.i18n.yaml new file mode 100644 index 0000000000..7d864c5be2 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md +/: + en: b3f54a7eadb3398a + zh: 6815b996b2cebc6f +/session-projection-registration-handles: + en: 727968e582c6eee6 + zh: e01dc83384747012 +/session-projection-registration-handles/change: + en: 06c3d4ee9031fb7e + zh: cf2b664bfdafe837 +/session-projection-registration-handles/migration: + en: 0619d4d16272fa0c + zh: c3b0bb2c9d3ecb57 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md new file mode 100644 index 0000000000..f746d9170d --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Session projection registration is asynchronous and returns a retained reader instead of a disposer." +--- +# Session projection registration handles + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-session-projection` changes `SessionProjectionRegistry.register(definition)` from a synchronous disposer to `Promise>`. Registry-level `stateOf(session, key)` is removed. Registration errors now reject the returned promise. After the last registration is disposed, the unit leaves snapshots, checkpoints, and live notifications, but retained handles can still fold Session events on demand. + +`@deepseek-ai/dsh-subagent` now requires `sessionProjections`. `@deepseek-ai/dsh-api-session-controller` removes `installModelSelectionProjection`; consumers register `modelSelectionProjection` instead. The controller also registers `agentPreset`, whose null value means the Session has no recorded preset; that key does not establish that a preset registry is available. `turnBoundaryProjectionDefinition` is exported by `@deepseek-ai/dsh-agent`; its `agent-loop` re-export remains available. + +## Migration + +1. Declare `sessionProjections` as a required plugin injection and mount `SessionProjectionRegistry` before consumers, including `SubagentRuntime` in custom compositions. +2. In an asynchronous function-plugin `apply()` or service `[Service.init]()`, await `ctx.sessionProjections.register(definition)` and retain the returned handle before publishing dependent operations. Keep existing function-plugin namespace exports. +3. Replace `registry.stateOf(session, key)` with `handle.stateOf(session)`. Import the owning package's projection definition when a reader needs to acquire a handle itself. Replace stored unregister callbacks with `handle.dispose`; fiber disposal still releases registrations automatically. Await registration when handling initialization failures. +4. Replace `installModelSelectionProjection(ctx)` with `await ctx.sessionProjections.register(modelSelectionProjection)`, importing the definition from `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts`. +5. Typecheck custom plugins, then verify activation, a Session state read, and owner disposal. A released reader remains usable, while the released unit disappears from new snapshots once its final registration is gone. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.zh.md new file mode 100644 index 0000000000..d6a19c5631 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Session 投影注册改为异步,并返回保留读取能力的句柄而不是释放函数。" +--- +# Session 投影注册句柄 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-session-projection` 的 `SessionProjectionRegistry.register(definition)` 从同步释放函数改为返回 `Promise>`。注册表级 `stateOf(session, key)` 已移除,注册错误现在使返回的 Promise 拒绝。最后一次注册释放后,该投影单元不再参与快照、检查点和实时通知,但保留的句柄仍可按需折叠 Session 事件。 + +`@deepseek-ai/dsh-subagent` 现在必需注入 `sessionProjections`。`@deepseek-ai/dsh-api-session-controller` 移除了 `installModelSelectionProjection`;消费者改为注册 `modelSelectionProjection`。控制器也注册 `agentPreset`,其 null 值表示 Session 未记录预设;该键不代表预设注册表可用。`turnBoundaryProjectionDefinition` 由 `@deepseek-ai/dsh-agent` 导出;`agent-loop` 的再导出仍然可用。 + +## 迁移 + +1. 将 `sessionProjections` 声明为插件的必需注入,并在消费者之前挂载 `SessionProjectionRegistry`,包括自定义组合中的 `SubagentRuntime`。 +2. 在函数插件的异步 `apply()` 或服务的 `[Service.init]()` 中,等待 `ctx.sessionProjections.register(definition)`,在发布依赖操作前保留返回的句柄。保留既有的函数插件命名空间导出。 +3. 将 `registry.stateOf(session, key)` 替换为 `handle.stateOf(session)`。读取方需要自行取得句柄时,导入归属包的投影定义。将保存的注销回调替换为 `handle.dispose`;fiber 释放仍会自动解除注册。处理初始化错误时须等待注册完成。 +4. 将 `installModelSelectionProjection(ctx)` 替换为 `await ctx.sessionProjections.register(modelSelectionProjection)`,从 `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts` 导入该定义。 +5. 对自定义插件执行类型检查,再验证激活、Session 状态读取与 owner 释放。已释放的读取句柄仍可使用,而投影单元在最后一次注册解除后会从新快照中消失。 diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 7da873bea6..d2ded420f8 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2204,19 +2204,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'sessionProjections', summary: '`ctx.sessionProjections`: the projection unit table and its drive.', - description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', + description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, checkpoints, and client snapshots; retained handles still fold new events on demand using the original unit.', methods: [ { - signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): () => void', - description: 'Register one domain\'s unit. The registration is an effect on the calling context\'s fiber: disposing the fiber (or calling the returned disposer) removes the key — and the unit\'s cached cells — from subsequent drives and snapshots.', + signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): Promise>', + description: 'Register one domain\'s unit on the calling context\'s fiber. Fiber or handle disposal releases one reference; the final release removes live participation. Retained handles remain readable after release. Callers must await registration before publishing consumers; registration may perform asynchronous work.', parameters: [{ name: 'definition', description: 'key, state schema, pure unit functions, and stateVersion.' }], - returns: 'the exact disposer that unregisters this unit.', + returns: 'a ready handle with synchronous state reads and the effect disposer.', }, { - signature: 'register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'>, ): () => void', - description: 'Register one host-only unit. Its state is omitted from client snapshots and always checkpointed like every other unit.', + signature: 'register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'>, ): Promise>', + description: 'Register one host-only unit. Its state is omitted from client snapshots and always checkpointed like every other unit. Callers must await registration before publishing consumers; registration may perform asynchronous work.', parameters: [{ name: 'definition', description: 'key, state schema, pure unit functions, and stateVersion.' }], - returns: 'the exact disposer that unregisters this unit.', + returns: 'a ready handle with synchronous state reads and the effect disposer.', }, { signature: 'onChanged(listener: ProjectionChangeListener): () => void', @@ -2224,12 +2224,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'listener', description: 'called once per client-visible unit whose raw view changed by `Object.is`, per committed event.' }], returns: 'the exact disposer that unsubscribes.', }, - { - signature: 'stateOf( session: Session, key: K, ): SessionProjectionStateMap[K] | undefined', - description: 'Read one unit\'s current host state after materializing every registered unit at the Session cursor. Unrelated wire views are not produced. The returned value is live; callers must not mutate it.', - parameters: [{ name: 'session', description: 'the session whose state is read.' }, { name: 'key', description: 'the registered unit key.' }], - returns: 'current state, or `undefined` when the key is not registered.', - }, { signature: 'snapshot( session: Session, keys?: readonly Extract[], ): ProjectionSnapshot', description: 'One consistent cut over every registered client-visible unit for one session, read from the watermark cache (missing cells fold lazily over the in-memory log). Fully synchronous — every value and `asOfSeq` reflect the same log position. Each value passes its unit\'s `viewSchema` before leaving.', @@ -6336,6 +6330,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ProjectionDefinition', declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(header: SessionHeader, inheritedEventCount: SessionLogOffset): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType;\n view(state: NoInfer): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}', }, + { + name: 'ProjectionHandle', + declaration: 'export interface ProjectionHandle {\n stateOf(session: Session): S;\n dispose(): void | Promise;\n}', + }, { name: 'ProjectionSnapshot', declaration: 'export interface ProjectionSnapshot {\n asOfSeq: SessionSeqCursor;\n values: Partial;\n}', diff --git a/scripts/gen-config-catalog.spec.ts b/scripts/gen-config-catalog.spec.ts index 7248df7b98..4aed5f9782 100644 --- a/scripts/gen-config-catalog.spec.ts +++ b/scripts/gen-config-catalog.spec.ts @@ -97,6 +97,31 @@ export function apply(ctx: unknown, config: Config): void {} expect(new Set(provider?.schemaKeys)).toEqual(new Set(['mode', 'headless', 'endpoint'])) }) + it.each([false, true])('checks a class Config imported from a sibling module (invalid field: %s)', (invalid) => { + const { root, write } = fixture() + write('packages/test/provider/src/config.ts', ` +import Schema from '@deepseek-ai/schemastery' +export interface Config { + /** Maximum result count. */ + limit: number +} +export const Config = Schema.object({ ${invalid ? 'hidden' : 'limit'}: Schema.number() }) +`) + write('packages/test/provider/src/index.ts', ` +import { Config } from './config.ts' +export default class Provider { + static Config = Config + constructor(ctx: unknown, config: Config) {} +} +`) + if (invalid) { + expect(() => collectConfigCatalog(root)).toThrow("schema validates key 'hidden' but config type 'Config' declares no such member") + } else { + const provider = collectConfigCatalog(root).find(entry => entry.pkg === '@test/provider') + expect(provider?.schemaKeys).toEqual(['limit']) + } + }) + it('rejects type-only imports used as runtime schemas', () => { const { root, write } = fixture() write('packages/test/provider/src/index.ts', ` diff --git a/scripts/gen-config-catalog.ts b/scripts/gen-config-catalog.ts index 60a31c9fdb..df0ac078d4 100644 --- a/scripts/gen-config-catalog.ts +++ b/scripts/gen-config-catalog.ts @@ -504,7 +504,7 @@ function unwrapExpr(expr: ts.Expression): ts.Expression { * packages whose schemas an intersect composes. A key path is the top-level * key or a nested path through object/array compositions (`agents[].id`). * Handles object/union calls, chained refinements, named const schema imports - * through public workspace source paths, and `z.intersect([X.Config, …])`; errors on + * through package-relative or public workspace source paths, and `z.intersect([X.Config, …])`; errors on * anything else, so a schema the walk cannot see fails the gate instead of * silently thinning it. Nested values that are neither `object` nor `array` * compositions (primitives, unions, dynamic-key dicts) contribute no paths. diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 89b1432bc4..0113d16160 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -805,6 +805,7 @@ export const LINK_MAP: Readonly> = { WorkflowRunInfo: 'workflow.md', WorkflowStartRequest: 'workflow.md', ProjectionDefinition: 'session-projection.md', + ProjectionHandle: 'session-projection.md', SessionProjectionMap: 'session-projection.md', SessionProjectionStateMap: 'session-projection.md', ProjectionChangeListener: 'session-projection.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 37f21e08c1..4218ec3c8c 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2116,6 +2116,11 @@ "symbol": "PresetSpec", "source": "packages/interaction/permission-presets/src/index.ts" }, + { + "doc": "docs/subsystems/session-projection.md", + "symbol": "ProjectionHandle", + "source": "packages/session/session-projection/src/index.ts" + }, { "doc": "docs/subsystems/session-projection.md", "symbol": "ProjectionDefinition", From 99f36601205c48817eb86fa2b684d7bd4a076c31 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 13:08:14 +0800 Subject: [PATCH 05/37] refactor(hooks-codex): simplify hook payload construction --- .../experimental/hooks-codex/src/index.ts | 64 +++++++++++-------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/packages/experimental/hooks-codex/src/index.ts b/packages/experimental/hooks-codex/src/index.ts index 6159a90dda..356e64393d 100644 --- a/packages/experimental/hooks-codex/src/index.ts +++ b/packages/experimental/hooks-codex/src/index.ts @@ -28,7 +28,7 @@ declare module '@deepseek-ai/dsh-llm' { import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' -import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import type { PostToolDecision, PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' import { appendHookInvoked, appendHookResult, @@ -113,6 +113,10 @@ export async function apply(ctx: Context, config: Config): Promise { const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) const model = config.model ?? '' + function lastTurn(agent: Agent | undefined): number { + return agent === undefined ? 0 : turnBoundary.stateOf(agent.session).lastTurn + } + // SessionStart is the one emit-shaped (detached) point Codex has: track its // run chains so disposal aborts a still-running hook process and drains the // continuation (docs/defensive-patterns/general.md: dispose must reach quiescence). @@ -235,7 +239,10 @@ export async function apply(ctx: Context, config: Config): Promise { // PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored). ctx.on('tools/pre-execute', async (exec, next): Promise => { const turn = lastTurn(exec.agent) - const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) + const payload = toolPayload(exec, 'PreToolUse', model, turn) + const merged = await runPoint('PreToolUse', exec.name, payload, { + ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal, + }) if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } return next() }) @@ -243,8 +250,14 @@ export async function apply(ctx: Context, config: Config): Promise { // PostToolUse → PostToolDecision (block with feedback, or attach context). ctx.on('tools/post-execute', async (exec, result, next): Promise => { const turn = lastTurn(exec.agent) + const payload = { + ...toolPayload(exec, 'PostToolUse', model, turn), + tool_response: blocksToText(result.content), + } /* jscpd:ignore-start */ - const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) + const merged = await runPoint('PostToolUse', exec.name, payload, { + ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal, + }) const context = contextFrom(merged) if (merged.decision === 'deny') { return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} } @@ -268,7 +281,13 @@ export async function apply(ctx: Context, config: Config): Promise { // avoid continuing the same turn indefinitely. It is always false here, so an // unconditionally blocking hook force-continues every step until it self-limits. ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise => { - const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn, signal }) + const payload = { + ...base(agent, 'Stop', model), + turn_id: String(lastTurn(agent)), + stop_hook_active: false, + last_assistant_message: null, + } + const merged = await runPoint('Stop', '', payload, { agent, turn, signal }) if (merged.decision === 'deny') { // A blocking Stop hook forces continuation; a block with no reason (exit 2, // empty stderr) still forces it — fall back to a generic steering line @@ -277,27 +296,6 @@ export async function apply(ctx: Context, config: Config): Promise { agent.steer(createUserMessage({ content: [{ type: 'text', text }], source: CONTEXT_SOURCE })) } }) - - function lastTurn(agent: Agent | undefined): number { - if (!agent) return 0 - return turnBoundary.stateOf(agent.session).lastTurn - } - - function turnBase(agent: Agent | undefined, event: string, model: string): Record { - return { ...base(agent, event, model), turn_id: String(lastTurn(agent)) } - } - - function preToolPayload(exec: ToolExecution, model: string): Record { - // `tool_name` is the REAL tool name (matching the `exec.name` matcher subject); - // a hardcoded constant would disagree with what the matcher tests and make a - // config's tool matcher never fire. `tool_input` keeps Codex's `{ command }` - // shape (its shell payload), derived from the call's `command` arg when present. - return { ...turnBase(exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId } - } - - function postToolPayload(exec: ToolExecution, result: ToolExecutionResult, model: string): Record { - return { ...turnBase(exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) } - } } // --- Codex DIALECT payloads: snake_case, model on every event, turn_id on @@ -325,6 +323,22 @@ function base(agent: Agent | undefined, event: string, model: string): Record { + return { + ...base(exec.agent, event, model), + turn_id: String(turn), + tool_name: exec.name, + tool_input: { command: commandOf(exec.arguments) }, + tool_use_id: exec.callId, + } +} + /** Extract a `command` string from a tool call's parsed arguments, else ''. */ function commandOf(args: unknown): string { if (typeof args === 'object' && args !== null && 'command' in args) { From 5b401b27a5a7ed2596e6294bbd64603d4d57669d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 13:08:18 +0800 Subject: [PATCH 06/37] refactor(tool-session-query): pass projection handles to operations --- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../tool-session-query/src/index.ts | 16 +- .../tool-session-query/src/operations.ts | 420 ++++++++---------- .../src/workspace-access.ts | 19 + 5 files changed, 215 insertions(+), 244 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index eb2a6a1ea2..8460cc7395 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3921,7 +3921,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-session-query` - `inject`: `tools` · `systemPrompt` · `sessionQuery` · `sessionProjections` -- `source`: [`packages/experimental/tool-session-query/src/index.ts:26`](../packages/experimental/tool-session-query/src/index.ts) +- `source`: [`packages/experimental/tool-session-query/src/index.ts:27`](../packages/experimental/tool-session-query/src/index.ts) ```ts config-catalog /** Deployment-owned search count and timeout bounds. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index aacdae8e58..ffd0313631 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3923,7 +3923,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-session-query` - `inject`: `tools` · `systemPrompt` · `sessionQuery` · `sessionProjections` -- `source`: [`packages/experimental/tool-session-query/src/index.ts:26`](../packages/experimental/tool-session-query/src/index.ts) +- `source`: [`packages/experimental/tool-session-query/src/index.ts:27`](../packages/experimental/tool-session-query/src/index.ts) ```ts config-catalog /** Deployment-owned search count and timeout bounds. */ diff --git a/packages/experimental/tool-session-query/src/index.ts b/packages/experimental/tool-session-query/src/index.ts index 8bbf6c3bff..2337b43402 100644 --- a/packages/experimental/tool-session-query/src/index.ts +++ b/packages/experimental/tool-session-query/src/index.ts @@ -5,11 +5,12 @@ */ import type { Context } from '@deepseek-ai/cordis' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' import { toolInput } from './input.ts' -import { SessionQueryOperations } from './operations.ts' +import { operations } from './operations.ts' import { presentation } from './presentation.ts' export const name = 'tool-session-query' @@ -58,8 +59,7 @@ const PROMPT_TEXT = * @returns when the tools and their projection reader are ready. */ export async function apply(ctx: Context, config: Config): Promise { - const operations = new SessionQueryOperations(ctx) - await operations.initialize() + const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) const resolved = resolveConfig(config) ctx.systemPrompt.section({ name: 'tool:session-query', @@ -73,7 +73,7 @@ export async function apply(ctx: Context, config: Config): Promise { parameters: toolInput.sessionSearchParameters, output: TEXT_OUTPUT, timeoutMs: resolved.searchTimeoutMs, - execute: (args, exec) => operations.executeSessionSearch(args, exec, resolved.maxSearchResults), + execute: (args, exec) => operations.executeSessionSearch(ctx, turnBoundary, args, exec, resolved.maxSearchResults), presentCall: presentation.presentSessionSearchCall, })) @@ -83,7 +83,7 @@ export async function apply(ctx: Context, config: Config): Promise { parameters: toolInput.eventSearchParameters, output: TEXT_OUTPUT, timeoutMs: resolved.searchTimeoutMs, - execute: (args, exec) => operations.executeEventSearch(args, exec, resolved.maxSearchResults), + execute: (args, exec) => operations.executeEventSearch(ctx, turnBoundary, args, exec, resolved.maxSearchResults), presentCall: presentation.presentEventSearchCall, })) @@ -93,7 +93,7 @@ export async function apply(ctx: Context, config: Config): Promise { parameters: toolInput.targetSessionParameter, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => operations.executeSessionTrace(args, exec), + execute: (args, exec) => operations.executeSessionTrace(ctx, turnBoundary, args, exec), presentCall: presentation.presentSessionTraceCall, })) @@ -106,7 +106,7 @@ export async function apply(ctx: Context, config: Config): Promise { }, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => operations.executeEventTrace(args, exec), + execute: (args, exec) => operations.executeEventTrace(ctx, turnBoundary, args, exec), presentCall: args => presentation.presentEventTargetCall('Trace event', args), })) @@ -121,7 +121,7 @@ export async function apply(ctx: Context, config: Config): Promise { }, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => operations.executeEventRead(args, exec), + execute: (args, exec) => operations.executeEventRead(ctx, turnBoundary, args, exec), presentCall: args => presentation.presentEventTargetCall('Read event', args), })) } diff --git a/packages/experimental/tool-session-query/src/operations.ts b/packages/experimental/tool-session-query/src/operations.ts index 4c05480221..2727447537 100644 --- a/packages/experimental/tool-session-query/src/operations.ts +++ b/packages/experimental/tool-session-query/src/operations.ts @@ -4,9 +4,9 @@ * @module @deepseek-ai/dsh-tool-session-query/operations */ -import { turnBoundaryProjectionDefinition, type TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' -import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { Context } from '@deepseek-ai/cordis' +import type { TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import { HarnessError } from '@deepseek-ai/dsh-llm' import { SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -54,249 +54,192 @@ interface SearchCollection { readonly capped: boolean } -/** Session-history tool operations over the caller’s current turn state. */ -export class SessionQueryOperations { - private turnBoundary!: ProjectionHandle - - constructor(private readonly ctx: Context) {} - - /** - * Acquire projection readers before exposing dependent operations. - * @returns when all required registrations are ready. - */ - async initialize(): Promise { - this.turnBoundary = await this.ctx.sessionProjections.register(turnBoundaryProjectionDefinition) +async function executeSessionSearch( + ctx: Context, + turnBoundary: ProjectionHandle, + args: SessionSearchArgs, + exec: ToolRunContext, + maxResults: number, +): Promise { + const caller = workspaceAccess.callerOf(exec, turnBoundary) + const cwd = caller.header.cwd + if (cwd === undefined) { + throw new HarnessError( + 'cross-session search is unavailable because the caller session has no workspace', + 'SESSION_QUERY_TOOL_UNAUTHORIZED', + ) } - - private callerOf(exec: ToolRunContext) { - const agent = exec.agent - if (agent === undefined) { - throw new HarnessError( - 'session query tools require an agent-bound caller', - 'SESSION_QUERY_TOOL_MISSING_AGENT', - ) - } - return { - id: agent.session.id, - header: agent.session.header, - boundary: this.turnBoundary.stateOf(agent.session), - } + const query = toolInput.normalizeQuery(args.query) + const sessionFilters = toolInput.buildSessionFilters(args) + const eventFilters = toolInput.buildEventFilters({ + seqFrom: args.event_seq_from, + seqTo: args.event_seq_to, + timeFrom: args.event_time_from, + timeTo: args.event_time_to, + eventTypes: args.event_types, + surfaces: args.event_surfaces, + }) + const requestedParentIds = toolInput.materializeParentSessionIds(args.parent_session_ids) + if (requestedParentIds !== undefined || args.include_root_sessions === true) { + const authorizedParentIds = requestedParentIds === undefined + ? new Set() + : await workspaceAccess.authorizeSessionIds(ctx, caller, requestedParentIds, exec.signal) + const parentValues: Array = requestedParentIds + ?.filter(id => authorizedParentIds.has(id)) ?? [] + if (args.include_root_sessions === true) parentValues.push(null) + if (parentValues.length === 0) return presentation.formatEmptySessionSearch() + sessionFilters.push({ kind: 'parent', values: parentValues }) } + sessionFilters.push({ kind: 'cwd', values: [cwd] }) + const collected = await collectPages( + maxResults, + exec.signal, + cursor => serviceBoundary.call(ctx, exec.signal, 'session search', () => + ctx.sessionQuery.searchSessions({ + query, + sessionFilters, + eventFilters, + ...cursor === undefined ? {} : { cursor }, + }, { signal: exec.signal })), + hit => hit.header.id !== caller.id && workspaceAccess.recordAuthorized(hit, caller), + ) - /** - * Search other Sessions within the caller’s authorized workspace. - * @param args - Session and event filters. - * @param exec - Agent-bound caller and cancellation signal. - * @param maxResults - Maximum number of authorized matches to return. - * @returns Formatted authorized Session matches and truncation status. - */ - async executeSessionSearch( - args: SessionSearchArgs, - exec: ToolRunContext, - maxResults: number, - ): Promise { - const ctx = this.ctx - const caller = this.callerOf(exec) - const cwd = caller.header.cwd - if (cwd === undefined) { - throw new HarnessError( - 'cross-session search is unavailable because the caller session has no workspace', - 'SESSION_QUERY_TOOL_UNAUTHORIZED', - ) - } - const query = toolInput.normalizeQuery(args.query) - const sessionFilters = toolInput.buildSessionFilters(args) - const eventFilters = toolInput.buildEventFilters({ - seqFrom: args.event_seq_from, - seqTo: args.event_seq_to, - timeFrom: args.event_time_from, - timeTo: args.event_time_to, - eventTypes: args.event_types, - surfaces: args.event_surfaces, - }) - const requestedParentIds = toolInput.materializeParentSessionIds(args.parent_session_ids) - if (requestedParentIds !== undefined || args.include_root_sessions === true) { - const authorizedParentIds = requestedParentIds === undefined - ? new Set() - : await workspaceAccess.authorizeSessionIds(ctx, caller, requestedParentIds, exec.signal) - const parentValues: Array = requestedParentIds - ?.filter(id => authorizedParentIds.has(id)) ?? [] - if (args.include_root_sessions === true) parentValues.push(null) - if (parentValues.length === 0) return presentation.formatEmptySessionSearch() - sessionFilters.push({ kind: 'parent', values: parentValues }) - } - sessionFilters.push({ kind: 'cwd', values: [cwd] }) - const collected = await collectPages( - maxResults, - exec.signal, - cursor => serviceBoundary.call(ctx, exec.signal, 'session search', () => - ctx.sessionQuery.searchSessions({ + const parentIds = collected.items + .map(hit => hit.header.parentSession) + .filter((id): id is SessionId => id !== undefined) + const authorizedParents = await workspaceAccess.authorizeSessionIds(ctx, caller, parentIds, exec.signal) + const titles = await workspaceAccess.readTitles( + ctx, + caller, + collected.items.map(hit => hit.header.id), + exec.signal, + ) + return presentation.formatSessionSearch(collected, titles, authorizedParents) +} + +async function executeEventSearch( + ctx: Context, + turnBoundary: ProjectionHandle, + args: EventSearchArgs, + exec: ToolRunContext, + maxResults: number, +): Promise { + const caller = workspaceAccess.callerOf(exec, turnBoundary) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const query = toolInput.normalizeQuery(args.query) + const range = toolInput.sequenceRange(args.seq_from, args.seq_to) + if (sessionId === caller.id) { + const stepStartSeq = caller.boundary.lastStepStartSeq + // `null` (no step started yet) caps the range to a degenerate `to` the + // filter validation rejects — the loop never runs tools outside a step. + range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, (stepStartSeq ?? 0) - 1) + } + const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) + if (range.from !== undefined && range.to !== undefined && range.from > range.to) { + return presentation.formatEventSearch(sessionId, title, { items: [], capped: false }) + } + const filters = toolInput.buildEventFilters({ + seqFrom: range.from, + seqTo: range.to, + timeFrom: args.time_from, + timeTo: args.time_to, + eventTypes: args.event_types, + surfaces: args.surfaces, + }) + const collected = await collectPages( + maxResults, + exec.signal, + async (cursor): Promise => { + const page = await serviceBoundary.call(ctx, exec.signal, 'event search', () => + ctx.sessionQuery.searchEvents({ + sessionId, query, - sessionFilters, - eventFilters, + filters, ...cursor === undefined ? {} : { cursor }, - }, { signal: exec.signal })), - hit => hit.header.id !== caller.id && workspaceAccess.recordAuthorized(hit, caller), - ) + }, { signal: exec.signal })) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, page.session) + return page + }, + () => true, + ) + return presentation.formatEventSearch(sessionId, title, collected) +} - const parentIds = collected.items - .map(hit => hit.header.parentSession) - .filter((id): id is SessionId => id !== undefined) - const authorizedParents = await workspaceAccess.authorizeSessionIds(ctx, caller, parentIds, exec.signal) - const titles = await workspaceAccess.readTitles( - ctx, - caller, - collected.items.map(hit => hit.header.id), - exec.signal, - ) - return presentation.formatSessionSearch(collected, titles, authorizedParents) - } +async function executeSessionTrace( + ctx: Context, + turnBoundary: ProjectionHandle, + args: SessionTargetArgs, + exec: ToolRunContext, +): Promise { + const caller = workspaceAccess.callerOf(exec, turnBoundary) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const trace = await serviceBoundary.call(ctx, exec.signal, 'session lineage trace', () => + ctx.sessionQuery.traceSession(sessionId, exec.signal)) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.target.header) - /** - * Search authorized Session events, excluding the caller’s current step. - * @param args - Target Session and event filters. - * @param exec - Agent-bound caller and cancellation signal. - * @param maxResults - Maximum number of authorized matches to return. - * @returns Formatted matching events and truncation status. - */ - async executeEventSearch( - args: EventSearchArgs, - exec: ToolRunContext, - maxResults: number, - ): Promise { - const ctx = this.ctx - const caller = this.callerOf(exec) - const sessionId = workspaceAccess.targetId(args, caller) - await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) - const query = toolInput.normalizeQuery(args.query) - const range = toolInput.sequenceRange(args.seq_from, args.seq_to) - if (sessionId === caller.id) { - const stepStartSeq = caller.boundary.lastStepStartSeq - // `null` (no step started yet) caps the range to a degenerate `to` the - // filter validation rejects — the loop never runs tools outside a step. - range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, (stepStartSeq ?? 0) - 1) + const ancestors: SessionRecord[] = [] + let ancestorBoundary = false + for (const ancestor of trace.ancestors) { + if (!workspaceAccess.recordAuthorized(ancestor, caller)) { + ancestorBoundary = true + break } - const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) - if (range.from !== undefined && range.to !== undefined && range.from > range.to) { - return presentation.formatEventSearch(sessionId, title, { items: [], capped: false }) - } - const filters = toolInput.buildEventFilters({ - seqFrom: range.from, - seqTo: range.to, - timeFrom: args.time_from, - timeTo: args.time_to, - eventTypes: args.event_types, - surfaces: args.surfaces, - }) - const collected = await collectPages( - maxResults, - exec.signal, - async (cursor): Promise => { - const page = await serviceBoundary.call(ctx, exec.signal, 'event search', () => - ctx.sessionQuery.searchEvents({ - sessionId, - query, - filters, - ...cursor === undefined ? {} : { cursor }, - }, { signal: exec.signal })) - workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, page.session) - return page - }, - () => true, - ) - return presentation.formatEventSearch(sessionId, title, collected) + ancestors.push(ancestor) } + if (ancestors.length === trace.ancestors.length && !trace.complete) ancestorBoundary = true + const descendants = workspaceAccess.authorizeDescendants(trace.descendants, caller) + const visibleIds = [ + trace.target.header.id, + ...ancestors.map(record => record.header.id), + ...workspaceAccess.descendantIds(descendants), + ] + const titles = await workspaceAccess.readTitles(ctx, caller, visibleIds, exec.signal) + return presentation.formatSessionTrace(trace, ancestors, ancestorBoundary, descendants, titles) +} - /** - * Trace Session lineage within the caller’s workspace permissions. - * @param args - Optional target Session id; defaults to the caller. - * @param exec - Agent-bound caller and cancellation signal. - * @returns Formatted authorized ancestors and descendants. - */ - async executeSessionTrace( - args: SessionTargetArgs, - exec: ToolRunContext, - ): Promise { - const ctx = this.ctx - const caller = this.callerOf(exec) - const sessionId = workspaceAccess.targetId(args, caller) - await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) - const trace = await serviceBoundary.call(ctx, exec.signal, 'session lineage trace', () => - ctx.sessionQuery.traceSession(sessionId, exec.signal)) - workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.target.header) +async function executeEventTrace( + ctx: Context, + turnBoundary: ProjectionHandle, + args: EventTargetArgs, + exec: ToolRunContext, +): Promise { + toolInput.assertNonNegativeSafeInteger('seq', args.seq) + const seq = SessionSeq(args.seq) + const caller = workspaceAccess.callerOf(exec, turnBoundary) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const trace = await serviceBoundary.call(ctx, exec.signal, 'event trace', () => + ctx.sessionQuery.traceEvent({ sessionId, seq }, exec.signal)) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.session) + const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) + return presentation.formatEventTrace(sessionId, title, trace) +} - const ancestors: SessionRecord[] = [] - let ancestorBoundary = false - for (const ancestor of trace.ancestors) { - if (!workspaceAccess.recordAuthorized(ancestor, caller)) { - ancestorBoundary = true - break - } - ancestors.push(ancestor) - } - if (ancestors.length === trace.ancestors.length && !trace.complete) ancestorBoundary = true - const descendants = workspaceAccess.authorizeDescendants(trace.descendants, caller) - const visibleIds = [ - trace.target.header.id, - ...ancestors.map(record => record.header.id), - ...workspaceAccess.descendantIds(descendants), - ] - const titles = await workspaceAccess.readTitles(ctx, caller, visibleIds, exec.signal) - return presentation.formatSessionTrace(trace, ancestors, ancestorBoundary, descendants, titles) - } - - /** - * Trace an event after authorizing its owning Session. - * @param args - Target Session and event sequence. - * @param exec - Agent-bound caller and cancellation signal. - * @returns Formatted event lineage. - */ - async executeEventTrace( - args: EventTargetArgs, - exec: ToolRunContext, - ): Promise { - const ctx = this.ctx - toolInput.assertNonNegativeSafeInteger('seq', args.seq) - const seq = SessionSeq(args.seq) - const caller = this.callerOf(exec) - const sessionId = workspaceAccess.targetId(args, caller) - await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) - const trace = await serviceBoundary.call(ctx, exec.signal, 'event trace', () => - ctx.sessionQuery.traceEvent({ sessionId, seq }, exec.signal)) - workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.session) - const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) - return presentation.formatEventTrace(sessionId, title, trace) - } - - /** - * Read an event window after authorizing its owning Session. - * @param args - Target event and surrounding event counts. - * @param exec - Agent-bound caller and cancellation signal. - * @returns Formatted event window. - */ - async executeEventRead( - args: EventReadArgs, - exec: ToolRunContext, - ): Promise { - const ctx = this.ctx - toolInput.assertNonNegativeSafeInteger('seq', args.seq) - const seq = SessionSeq(args.seq) - if (args.before !== undefined) toolInput.assertNonNegativeSafeInteger('before', args.before) - if (args.after !== undefined) toolInput.assertNonNegativeSafeInteger('after', args.after) - const caller = this.callerOf(exec) - const sessionId = workspaceAccess.targetId(args, caller) - await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) - const window = await serviceBoundary.call(ctx, exec.signal, 'event read', () => - ctx.sessionQuery.readEvent({ - sessionId, - seq, - ...args.before === undefined ? {} : { before: args.before }, - ...args.after === undefined ? {} : { after: args.after }, - }, exec.signal)) - workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, window.session) - const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) - return presentation.formatEventRead(sessionId, title, window) - } +async function executeEventRead( + ctx: Context, + turnBoundary: ProjectionHandle, + args: EventReadArgs, + exec: ToolRunContext, +): Promise { + toolInput.assertNonNegativeSafeInteger('seq', args.seq) + const seq = SessionSeq(args.seq) + if (args.before !== undefined) toolInput.assertNonNegativeSafeInteger('before', args.before) + if (args.after !== undefined) toolInput.assertNonNegativeSafeInteger('after', args.after) + const caller = workspaceAccess.callerOf(exec, turnBoundary) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const window = await serviceBoundary.call(ctx, exec.signal, 'event read', () => + ctx.sessionQuery.readEvent({ + sessionId, + seq, + ...args.before === undefined ? {} : { before: args.before }, + ...args.after === undefined ? {} : { after: args.after }, + }, exec.signal)) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, window.session) + const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) + return presentation.formatEventRead(sessionId, title, window) } async function collectPages( @@ -333,3 +276,12 @@ async function collectPages( cursor = page.nextCursor } } + +/** Five model-facing session-query operation implementations. */ +export const operations = { + executeSessionSearch, + executeEventSearch, + executeSessionTrace, + executeEventTrace, + executeEventRead, +} diff --git a/packages/experimental/tool-session-query/src/workspace-access.ts b/packages/experimental/tool-session-query/src/workspace-access.ts index 4f11e64f38..836763cb3e 100644 --- a/packages/experimental/tool-session-query/src/workspace-access.ts +++ b/packages/experimental/tool-session-query/src/workspace-access.ts @@ -6,6 +6,7 @@ import type { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' +import { HarnessError } from '@deepseek-ai/dsh-llm' import { type SessionHeader, type SessionId as SessionIdValue, @@ -15,6 +16,8 @@ import type { SessionLineageNode, SessionRecord, } from '@deepseek-ai/dsh-session-query' +import type { ToolRunContext } from '@deepseek-ai/dsh-tools' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import { serviceBoundary } from './service-boundary.ts' interface Caller { @@ -50,6 +53,21 @@ interface DescendantVisit { readonly next: DescendantVisit | undefined } +function callerOf(exec: ToolRunContext, turnBoundary: ProjectionHandle): Caller { + const agent = exec.agent + if (agent === undefined) { + throw new HarnessError( + 'session query tools require an agent-bound caller', + 'SESSION_QUERY_TOOL_MISSING_AGENT', + ) + } + return { + id: agent.session.id, + header: agent.session.header, + boundary: turnBoundary.stateOf(agent.session), + } +} + function targetId(args: { readonly session_id?: string }, caller: Caller): SessionIdValue { return args.session_id === undefined ? caller.id : brandString(args.session_id) } @@ -224,6 +242,7 @@ function titleText(view: TitleView): string { /** Workspace-scoped caller authorization, title access, and lineage projection. */ export const workspaceAccess = { + callerOf, targetId, authorizeTarget, recordAuthorized, From a9bbe0c220e398cceb036c346c4b93e323f8bce6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 13:08:25 +0800 Subject: [PATCH 07/37] fix(session-projection): always return a promise from handle disposal --- docs/subsystems/session-projection.md | 7 +++++-- docs/subsystems/session-projection.zh.md | 7 +++++-- .../session-projection-handles/guide.i18n.yaml | 4 ++-- .../session-projection-handles/guide.md | 2 +- .../session-projection-handles/guide.zh.md | 2 +- packages/extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/session/session-projection/README.i18n.yaml | 4 ++-- packages/session/session-projection/README.md | 2 +- packages/session/session-projection/README.zh.md | 2 +- packages/session/session-projection/src/index.ts | 11 ++++++++--- .../tests/registration-reader.spec.ts | 7 ++++--- 11 files changed, 31 insertions(+), 19 deletions(-) diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 5da8044825..b7d8fb2bad 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -18,8 +18,11 @@ interface ProjectionHandle { * @returns the projection state at the Session's current cursor. */ stateOf(session: Session): S - /** Unregister from global drives and snapshots without invalidating this reader. */ - dispose(): void | Promise + /** + * Unregister from global drives and snapshots without invalidating this reader. + * @returns a promise that resolves when this registration is released. + */ + dispose(): Promise } ``` diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index c2c952b8fc..224a5dfdb6 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -18,8 +18,11 @@ interface ProjectionHandle { * @returns the projection state at the Session's current cursor. */ stateOf(session: Session): S - /** Unregister from global drives and snapshots without invalidating this reader. */ - dispose(): void | Promise + /** + * Unregister from global drives and snapshots without invalidating this reader. + * @returns a promise that resolves when this registration is released. + */ + dispose(): Promise } ``` diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.i18n.yaml index 7d864c5be2..5d03bdc8c8 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.i18n.yaml @@ -12,5 +12,5 @@ en: 06c3d4ee9031fb7e zh: cf2b664bfdafe837 /session-projection-registration-handles/migration: - en: 0619d4d16272fa0c - zh: c3b0bb2c9d3ecb57 + en: 50cde783ecaa35e8 + zh: 42e5574f58b049d6 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md index f746d9170d..20d11a2b03 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md +++ b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md @@ -16,6 +16,6 @@ English | [中文](guide.zh.md) 1. Declare `sessionProjections` as a required plugin injection and mount `SessionProjectionRegistry` before consumers, including `SubagentRuntime` in custom compositions. 2. In an asynchronous function-plugin `apply()` or service `[Service.init]()`, await `ctx.sessionProjections.register(definition)` and retain the returned handle before publishing dependent operations. Keep existing function-plugin namespace exports. -3. Replace `registry.stateOf(session, key)` with `handle.stateOf(session)`. Import the owning package's projection definition when a reader needs to acquire a handle itself. Replace stored unregister callbacks with `handle.dispose`; fiber disposal still releases registrations automatically. Await registration when handling initialization failures. +3. Replace `registry.stateOf(session, key)` with `handle.stateOf(session)`. Import the owning package's projection definition when a reader needs to acquire a handle itself. Replace stored unregister callbacks with `handle.dispose`, which always returns `Promise`, and await explicit disposal; fiber disposal still releases registrations automatically. Await registration when handling initialization failures. 4. Replace `installModelSelectionProjection(ctx)` with `await ctx.sessionProjections.register(modelSelectionProjection)`, importing the definition from `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts`. 5. Typecheck custom plugins, then verify activation, a Session state read, and owner disposal. A released reader remains usable, while the released unit disappears from new snapshots once its final registration is gone. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.zh.md index d6a19c5631..fd52f33077 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.zh.md @@ -16,6 +16,6 @@ description: "Session 投影注册改为异步,并返回保留读取能力的 1. 将 `sessionProjections` 声明为插件的必需注入,并在消费者之前挂载 `SessionProjectionRegistry`,包括自定义组合中的 `SubagentRuntime`。 2. 在函数插件的异步 `apply()` 或服务的 `[Service.init]()` 中,等待 `ctx.sessionProjections.register(definition)`,在发布依赖操作前保留返回的句柄。保留既有的函数插件命名空间导出。 -3. 将 `registry.stateOf(session, key)` 替换为 `handle.stateOf(session)`。读取方需要自行取得句柄时,导入归属包的投影定义。将保存的注销回调替换为 `handle.dispose`;fiber 释放仍会自动解除注册。处理初始化错误时须等待注册完成。 +3. 将 `registry.stateOf(session, key)` 替换为 `handle.stateOf(session)`。读取方需要自行取得句柄时,导入归属包的投影定义。将保存的注销回调替换为始终返回 `Promise` 的 `handle.dispose`,并等待显式释放完成;fiber 释放仍会自动解除注册。处理初始化错误时须等待注册完成。 4. 将 `installModelSelectionProjection(ctx)` 替换为 `await ctx.sessionProjections.register(modelSelectionProjection)`,从 `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts` 导入该定义。 5. 对自定义插件执行类型检查,再验证激活、Session 状态读取与 owner 释放。已释放的读取句柄仍可使用,而投影单元在最后一次注册解除后会从新快照中消失。 diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 4e5b3862ed..ceac4dd3a7 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -6332,7 +6332,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ProjectionHandle', - declaration: 'export interface ProjectionHandle {\n stateOf(session: Session): S;\n dispose(): void | Promise;\n}', + declaration: 'export interface ProjectionHandle {\n stateOf(session: Session): S;\n dispose(): Promise;\n}', }, { name: 'ProjectionSnapshot', diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index cf0fd8eeeb..90321e2e39 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -24,8 +24,8 @@ en: 09758d1475fad86e zh: 473637cb34aebe72 /deepseek-ai-dsh-session-projection/use-this-package/register-and-read: - en: e10e44c46035dbdb - zh: dff312b52f4ec2ce + en: 52120bc6e18a8da8 + zh: c45ca6a83a6fcd4e /deepseek-ai-dsh-session-projection/use-this-package/persisted-checkpoints: en: 45f1c05fb7e08180 zh: f8d62c749899651a diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 61e2677e47..9a1330ed60 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -55,7 +55,7 @@ const definition = { ### Register and read -`await register(definition)` installs the unit and returns a handle with synchronous `stateOf(session)` reads and `dispose()`. Registrants with the same key and `stateVersion` share cells; incompatible or invalid versions reject registration. Registration belongs to the calling fiber. The last unregister removes the unit from global event drives, snapshots, checkpoints, and change notifications. Existing handles remain readable: each retains its original unit and folds new Session events on demand, even if another version later registers the same key. Reading a handle does not initialize unrelated units or register it again. Carriers use `snapshot(session)` for a consistent client-view cut and `onChanged(listener)` for changes to registered units. +`await register(definition)` installs the unit and returns a handle with synchronous `stateOf(session)` reads and `dispose(): Promise`. Await `dispose()` to release the registration; repeated calls are safe. Registrants with the same key and `stateVersion` share cells; incompatible or invalid versions reject registration. Registration belongs to the calling fiber. The last unregister removes the unit from global event drives, snapshots, checkpoints, and change notifications. Existing handles remain readable: each retains its original unit and folds new Session events on demand, even if another version later registers the same key. Reading a handle does not initialize unrelated units or register it again. Carriers use `snapshot(session)` for a consistent client-view cut and `onChanged(listener)` for changes to registered units. ```text const projection = await ctx.sessionProjections.register(definition) diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 2a0ac169d7..865d9e8537 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -55,7 +55,7 @@ const definition = { ### 注册与读取 -`await register(definition)` 安装单元,返回提供同步 `stateOf(session)` 读取和 `dispose()` 的 handle。具有相同 key 和 `stateVersion` 的注册方共享 cell;版本不兼容或非法时注册会拒绝。注册归调用方 fiber 所有。最后一个注册方注销后,单元退出全局事件驱动、快照、检查点和变更通知。已有 handle 仍可读取:它保留原有单元,按需折叠新增的 Session 事件,即使同一 key 后来注册了其他版本也不受影响。读取 handle 不会初始化无关单元,也不会重新注册。载体用 `snapshot(session)` 读取一致的客户端视图切面,用 `onChanged(listener)` 订阅已注册单元的变更。 +`await register(definition)` 安装单元,返回提供同步 `stateOf(session)` 读取和 `dispose(): Promise` 的 handle。等待 `dispose()` 完成即可释放注册;重复调用是安全的。具有相同 key 和 `stateVersion` 的注册方共享 cell;版本不兼容或非法时注册会拒绝。注册归调用方 fiber 所有。最后一个注册方注销后,单元退出全局事件驱动、快照、检查点和变更通知。已有 handle 仍可读取:它保留原有单元,按需折叠新增的 Session 事件,即使同一 key 后来注册了其他版本也不受影响。读取 handle 不会初始化无关单元,也不会重新注册。载体用 `snapshot(session)` 读取一致的客户端视图切面,用 `onChanged(listener)` 订阅已注册单元的变更。 ```text const projection = await ctx.sessionProjections.register(definition) diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 9a27c2b98a..1790882394 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -46,8 +46,11 @@ export interface ProjectionHandle { * @returns the projection state at the Session's current cursor. */ stateOf(session: Session): S - /** Unregister from global drives and snapshots without invalidating this reader. */ - dispose(): void | Promise + /** + * Unregister from global drives and snapshots without invalidating this reader. + * @returns a promise that resolves when this registration is released. + */ + dispose(): Promise } /** @@ -303,7 +306,9 @@ export class SessionProjectionRegistry extends Service { await Promise.resolve() this.ctx.fiber.assertActive() return { - dispose, + async dispose() { + await dispose() + }, stateOf: (session) => { return this.cellFor(registration, session).state as S }, diff --git a/packages/session/session-projection/tests/registration-reader.spec.ts b/packages/session/session-projection/tests/registration-reader.spec.ts index 3ee393b85a..ab287d6110 100644 --- a/packages/session/session-projection/tests/registration-reader.spec.ts +++ b/packages/session/session-projection/tests/registration-reader.spec.ts @@ -98,10 +98,11 @@ describe('retained projection readers', () => { const retained = await ctx.sessionProjections.register(definition) const handle = await owner.ctx.sessionProjections.register(definition) if (release === 'twice') { - await handle.dispose() - await handle.dispose() + await expect(handle.dispose()).resolves.toBeUndefined() + await expect(handle.dispose()).resolves.toBeUndefined() } else { - await Promise.all([owner.dispose(), Promise.resolve(handle.dispose())]) + await Promise.all([owner.dispose(), expect(handle.dispose()).resolves.toBeUndefined()]) + await expect(handle.dispose()).resolves.toBeUndefined() } session.append('turn/start', { turn: 1 }) expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/reader': 1 }) From 96784dae411619183484de7db2816f6b67844aa6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 13:20:04 +0800 Subject: [PATCH 08/37] refactor(tool-goal): keep authority checks on the original event scan --- packages/goal/tool-goal/README.i18n.yaml | 4 +- packages/goal/tool-goal/README.md | 3 +- packages/goal/tool-goal/README.zh.md | 3 +- packages/goal/tool-goal/package.json | 3 +- packages/goal/tool-goal/src/authority.ts | 147 ++++++++---------- packages/goal/tool-goal/src/index.ts | 13 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 23 ++- pnpm-lock.yaml | 3 - 8 files changed, 82 insertions(+), 117 deletions(-) diff --git a/packages/goal/tool-goal/README.i18n.yaml b/packages/goal/tool-goal/README.i18n.yaml index 1d97f69a1b..1006276ef8 100644 --- a/packages/goal/tool-goal/README.i18n.yaml +++ b/packages/goal/tool-goal/README.i18n.yaml @@ -30,8 +30,8 @@ en: 4e9fdb5a89f4baa3 zh: 50d182467ae1f3a7 /deepseek-ai-dsh-tool-goal/understand-the-implementation/design: - en: 905e5e105efdf7a8 - zh: 3704176a160aabb1 + en: 3be550d69f7167f1 + zh: 0becdbd7b7395e40 /deepseek-ai-dsh-tool-goal/understand-the-implementation/source-map: en: 4d5c648787409868 zh: b4938bfe660f18aa diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 9ab458268d..23b457e6c8 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -68,8 +68,7 @@ This section explains how the tools enforce authority and render output; the obs ### Design -- **Authority at execution.** Every call resolves the exact live agent, its inherited `AgentRegistry` initiator, running status, and an open turn; `create`, `edit`, `pause`, and `resume` additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. A durable paused goal fails the `resume` action with `GOAL_TOOL_RESUME_PAUSED`; the user-facing command or Web control owns that transition. Durable fork lineage does not demote a resumed root; live subagent ownership does. -- **Current-turn input projection.** A host-only projection retains human-input presence and admitted goal-round identifiers, resets at each turn boundary, and reconstructs these facts from committed events when registered. +- **Authority at execution.** The plugin awaits its turn-boundary reader before registering tools. Every call resolves the exact live agent, its inherited `AgentRegistry` initiator, running status, and an open turn; `create`, `edit`, `pause`, and `resume` additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. A durable paused goal fails the `resume` action with `GOAL_TOOL_RESUME_PAUSED`; the user-facing command or Web control owns that transition. Durable fork lineage does not demote a resumed root; live subagent ownership does. - **Host attestation of human input.** `{ kind: 'user' }` is assigned by `Agent.followup()` and `steer()` when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority. - **System-prompt guidance with the configured threshold.** The package registers one `tool:goal` system-prompt section whose fixed text interpolates `blockedAfterConsecutiveRounds`; the same value is the hard lower bound enforced at execution. - **Wrap-up context for terminal rounds.** A successful autonomous `complete` or `blocked` defers a closing `` or `` instruction so the model addresses the user once before the turn ends; direct-human mutations never defer this context. diff --git a/packages/goal/tool-goal/README.zh.md b/packages/goal/tool-goal/README.zh.md index 9e868061c2..e7d67715c4 100644 --- a/packages/goal/tool-goal/README.zh.md +++ b/packages/goal/tool-goal/README.zh.md @@ -68,8 +68,7 @@ kind: "package-reference" ### 设计 -- **执行时权限。** 每次调用都解析精确活跃 agent、其继承的 `AgentRegistry` initiator、running 状态与开放轮次;`create`、`edit`、`pause` 和 `resume` 还要求运行时根 agent 的当前轮次中存在已接受的 `{ kind: 'user' }` 消息或 steering 事件。持久的 paused goal 会让 `resume` 以 `GOAL_TOOL_RESUME_PAUSED` 失败;面向用户的命令或 Web 控件拥有该转换。持久 fork 谱系不会降低已恢复根 agent 的等级;活跃 subagent 所有权会降低。 -- **当前轮输入投影。** 仅供 Host 使用的投影保存是否有人类输入及已接纳的目标轮次标识,在每个轮次边界重置,并在注册时从已提交事件重建这些事实。 +- **执行时权限。** 插件等待轮次边界读取句柄就绪后才注册工具。每次调用都解析精确活跃 agent、其继承的 `AgentRegistry` initiator、running 状态与开放轮次;`create`、`edit`、`pause` 和 `resume` 还要求运行时根 agent 的当前轮次中存在已接受的 `{ kind: 'user' }` 消息或 steering 事件。持久的 paused goal 会让 `resume` 以 `GOAL_TOOL_RESUME_PAUSED` 失败;面向用户的命令或 Web 控件拥有该转换。持久 fork 谱系不会降低已恢复根 agent 的等级;活跃 subagent 所有权会降低。 - **人类输入的宿主证明。** `Agent.followup()` 与 `steer()` 会在调用方省略 source 时分配 `{ kind: 'user' }`,因此插件、调度器与其他非人类生产方必须传入自己的 source,不能继承人类权限。 - **带配置阈值的系统提示词指引。** 本包注册一个 `tool:goal` 系统提示词章节,其固定文本插入 `blockedAfterConsecutiveRounds`;同一数值就是执行时强制执行的硬下限。 - **终局 Round 的结束上下文。** 成功的自主 `complete` 或 `blocked` 会延后一条 `` 或 `` 结束指令,让模型在轮次结束前向用户做一次交代;人类直接变更绝不会延后该上下文。 diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index 65a9e48f4b..e5c0e9d8e7 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -39,8 +39,7 @@ "@deepseek-ai/dsh-session-projection": "workspace:*" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:~", - "zod": "4.4.3" + "@deepseek-ai/schemastery": "workspace:~" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:~", diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index 6214005230..07de4e68ae 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -1,58 +1,18 @@ /** Execution-time authority checks for the model-facing goal tools. */ import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { GoalId, type GoalView } from '@deepseek-ai/dsh-goal' +import type { Agent, TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' +import type { GoalView } from '@deepseek-ai/dsh-goal' import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { SessionEvent, SessionSeq } from '@deepseek-ai/dsh-session' import type { ToolRunContext } from '@deepseek-ai/dsh-tools' -import type { ProjectionDefinition, ProjectionHandle } from '@deepseek-ai/dsh-session-projection' -import { z as zod } from 'zod' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' -const turnAuthoritySchema = zod.object({ - open: zod.boolean(), - directHuman: zod.boolean(), - goalRounds: zod.array(zod.object({ - goalId: zod.string().transform(GoalId), - revision: zod.number().int().positive(), - round: zod.number().int().positive(), - })), -}) - -/** Accepted input authority for the currently open turn, reset at both turn boundaries. */ -type TurnAuthority = zod.infer - -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Input sources admitted in the current turn for goal-tool authorization. */ - goalToolAuthority: TurnAuthority - } -} - -const turnAuthorityProjection = { - key: 'goalToolAuthority', - stateVersion: 1, - stateSchema: turnAuthoritySchema, - init: (): TurnAuthority => ({ open: false, directHuman: false, goalRounds: [] }), - apply: (state, event) => { - if (event.type === 'turn/start' || event.type === 'turn/end') { - return { open: event.type === 'turn/start', directHuman: false, goalRounds: [] } - } - if (!state.open || event.type !== 'user/message') return state - const source = event.data.source - if (source.kind === 'user') return state.directHuman ? state : { ...state, directHuman: true } - if (source.kind === 'goal') { - const { goalId, revision, round } = source - return { ...state, goalRounds: [...state.goalRounds, { goalId, revision, round }] } - } - // Other message producers do not grant goal-tool authority. - return state - }, -} satisfies ProjectionDefinition<'goalToolAuthority', TurnAuthority> - -/** The authenticated caller and immutable authority facts captured for one tool call. */ +/** The calling agent plus the immutable event cut and open-turn start seq used for authority checks. */ export interface GoalToolExecution { readonly agent: Agent - readonly authority: TurnAuthority + readonly events: readonly SessionEvent[] + readonly openTurnStartSeq: SessionSeq } /** Hard authority granted to one state-changing call. */ @@ -65,44 +25,56 @@ function reject(message: string, code = 'GOAL_TOOL_AUTHORITY_REQUIRED'): never { throw new HarnessError(message, code) } -/** Resolves goal-tool authority from the live driver and its committed turn. */ -export class GoalToolExecutionResolver { - private authority!: ProjectionHandle - - /** @param ctx - Context carrying the live agent registry and session projections. */ - constructor(private readonly ctx: Context) {} - - /** - * Acquire projection readers before exposing dependent operations. - * @returns when all required registrations are ready. - */ - async initialize(): Promise { - this.authority = await this.ctx.sessionProjections.register(turnAuthorityProjection) +/** Resolve the immutable event cut and open-turn boundary without copying the turn suffix. */ +function openTurnEvents( + turnBoundary: ProjectionHandle, + agent: Agent, +): Pick { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. + const events = agent.session.snapshotEvents() + const boundary = turnBoundary.stateOf(agent.session) + if (boundary.openTurnStartSeq === null) { + reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') } + return { events, openTurnStartSeq: boundary.openTurnStartSeq } +} - /** - * Authenticate the caller and capture its current turn's accepted input sources. - * @param exec - Tool execution carrying the live calling agent. - * @returns the caller and projected authority facts. - */ - resolve(exec: ToolRunContext): GoalToolExecution { - const agent = exec.agent - if (agent === undefined) { - return reject('goal tools require a calling agent', 'GOAL_TOOL_AGENT_REQUIRED') - } - if (this.ctx.agents.get(agent.id) !== agent || agent.status !== 'running' - || this.ctx.agents.currentInitiator() !== agent) { - return reject( - 'goal tools require the exact live calling agent inside its active driver', - 'GOAL_TOOL_DRIVER_REQUIRED', - ) - } - const authority = this.authority.stateOf(agent.session) - if (!authority.open) { - reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') - } - return { agent, authority } +/** + * Resolve and authenticate the calling agent and its driver boundary. + * @param ctx - Context carrying the live agent registry. + * @param exec - Tool execution metadata supplied by the registry. + * @param turnBoundary - Ready reader for the calling Session's turn boundary. + * @returns The authenticated agent, immutable event cut, and open-turn boundary. + */ +export function goalToolExecution( + ctx: Context, + exec: ToolRunContext, + turnBoundary: ProjectionHandle, +): GoalToolExecution { + const agent = exec.agent + if (agent === undefined) { + return reject('goal tools require a calling agent', 'GOAL_TOOL_AGENT_REQUIRED') } + if (ctx.agents.get(agent.id) !== agent || agent.status !== 'running' + || ctx.agents.currentInitiator() !== agent) { + return reject( + 'goal tools require the exact live calling agent inside its active driver', + 'GOAL_TOOL_DRIVER_REQUIRED', + ) + } + return { agent, ...openTurnEvents(turnBoundary, agent) } +} + +/** Whether the captured open turn contains an event accepted by `predicate`. */ +function someOpenTurnEvent( + execution: GoalToolExecution, + predicate: (event: SessionEvent) => boolean, +): boolean { + for (let seq = execution.openTurnStartSeq + 1; seq < execution.events.length; seq += 1) { + const event = execution.events[seq] + if (event !== undefined && predicate(event)) return true + } + return false } /** @@ -112,14 +84,17 @@ export class GoalToolExecutionResolver { */ function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean { if (!ctx.agents.roots().includes(execution.agent)) return false - return execution.authority.directHuman + return someOpenTurnEvent(execution, event => + event.type === 'user/message' && event.data.source.kind === 'user') } /** Whether this turn is the current goal's exact admitted round. */ function isMatchingGoalRound(execution: GoalToolExecution, goal: GoalView): boolean { - return execution.authority.goalRounds.some(round => round.goalId === goal.id - && round.revision === goal.revision - && round.round === goal.roundsStarted) + return someOpenTurnEvent(execution, event => event.type === 'user/message' + && event.data.source.kind === 'goal' + && event.data.source.goalId === goal.id + && event.data.source.revision === goal.revision + && event.data.source.round === goal.roundsStarted) } /** diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index a364dec967..962d0b8f98 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -5,6 +5,7 @@ */ import type { Context } from '@deepseek-ai/cordis' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' import z from '@deepseek-ai/schemastery' import { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal' @@ -20,13 +21,12 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' import { completionAuthority, - GoalToolExecutionResolver, + goalToolExecution, requireDirectHuman, } from './authority.ts' import { renderWrapupContext } from './wrapup.ts' export const name = 'tool-goal' - export const inject = ['agents', 'goals', 'tools', 'systemPrompt', 'sessionProjections'] /** Model policy and hard lower bounds for goal-state updates. */ @@ -186,9 +186,8 @@ function present(title: string, kind: 'read' | 'other', rawInput?: unknown): Gen /** Register the three Codex-shaped goal tools and their shared policy section. */ export async function apply(ctx: Context, config: Config): Promise { - const executionResolver = new GoalToolExecutionResolver(ctx) - await executionResolver.initialize() const resolved = resolveConfig(config) + const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) ctx.systemPrompt.section({ name: 'tool:goal', order: ctx.systemPrompt.getSectionOrder('TOOL_GOAL'), @@ -201,7 +200,7 @@ export async function apply(ctx: Context, config: Config): Promise { parameters: {}, output: GOAL_OUTPUT, execute(_args, exec) { - const execution = executionResolver.resolve(exec) + const execution = goalToolExecution(ctx, exec, turnBoundary) return Promise.resolve(goalValue(ctx.goals.get(execution.agent))) }, presentCall: () => present('Read current goal', 'read'), @@ -223,7 +222,7 @@ export async function apply(ctx: Context, config: Config): Promise { }, output: GOAL_OUTPUT, execute(args, exec) { - const execution = executionResolver.resolve(exec) + const execution = goalToolExecution(ctx, exec, turnBoundary) requireDirectHuman(ctx, execution) const goal = ctx.goals.create(execution.agent, { objective: args.objective, @@ -256,7 +255,7 @@ export async function apply(ctx: Context, config: Config): Promise { }, output: GOAL_OUTPUT, execute(args, exec) { - const execution = executionResolver.resolve(exec) + const execution = goalToolExecution(ctx, exec, turnBoundary) const ref = goalRef(args.goal_id, args.revision) const replacements = { ...hasText(args.objective) ? { objective: args.objective } : {}, diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 0bb6a91a48..08d5e46589 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -18,7 +18,6 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' -import * as GoalTools from '@deepseek-ai/dsh-tool-goal' import * as toolGoal from '@deepseek-ai/dsh-tool-goal' import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -105,7 +104,7 @@ async function harness(config: toolGoal.Config = {}) { await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) await ctx.plugin(GoalService) - const fiber = await ctx.plugin(GoalTools, config) + const fiber = await ctx.plugin(toolGoal, config) const root = stubAgent(`goal-tool-root-${Math.random()}`, undefined, ctx) await ctx.agents.register(root.agent) return { ctx, fiber, root } @@ -207,13 +206,13 @@ describe('goal tool registration and presentation', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) await ctx.plugin(GoalService) - await expect(GoalTools.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 })).rejects.toThrow( + await expect(toolGoal.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 })).rejects.toThrow( 'blockedAfterConsecutiveRounds must be a positive safe integer', ) expect(ctx.tools.get('get_goal')).toBeUndefined() }) - it('resolves the direct-constructor default before registration', async () => { + it('resolves the direct-apply default before registration', async () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) @@ -221,7 +220,7 @@ describe('goal tool registration and presentation', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) await ctx.plugin(GoalService) - await GoalTools.apply(ctx, {}) + await toolGoal.apply(ctx, {}) const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal') expect(section?.text).toContain('at least 3 consecutive goal rounds') }) @@ -347,12 +346,12 @@ describe('goal tool execution authority', () => { const { ctx, root, fiber } = await harness() await fiber.dispose() openTurn(root, { kind: 'user' }) - await ctx.plugin(GoalTools, {}) + await ctx.plugin(toolGoal, {}) const result = await execute(ctx, 'create_goal', { objective: 'accepted before tool load' }, root.agent) expect(resultGoal(result)).toMatchObject({ objective: 'accepted before tool load' }) }) - it.each([1, 2])('accepts %i direct human steering messages in a goal-sourced root turn', async (messageCount) => { + it('accepts direct human steering in a goal-sourced root turn', async () => { const { ctx, root } = await harness() const humanTurn = openTurn(root, { kind: 'user' }) const created = ctx.goals.create(root.agent, { objective: 'steer me' }) @@ -360,12 +359,10 @@ describe('goal tool execution authority', () => { openTurn(root, { kind: 'goal', goalId: created.id, revision: created.revision, round: 1, }) - for (let index = 0; index < messageCount; index++) { - root.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'pause now' }], - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - } + root.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'pause now' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) const paused = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'pause', }, root.agent) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06d99d6378..3ffe9577da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9843,9 +9843,6 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery - zod: - specifier: 4.4.3 - version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ From 409ed3318384b3d3cbd9b052234373c2397acc7e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 14:04:28 +0800 Subject: [PATCH 09/37] test(tool-present): import the built plugin namespace --- packages/deliverables/tool-present/tests/built-errors.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/deliverables/tool-present/tests/built-errors.e2e.ts b/packages/deliverables/tool-present/tests/built-errors.e2e.ts index 7595872758..2fe7201def 100644 --- a/packages/deliverables/tool-present/tests/built-errors.e2e.ts +++ b/packages/deliverables/tool-present/tests/built-errors.e2e.ts @@ -16,7 +16,7 @@ import LocalFileSystem from './packages/fs/fs-local/lib/index.js' import SystemPrompt from './packages/core/system-prompt/lib/index.js' import ToolRuntime from './packages/core/tools/lib/index.js' import { Session, SESSION_FORMAT_VERSION } from './packages/core/session/lib/index.js' -import Present from './packages/deliverables/tool-present/lib/index.js' +import * as Present from './packages/deliverables/tool-present/lib/index.js' import SessionProjectionRegistry from './packages/session/session-projection/lib/index.js' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' From 5705bf12f0e176b80f307dfef6dfde08590a0293 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 15:41:37 +0800 Subject: [PATCH 10/37] refactor(agent-instructions): remove unused source type re-export --- packages/context/agent-instructions/README.i18n.yaml | 4 ++-- packages/context/agent-instructions/README.md | 2 +- packages/context/agent-instructions/README.zh.md | 2 +- packages/context/agent-instructions/src/index.ts | 1 - 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/context/agent-instructions/README.i18n.yaml b/packages/context/agent-instructions/README.i18n.yaml index 818bcda27b..9fc0cb6eae 100644 --- a/packages/context/agent-instructions/README.i18n.yaml +++ b/packages/context/agent-instructions/README.i18n.yaml @@ -30,8 +30,8 @@ en: 724df7ff6d1d9fea zh: 91fb1013eb9178ec /deepseek-ai-dsh-agent-instructions/understand-the-implementation/design-concept: - en: 566f3518b2d1d100 - zh: c7d0a22b19e7697a + en: 4708b51bf8fe86f7 + zh: 869fc4b1f78383ee /deepseek-ai-dsh-agent-instructions/understand-the-implementation/source-map: en: cb4c1f929ef5a835 zh: 3f0fcb7f568a0a5c diff --git a/packages/context/agent-instructions/README.md b/packages/context/agent-instructions/README.md index e935cedf8d..25c48ba1ac 100644 --- a/packages/context/agent-instructions/README.md +++ b/packages/context/agent-instructions/README.md @@ -85,7 +85,7 @@ This section explains the design decisions behind the plugin; the observable beh ### Design concept -The plugin is built on one principle: workspace instructions are durable conversation content, owned per agent and per session. Baseline and refresh messages are ordinary sourced `user/message` events, so they replay, compact, and resume exactly like other history, and model-visible state is always reconstructable from the session log. The plugin owns the complete `` framing and every injected message reaches the model verbatim. The package exports `AgentInstructionSource` for typed consumers of these message sources. +The plugin is built on one principle: workspace instructions are durable conversation content, owned per agent and per session. Baseline and refresh messages are ordinary sourced `user/message` events, so they replay, compact, and resume exactly like other history, and model-visible state is always reconstructable from the session log. The plugin owns the complete `` framing and every injected message reaches the model verbatim. ### Source map diff --git a/packages/context/agent-instructions/README.zh.md b/packages/context/agent-instructions/README.zh.md index cf193f2a28..a87cfae53a 100644 --- a/packages/context/agent-instructions/README.zh.md +++ b/packages/context/agent-instructions/README.zh.md @@ -85,7 +85,7 @@ export interface Config { ### 设计理念 -该插件建立在一个原则上:工作区指令是持久的对话内容,按 agent 与会话分别归属。基线消息与刷新消息都是普通的带来源 `user/message` 事件,因此与其他历史一样可回放、可压缩(compaction)、可恢复,模型可见状态总能从会话日志重建。插件拥有完整的 `` 框架,每条注入消息都原样到达模型。 本包导出 `AgentInstructionSource`,供这些消息来源的消费者进行类型检查。 +该插件建立在一个原则上:工作区指令是持久的对话内容,按 agent 与会话分别归属。基线消息与刷新消息都是普通的带来源 `user/message` 事件,因此与其他历史一样可回放、可压缩(compaction)、可恢复,模型可见状态总能从会话日志重建。插件拥有完整的 `` 框架,每条注入消息都原样到达模型。 ### 源码地图 diff --git a/packages/context/agent-instructions/src/index.ts b/packages/context/agent-instructions/src/index.ts index b32a5e6bb7..31070a9102 100644 --- a/packages/context/agent-instructions/src/index.ts +++ b/packages/context/agent-instructions/src/index.ts @@ -33,7 +33,6 @@ import { instructionCandidateGroup, instructionScopeKey, scopeForDisplayPath, ty export { Config } export { name } from './state.ts' -export type { AgentInstructionSource } from './state.ts' export { discoverBaselineInstructionFiles, loadBaselineInstructions, From fc41f012a98409d2ddfcc6631e07ba2aeeb5aa1e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 13 Oct 2026 11:32:36 +0800 Subject: [PATCH 11/37] docs: refresh config catalog after merging master --- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a31297183e..188bf83918 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3992,7 +3992,7 @@ export interface Config { - `inject`: `tools` · `subagents` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.md) -- `source`: [`packages/subagent/tool-subagent/src/index.ts:49`](../packages/subagent/tool-subagent/src/index.ts) +- `source`: [`packages/subagent/tool-subagent/src/index.ts:50`](../packages/subagent/tool-subagent/src/index.ts) ```ts config-catalog /** Config: which registered provider this tool delegates to, plus child defaults. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ed71c85653..bcde75fe3d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3994,7 +3994,7 @@ export interface Config { - `inject`: `tools` · `subagents` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.zh.md) -- `source`: [`packages/subagent/tool-subagent/src/index.ts:49`](../packages/subagent/tool-subagent/src/index.ts) +- `source`: [`packages/subagent/tool-subagent/src/index.ts:50`](../packages/subagent/tool-subagent/src/index.ts) ```ts config-catalog /** Config: which registered provider this tool delegates to, plus child defaults. */ From c25806f422f4491d4b8524497d046e498c0c4bea Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 13 Oct 2026 20:14:43 +0800 Subject: [PATCH 12/37] fix(ui-chat): settle folds with final sibling spacing --- packages/client/ui-chat/README.i18n.yaml | 4 +-- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../src/client/chat/use-chat-viewport.ts | 5 ++- .../ui-chat/tests/chat-scroll.client.spec.tsx | 31 +++++++++++++++---- 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index dd9033ae18..f971449ddb 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -36,8 +36,8 @@ en: 81d7ca7595c34d39 zh: 29eb8248c75e9ea6 /deepseek-ai-dsh-client-ui-chat/scroll-ownership: - en: 3f4e6c161b4583bb - zh: e4265bb2bce8eb1c + en: 27f0323883ced61d + zh: 33ee7115231140c4 /deepseek-ai-dsh-client-ui-chat/model-experience: en: c17b4dd43a61c569 zh: f7414309810c3a08 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index f04957566b..05ca3efc62 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -138,7 +138,7 @@ The transcript root uses `overflow-x: visible; overflow-y: clip`: vertical overf Outer transcript following and each open capped group's following are independent. Native animation progress retains follow intent; only that scrollport's own `scrollend` settles its animation. Browsers without `scrollend` may retain the native follow target until a later bottom-aligned update or reader gesture. A reader gesture interrupts the animation, and actual movement determines whether following remains enabled. Scroll chaining can move the outer transcript, which then applies its own distance threshold. The back-to-bottom button restores only outer following. -Next-message folding combines row closure and tail following in one cancellable task, with browser scroll anchoring disabled during the fold. If the removed height exceeds the new content's height, the difference remains as bottom space rather than reducing the scroll offset to chase the unpadded content floor. This applies even when the conversation already exceeds the viewport. New content fills the retained space before following advances again; a smaller fold leaves only the remaining forward distance to scroll. Reader gestures cancel the task and active native following; resize cannot revive a cancelled task in the same fold, but new submitted input can. Off-bottom readers retain their position, and acknowledged scrolling releases only space that can be removed without clamping them. Unloading Chat cancels waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes. +Next-message folding combines row closure and tail following in one cancellable task, with browser scroll anchoring disabled during the fold. The closing frame includes final sibling spacing in its scroll position and reserved room. If the removed height exceeds the new content's height, the difference remains as bottom space rather than reducing the scroll offset to chase the unpadded content floor. This applies even when the conversation already exceeds the viewport. New content fills the retained space before following advances again; a smaller fold leaves only the remaining forward distance to scroll. Reader gestures cancel the task and active native following; resize cannot revive a cancelled task in the same fold, but new submitted input can. Off-bottom readers retain their position, and acknowledged scrolling releases only space that can be removed without clamping them. Unloading Chat cancels waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes. Changing Work details clears reserved bottom space after active folds finish and reconciles the scroll range with the selected mode. Readers already following the tail stay at the new bottom; other readers are not forced to follow. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 064607a1a3..3b55b0d16a 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -138,7 +138,7 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且 外层文本记录与每个已展开限高组的跟随状态彼此独立。原生动画的中间滚动保留跟随意图;只有该滚动容器自身的 `scrollend` 才结束其动画。不支持 `scrollend` 的浏览器可能保留原生跟随目标,直到后续贴底更新或读者手势将其结束。读者手势中断动画,是否继续跟随由实际位移决定。滚动传递可以带动外层,外层再按自身离底距离判断。回到底部按钮只恢复外层跟随。 -下次消息触发的折叠与跟尾属于同一个可取消任务,折叠期间禁用浏览器滚动锚定。如果移除的高度大于新增内容的高度,差值保留为底部空间,不通过减小滚动偏移追到未含预留空间的内容底部;对话总高度已经超过视口时也遵循此规则。后续新增内容先填充保留区域,填满后才继续跟尾;折叠高度较小时,仅滚动剩余的向前距离。读者手势取消该任务及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复已取消的任务,但新提交的输入可以。离底读者保留位置,已确认的滚动只释放不会造成位置钳制的多余空间。卸载 Chat 时取消等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。 +下次消息触发的折叠与跟尾属于同一个可取消任务,折叠期间禁用浏览器滚动锚定。收尾帧按最终兄弟节点间距确定滚动位置与预留空间。如果移除的高度大于新增内容的高度,差值保留为底部空间,不通过减小滚动偏移追到未含预留空间的内容底部;对话总高度已经超过视口时也遵循此规则。后续新增内容先填充保留区域,填满后才继续跟尾;折叠高度较小时,仅滚动剩余的向前距离。读者手势取消该任务及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复已取消的任务,但新提交的输入可以。离底读者保留位置,已确认的滚动只释放不会造成位置钳制的多余空间。卸载 Chat 时取消等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。 切换工作步骤展示档位时,当前折叠结束后清空底部预留空间,并按所选档位调整滚动范围。原本跟随底部的读者保持在新的底部;其他读者不会被强制拉回底部。 diff --git a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts index ad6de91901..e33fe2b90c 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts @@ -243,7 +243,7 @@ export class ChatViewport { finish: (cancelled) => { if (this.elements !== elements) return // Commit the final sibling margins while their input-fold transition override still applies. - void elements.column.offsetHeight + const finalHeight = Math.max(elements.column.offsetHeight, elements.scroller.scrollHeight - this.spacerHeight()) delete elements.column.dataset.inputFold if (cancelled) { const metrics = scrollMetrics(elements.scroller) @@ -252,7 +252,10 @@ export class ChatViewport { this.invalidate() return } + target = Math.max(base, finalHeight - elements.scroller.clientHeight, 0) + finalRoom = target === 0 ? 0 : Math.max(0, target + elements.scroller.clientHeight - finalHeight) this.setSpacer(finalRoom) + elements.scroller.scrollTop = target const metrics = scrollMetrics(elements.scroller) const landing = { metrics, position: null, turn: this.latestTurn } this.observation = { top: metrics.top, landing } diff --git a/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx b/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx index b212f91940..1340e7d815 100644 --- a/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx @@ -87,10 +87,13 @@ function mountScroll(deferCompletedTurns = true, initial: Partial) }, - startFold: () => { + startFold: (finalHeight?: number) => { const motion = callbacks.motion if (motion === undefined) throw new Error('Viewport motion callbacks are not bound') - act(() => { motion.collapse(row, () => { row.hidden = true }) }) + act(() => { motion.collapse(row, () => { + row.hidden = true + if (finalHeight !== undefined) height = finalHeight + }) }) }, finishFold: () => { const event = new Event('transitionend', { bubbles: true }) @@ -117,7 +120,7 @@ const visibleFoldCases = [ { name: 'small viewport', earlier: 200, steps: 900, body: 100, viewport: 300, top: 900, input: 30, target: 200, room: 170 }, ] as const -function mountVisibleFold(test: typeof visibleFoldCases[number], reduced: boolean) { +function mountVisibleFold(test: typeof visibleFoldCases[number], reduced: boolean, finalSiblingGrowth = 0) { let inputHeight = 0 let submission = 0 let top: number = test.top @@ -142,7 +145,7 @@ function mountVisibleFold(test: typeof visibleFoldCases[number], reduced: boolea const rowHeight = () => row.hidden ? 0 : (row.style.transition !== '' && row.style.transition !== 'none') || row.style.height === '' ? test.steps : Number.parseFloat(row.style.height) const room = () => Number.parseFloat(spacer.style.height) || 0 - const contentHeight = () => test.earlier + rowHeight() + test.body + inputHeight + const contentHeight = () => test.earlier + rowHeight() + test.body + inputHeight + (row.hidden ? finalSiblingGrowth : 0) const floor = () => Math.max(0, contentHeight() + room() - test.viewport) const scrollTo = vi.fn((options: ScrollToOptions) => { scroller.scrollTop = options.top ?? scroller.scrollTop @@ -211,6 +214,22 @@ function mountVisibleFold(test: typeof visibleFoldCases[number], reduced: boolea } describe.each([false, true])('visible input-fold matrix (reduced=%s)', (reduced) => { + it.each(['larger input', 'T/B'])('lands on final sibling spacing in the closing frame (%s)', async (name) => { + const test = visibleFoldCases.find(test => test.name === name)! + const h = mountVisibleFold(test, reduced, 4) + h.submit() + await act(async () => { await Promise.resolve() }) + for (let frame = 0; frame < 24 && !h.row.hidden; frame++) { + act(() => { vi.advanceTimersByTime(16) }) + } + expect(h.row.hidden).toBe(true) + const target = test.target + (test.room === 0 ? 4 : 0) + expect(h.scroller.scrollTop).toBe(target) + expect(h.room()).toBe(Math.max(0, test.room - 4)) + act(() => { vi.advanceTimersByTime(500) }) + expect(h.scroller.scrollTop).toBe(target) + }) + it.each(visibleFoldCases)('$name lands with only the required visible room', async (test) => { const h = mountVisibleFold(test, reduced) const positions = [h.bodyTop()] @@ -363,7 +382,7 @@ describe('Chat scroll collapse timing', () => { it('coalesces submitted inputs into one frame-owned fold', async () => { const h = mountScroll() - h.startFold() + h.startFold(920) h.update({ submissionId: 'first' }) h.update({ submissionId: 'second' }) expect(h.scrollTo).not.toHaveBeenCalled() @@ -467,7 +486,7 @@ describe('Chat scroll collapse timing', () => { it.each([false, true])('keeps a fold cancelled through resize until new input owns follow (new input=%s)', async (newInput) => { const h = mountScroll() h.scroller.scrollTop = 500 - h.startFold() + h.startFold(920) h.update({ submissionId: 'first' }) await act(async () => { await Promise.resolve() }) fireEvent.wheel(h.scroller, { deltaY: -80 }) From f50bab696ad1860aaf392c88ea5aa6a94bc097eb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 13 Oct 2026 20:14:59 +0800 Subject: [PATCH 13/37] test(web): sample input folds on a controlled frame clock --- apps/web/tests/chat-scroll-contract.e2e.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index 955d251ab7..49675a473a 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -593,7 +593,7 @@ describe('web e2e: long Chat scroll contract', () => { const artifactRoot = fileURLToPath(new URL('../../../.artifacts/screenshots/visible-fold/', import.meta.url)) await mkdir(artifactRoot, { recursive: true }) const evidence = await mkdtemp(join(artifactRoot, `${scenario.name}-`)) - const controlledClock = scenario.name === 'content-growth-during-fold' || scenario.name === 'cancel-all-during-fold' + const controlledClock = !scenario.reduced await withScrollWorld({ failureShot: `visible-fold-${scenario.name}`, controlledClock, @@ -760,17 +760,19 @@ describe('web e2e: long Chat scroll contract', () => { try { settled = world.scaffold.whenTurnSettled(30_000) if (controlledClock) { - // Advance every animation frame so the race cannot miss the intermediate fold height. + // Sample each animation frame, including when the CI browser misses real-time frames. await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000)) try { await page.getByRole('button', { name: 'Send message', exact: true }).evaluate((button) => { if (!(button instanceof HTMLButtonElement)) throw new Error('Send control is not a button') button.click() }) - await expect.poll(async () => { - await page.clock.runFor(16) - return page.locator('[data-fold-race-applied]').count() - }, { timeout: 10_000 }).toBe(1) + if (scenario.name === 'content-growth-during-fold' || scenario.name === 'cancel-all-during-fold') { + await expect.poll(async () => { + await page.clock.runFor(16) + return page.locator('[data-fold-race-applied]').count() + }, { timeout: 10_000 }).toBe(1) + } await expect.poll(async () => { await page.clock.runFor(16) return page.locator('[data-chat-motion="collapse"]').count() From c3efd4bff844ad479a967623518566a17af61f0e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 15 Oct 2026 18:18:58 +0800 Subject: [PATCH 14/37] refactor(session-projection): keep state reads inside owning services --- ...ession-projection-mandatory-seam.i18n.yaml | 4 +- ...08-19-session-projection-mandatory-seam.md | 4 +- ...19-session-projection-mandatory-seam.zh.md | 4 +- ...-projection-registration-handles.i18n.yaml | 8 +- ...session-projection-registration-handles.md | 6 +- ...sion-projection-registration-handles.zh.md | 6 +- .../session-corpus/projection-list.worker.ts | 2 +- .../session-open/session-open.worker.ts | 10 +-- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/config-catalog.md | 46 +++++----- docs/config-catalog.zh.md | 46 +++++----- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 16 ++-- docs/persistence-catalog.md | 8 +- docs/persistence-catalog.zh.md | 8 +- docs/persistence-schema.json | 4 +- docs/subsystems/core.i18n.yaml | 3 + docs/subsystems/core.md | 38 ++++++++- docs/subsystems/core.zh.md | 38 ++++++++- docs/subsystems/token-meter.i18n.yaml | 3 + docs/subsystems/token-meter.md | 45 ++++++++++ docs/subsystems/token-meter.zh.md | 45 ++++++++++ docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 6 +- docs/tool-catalog.zh.md | 6 +- .../guide.i18n.yaml | 8 +- .../session-projection-handles/guide.md | 8 +- .../session-projection-handles/guide.zh.md | 8 +- .../api/job-controller/tests/host-harness.ts | 2 + .../job-controller/tests/kill.host.spec.ts | 2 + .../job-controller/tests/observe.host.spec.ts | 3 + .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- packages/api/session-controller/src/agent.ts | 18 ++-- .../tests/agent.host.spec.ts | 2 + .../tests/archived-session-gate.host.spec.ts | 3 + .../tests/commands-create-fork.host.spec.ts | 2 + .../commands-queue-attachment.host.spec.ts | 3 + .../tests/commands-upload-file.host.spec.ts | 13 ++- .../tests/controller.host.spec.ts | 4 + .../tests/projection-discovery.host.spec.ts | 2 +- .../tests/session-cold.host.spec.ts | 17 ++++ .../tests/session-fork.host.spec.ts | 2 + .../session-history-journal.host.spec.ts | 2 + .../tests/session-list-blank.host.spec.ts | 2 + .../tests/session-models.host.spec.ts | 2 + .../session-open-workspace-path.host.spec.ts | 2 + .../tests/session-presets.host.spec.ts | 7 +- .../tests/session-projections.host.spec.ts | 6 +- .../tests/session-search.host.spec.ts | 2 +- .../tests/session-skills.host.spec.ts | 2 + .../tests/subagent-catalog.host.spec.ts | 3 + .../context/agent-instructions/package.json | 1 - .../context/agent-instructions/src/index.ts | 20 ++--- .../tests/agent-instructions.spec.ts | 10 +-- .../context/agent-instructions/tsconfig.json | 3 - .../tests/service.spec.ts | 3 +- .../tests/session-reference.spec.ts | 3 +- packages/context/time-context/src/index.ts | 1 + packages/context/tmux-context/src/index.ts | 2 + .../tmux-context/tests/tmux-context.spec.ts | 2 +- packages/core/agent-loop/src/agent.ts | 10 +-- packages/core/agent-loop/src/index.ts | 35 ++++---- .../agent-loop/tests/request-freeze.spec.ts | 9 +- .../tests/resource-lifecycle.spec.ts | 2 +- packages/core/agent/README.i18n.yaml | 8 +- packages/core/agent/README.md | 4 +- packages/core/agent/README.zh.md | 4 +- packages/core/agent/src/index.ts | 24 +++++- packages/core/agent/src/types.ts | 10 +-- .../core/agent/tests/agent-initiator.spec.ts | 2 + packages/core/agent/tests/agent.spec.ts | 40 ++++++++- .../agent/tests/archive-admission.spec.ts | 4 + .../deliverables/tool-present/package.json | 1 - .../deliverables/tool-present/src/index.ts | 12 ++- .../tool-present/tests/present.spec.ts | 6 +- .../deliverables/tool-present/tsconfig.json | 3 - packages/experimental/agent-team/src/index.ts | 2 +- .../browser-use-runtime/tests/mcp.spec.ts | 1 + .../tests/resources.spec.ts | 2 + .../claude-code-mods/README.i18n.yaml | 12 +-- .../experimental/claude-code-mods/README.md | 6 +- .../claude-code-mods/README.zh.md | 6 +- .../claude-code-mods/package.json | 1 - .../claude-code-mods/src/host-ops.ts | 21 ++--- .../claude-code-mods/src/index.ts | 27 +++--- .../claude-code-mods/tests/bridge.spec.ts | 4 +- .../claude-code-mods/tests/define-mod.spec.ts | 4 + .../claude-code-mods/tests/host-ops.spec.ts | 5 +- .../claude-code-mods/tsconfig.json | 3 - .../hooks-claude-code/package.json | 1 - .../hooks-claude-code/src/index.ts | 34 +++----- .../hooks-claude-code/tests/bridge.spec.ts | 4 +- .../hooks-claude-code/tests/coverage-cases.ts | 2 +- .../experimental/hooks-codex/package.json | 1 - .../experimental/hooks-codex/src/index.ts | 83 +++++++------------ .../hooks-codex/tests/bridge.spec.ts | 4 +- .../hooks-codex/tests/coverage-cases.ts | 2 +- .../tests/provider.spec.ts | 6 +- .../tool-session-query/package.json | 1 - .../tool-session-query/src/index.ts | 25 +++--- .../tool-session-query/src/operations.ts | 17 ++-- .../src/workspace-access.ts | 5 +- .../tests/sqlite-integration.spec.ts | 5 +- .../tests/tool-session-query.spec.ts | 16 ++-- .../tool-session-query/tsconfig.json | 3 - .../tool-terminal/tests/tools.spec.ts | 2 + .../experimental/worktree/tests/harness.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 26 ++++++ .../tests/command-feedback.spec.ts | 2 + .../tests/loader-composition.spec.ts | 3 + packages/fs/tool-fs/tests/tools.spec.ts | 4 +- .../tests/tools.spec.ts | 5 +- packages/goal/goal/tests/goal.spec.ts | 12 +-- packages/goal/goal/tests/projection.spec.ts | 2 +- packages/goal/tool-goal/README.i18n.yaml | 4 +- packages/goal/tool-goal/README.md | 2 +- packages/goal/tool-goal/README.zh.md | 2 +- packages/goal/tool-goal/package.json | 3 +- packages/goal/tool-goal/src/authority.ts | 16 ++-- packages/goal/tool-goal/src/index.ts | 12 ++- .../goal/tool-goal/tests/tool-goal.spec.ts | 6 +- packages/goal/tool-goal/tsconfig.json | 3 - .../permission-presets/src/index.ts | 3 +- .../tool-ask-user/tests/tool-ask-user.spec.ts | 2 + .../tests/user-questions.spec.ts | 13 ++- packages/jobs/jobs-local/tests/jobs.spec.ts | 9 ++ .../jobs/tool-jobs/tests/tool-jobs.spec.ts | 6 ++ .../llm/llm-deepseek/tests/adapter.e2e.ts | 2 + .../tests/loader-composition.spec.ts | 3 + .../llm/llm-deepseek/tests/runtime.e2e.ts | 2 + packages/llm/llm-retry/src/index.ts | 1 - .../tests/inventory.spec.ts | 3 +- .../tests/private-package-composition.spec.ts | 3 + packages/llm/token-meter/README.i18n.yaml | 8 +- packages/llm/token-meter/README.md | 4 +- packages/llm/token-meter/README.zh.md | 4 +- packages/llm/token-meter/src/index.ts | 18 +++- .../tests/token-usage-projection.spec.ts | 6 +- packages/plan/plan-mode/src/index.ts | 13 +-- .../plan/plan-mode/tests/plan-mode.spec.ts | 7 +- .../plan/plan-mode/tests/projection.spec.ts | 2 +- .../agent-preset-registry/README.i18n.yaml | 4 +- .../preset/agent-preset-registry/README.md | 2 +- .../preset/agent-preset-registry/README.zh.md | 2 +- .../preset/agent-preset-registry/src/index.ts | 17 ++-- .../tests/registry.spec.ts | 10 +-- packages/schedule/schedule/tests/harness.ts | 2 + packages/sdk/server/tests/server.spec.ts | 2 + .../session-projection/README.i18n.yaml | 8 +- packages/session/session-projection/README.md | 4 +- .../session/session-projection/README.zh.md | 4 +- packages/session/session-stats/src/index.ts | 3 +- .../tests/loader-composition.spec.ts | 3 + .../tests/provider.spec.ts | 6 +- packages/session/session-title/src/index.ts | 9 +- .../session-title/tests/persistence.spec.ts | 2 + .../session-title/tests/projection.spec.ts | 2 + .../session-title/tests/provider.spec.ts | 16 +++- .../session-title/tests/rename.spec.ts | 7 ++ .../tests/service-contracts.spec.ts | 5 ++ .../session-title/tests/session-title.spec.ts | 4 + packages/session/session-title/tsconfig.json | 1 + .../session/session-turn-outline/src/index.ts | 3 +- .../tool-bash-persistent/tests/tools.spec.ts | 3 + .../shell/tool-bash/tests/background.spec.ts | 3 + packages/shell/tool-bash/tests/tools.spec.ts | 11 ++- .../tool-pwsh-persistent/tests/tools.spec.ts | 3 + .../shell/tool-pwsh/tests/background.spec.ts | 4 + packages/shell/tool-pwsh/tests/tools.spec.ts | 9 +- .../skill/tool-skill/tests/tool-skill.spec.ts | 4 + .../tests/tool-workspace-dependencies.spec.ts | 2 + .../tests/external-activation-helpers.ts | 2 + packages/subagent/tool-subagent/src/index.ts | 3 +- .../terminal-bash/tests/index.spec.ts | 8 +- .../terminal-bash/tests/local.spec.ts | 2 +- .../terminal/terminal/tests/service.spec.ts | 2 + packages/todo/tool-todo/src/index.ts | 1 - .../todo/tool-todo/tests/projection.spec.ts | 2 +- .../tests/deepseek.spec.ts | 2 + .../tool-workflow/tests/tool-workflow.spec.ts | 3 +- .../workflow/workflow-ptc/tests/host.spec.ts | 2 +- packages/workflow/workflow-ptc/tests/setup.ts | 2 +- scripts/gen-config-catalog.ts | 2 +- scripts/gen-cordis-catalog.ts | 2 + scripts/gen-tool-catalog.ts | 9 +- scripts/type-equiv.manifest.json | 10 +++ 191 files changed, 891 insertions(+), 558 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml index f391fe518e..30244c0085 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml @@ -9,8 +9,8 @@ en: fca00eeb7a8d9abd zh: d7a9766f5322dff9 /agent-note-session-projections-as-a-required-reader-seam/decision: - en: dd550c8f4c2aa263 - zh: 9e60b0e465697e77 + en: 8d38ed9356085c7e + zh: e15ede51415192f3 /agent-note-session-projections-as-a-required-reader-seam/alternatives-considered: en: aea3da939c90d240 zh: 21e91c481cdd23b7 diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md index 5ab32c0b2c..091e8b277a 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md @@ -12,9 +12,9 @@ An optional projection registry lets a plugin whose host behavior reads projecte This decision builds on the split between host projection state and client views in [Session projection state and client views](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md). -Every host reader treats the projection registry and its required key as mandatory state. Plugins declare `sessionProjections` as a required injection and await acquisition before exposing readers. Official compositions mount the registry before those plugins. `ApiProxyService` follows the required-injection form; the lower-level `createApiProxy` factory remains tolerant for isolated tests and diagnostics. +Every host state owner treats the projection registry and its required key as mandatory state. Owners declare `sessionProjections` as a required injection and await acquisition before exposing service methods; other consumers depend on those services. Official compositions mount the registry before those plugins. `ApiProxyService` follows the required-injection form; the lower-level `createApiProxy` factory remains tolerant for isolated tests and diagnostics. -Readers acquire state through [awaited registration and retained handles](2026-10-12-session-projection-registration-handles.md). That decision supersedes optional first-access acquisition and returning a bare disposer. It preserves the required-state rule here: a missing registry cannot silently produce default host state. Client views still contain only consumed fields; typed handle reads provide richer host state. +Owners acquire state through [awaited registration and retained handles](2026-10-12-session-projection-registration-handles.md). That decision supersedes optional first-access acquisition and returning a bare disposer. It preserves the required-state rule here: a missing registry cannot silently produce default host state. Client views still contain only consumed fields; business service methods expose the host data each consumer needs. `onChanged` publishes client-visible value changes only. Registration changes do not create a second Host event stream or client tombstone protocol. A later authoritative history or list baseline reflects the active key set. diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md index 0ab43fd5c0..c30f7bd35d 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md @@ -12,9 +12,9 @@ Status: implemented 本决策建立在[会话投影的 host 状态与客户端视图](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md)所定义的拆分之上。 -每个 host 读取方都把投影注册表及其所需 key 视为必需状态。插件将 `sessionProjections` 声明为必需注入,并在暴露读取方前等待获取完成。正式组合在这些插件之前挂载注册表。`ApiProxyService` 采用必需注入形式;较低层的 `createApiProxy` factory 对隔离测试和诊断保持容错。 +每个 host 状态所有者都把投影注册表及其所需 key 视为必需状态。所有者将 `sessionProjections` 声明为必需注入,并在提供服务方法前等待获取完成;其他消费方依赖这些服务。正式组合在这些插件之前挂载注册表。`ApiProxyService` 采用必需注入形式;较低层的 `createApiProxy` factory 对隔离测试和诊断保持容错。 -读取方通过[等待注册与保留句柄](2026-10-12-session-projection-registration-handles.zh.md)获取状态。该决策取代可选首次访问获取和返回裸释放函数的方式,保留这里的必需状态规则:缺少注册表不能静默产生默认 host 状态。客户端视图仍只包含消费字段;类型化句柄读取提供更丰富的 host 状态。 +所有者通过[等待注册与保留句柄](2026-10-12-session-projection-registration-handles.zh.md)获取状态。该决策取代可选首次访问获取和返回裸释放函数的方式,保留这里的必需状态规则:缺少注册表不能静默产生默认 host 状态。客户端视图仍只包含消费字段;业务服务方法提供各消费方所需的 host 数据。 `onChanged` 只发布客户端可见值的变化。注册变化不会创建第二条 Host 事件流或客户端 tombstone 协议。后续权威 history 或 list 基线会反映活跃 key 集。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml index c22458657b..dd6e68b389 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml @@ -9,11 +9,11 @@ en: 47ba548b869be190 zh: c2e5038a56fe741c /agent-note-asynchronous-projection-registration-and-retained-readers/decision: - en: 19064b5aecb26f57 - zh: 075bb87c6a5299ae + en: 9e56d4bcbdc3a291 + zh: bebaaab338a15e40 /agent-note-asynchronous-projection-registration-and-retained-readers/alternatives-considered: en: ee5b0cd715ed1fef zh: 6b1478dcde21916d /agent-note-asynchronous-projection-registration-and-retained-readers/consequences: - en: 4b5e4bb446872545 - zh: 9fb2cc8ae77fb823 + en: 6f0e04ef7924e2c6 + zh: e3730273e323440d diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md index ea16e10828..90598d8f9f 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md @@ -10,11 +10,11 @@ Host readers coupled to registry key lookup lose access when a plugin unloads, a ## Decision -`SessionProjectionRegistry.register` returns a promise for a typed handle. Consumers await it during activation before exposing dependent operations. Function plugins retain their namespace exports and use asynchronous `apply`; services use their initialization hook. Definitions remain with their domain owner, and each reader registers the required definition instead of looking up a possibly absent key. This supersedes the optional first-access acquisition and disposer-return details in [the mandatory-reader decision](2026-08-19-session-projection-mandatory-seam.md), while retaining its mandatory-state and cropped-client-view rules. +`SessionProjectionRegistry.register` returns a promise for a typed handle. Domain owners await it during activation before exposing dependent operations. Function plugins retain their namespace exports and use asynchronous `apply`; services use their initialization hook. Each domain service keeps its definitions and handles internal and exposes methods returning business data. Consumers depend on that service rather than registering its definitions or receiving projection readers. This supersedes the optional first-access acquisition and disposer-return details in [the mandatory-reader decision](2026-08-19-session-projection-mandatory-seam.md), while retaining its mandatory-state and cropped-client-view rules. A handle owns one reference to a shared definition and its per-Session cells. Its disposer releases global participation exactly once; the final release removes the unit from live drives, snapshots, and checkpoints. Its reader remains usable after release, folding unseen events with the original definition. A later registration at a different state version does not replace an older handle's definition. Registration checks owner liveness after its asynchronous handoff and rejects if the owner unloaded before receiving the handle. -AgentLoop acquires inbox and turn-boundary handles once before publishing its factory. Each Agent receives those readers during synchronous construction. Cleanup can use them after the loop fiber removes global registrations, so projection release need not wait for every Agent teardown. This preserves the existing creation/setup sequence without per-Agent registration or an extra initialization state. +AgentRegistry owns turn and step state and exposes `turnBoundaryOf(session)`. AgentLoop acquires its inbox handle once before publishing its factory, reads the last turn before creating an Agent scope, and passes an inbox callback plus that turn number into synchronous Agent construction. The callback retains its handle through cleanup after the loop fiber removes global registrations. This preserves the existing creation/setup sequence without per-Agent registration or an extra initialization state. ## Alternatives considered @@ -28,4 +28,4 @@ AgentLoop acquires inbox and turn-boundary handles once before publishing its fa ## Consequences -Consumers must migrate acquisition and retain handles. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown. +Projection owners must migrate acquisition and retain handles; other consumers use the owning services. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown. diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md index 28974a237b..1043a77972 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md @@ -10,11 +10,11 @@ Status: implemented ## Decision -`SessionProjectionRegistry.register` 返回类型化句柄的 Promise。消费者在激活期间等待它,再暴露依赖操作。函数插件保留命名空间导出并使用异步 `apply`;服务使用初始化钩子。定义仍归领域拥有方,每个读取方注册所需定义,不再查询可能不存在的键。这取代了[必需读取方决策](2026-08-19-session-projection-mandatory-seam.zh.md)中的可选首次访问获取方式与返回释放函数的细节,同时保留必需状态和裁剪 Client 视图的规则。 +`SessionProjectionRegistry.register` 返回类型化句柄的 Promise。领域所有者在激活期间等待它,再提供依赖操作。函数插件保留命名空间导出并使用异步 `apply`;服务使用初始化钩子。领域服务在内部持有定义和句柄,通过方法返回业务数据。消费方依赖该服务,无需注册其定义或接收投影读取句柄。这取代了[必需读取方决策](2026-08-19-session-projection-mandatory-seam.zh.md)中的可选首次访问获取方式与返回释放函数的细节,同时保留必需状态和裁剪 Client 视图的规则。 句柄拥有共享定义及其逐 Session 单元的一次引用。其释放函数恰好一次地解除全局参与;最后一次释放会从实时推进、快照和检查点中移除单元。读取句柄在释放后仍可使用原定义折叠未读取事件。后续注册的其他状态版本不会替换旧句柄的定义。注册在异步交接后检查 owner 是否存活;如果 owner 在收到句柄前卸载,注册拒绝。 -AgentLoop 在发布工厂前只获取一次 inbox 与轮次边界句柄。每个 Agent 在同步构造时收到这些读取句柄。loop fiber 移除全局注册后,清理过程仍可使用它们,因此投影释放无需等待每个 Agent teardown。这保留既有创建与 setup 顺序,无需逐 Agent 注册或增加初始化状态。 +AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`。AgentLoop 在发布工厂前只获取一次 inbox 句柄,在创建 Agent 作用域前读取最后轮次,并将 inbox 回调与该轮次编号传入同步 Agent 构造过程。loop fiber 移除全局注册后,回调仍在清理期间保留其句柄。这保留既有创建与 setup 顺序,无需逐 Agent 注册或增加初始化状态。 ## Alternatives considered @@ -28,4 +28,4 @@ AgentLoop 在发布工厂前只获取一次 inbox 与轮次边界句柄。每个 ## Consequences -消费者必须迁移获取方式并保留句柄。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。 +投影所有者必须迁移获取方式并保留句柄;其他消费方使用所属服务。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。 diff --git a/benchmarks/session-corpus/projection-list.worker.ts b/benchmarks/session-corpus/projection-list.worker.ts index 48a751aaa4..3dbe0a2d3f 100644 --- a/benchmarks/session-corpus/projection-list.worker.ts +++ b/benchmarks/session-corpus/projection-list.worker.ts @@ -171,8 +171,8 @@ async function seed(root: string, workload: typeof WORKLOADS[keyof typeof WORKLO async function mountHost(ctx: Context, root: string, workload: typeof WORKLOADS[keyof typeof WORKLOADS], observation: ViewObservation, listWorkSliceMs?: number): Promise { await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.sessionProjections.register({ key: KEY, stateVersion: 1, stateSchema, init: header => makeState(Number(header.id.slice('projection-bench-'.length)), workload.baseRows), diff --git a/benchmarks/session-open/session-open.worker.ts b/benchmarks/session-open/session-open.worker.ts index 341053de6f..00d5f17d33 100644 --- a/benchmarks/session-open/session-open.worker.ts +++ b/benchmarks/session-open/session-open.worker.ts @@ -3,7 +3,7 @@ import { performance } from 'node:perf_hooks' import { scheduler } from 'node:timers/promises' import { Context } from '@deepseek-ai/cordis' -import AgentLoop, { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import SessionStore, { @@ -12,7 +12,7 @@ import SessionStore, { SessionLogOffset, SessionPreparation, } from '@deepseek-ai/dsh-session' -import type { AgentHandle } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type AgentHandle } from '@deepseek-ai/dsh-agent' import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionQueryEngine from '@deepseek-ai/dsh-session-query' @@ -128,9 +128,8 @@ function memoryDelta( } } -async function installProjectionSet(ctx: Context, agentLoopOwnsBoundary: boolean): Promise { +async function installProjectionSet(ctx: Context): Promise { // Mirrors projection owners mounted by the base and web-app bundles without timing profile boot. - if (!agentLoopOwnsBoundary) await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.sessionProjections.register(agentPresetProjectionDefinition) await ctx.sessionProjections.register(modelSelectionProjection) await ctx.plugin(SessionTitleService, { @@ -163,9 +162,10 @@ class SessionBenchmarkHost { if (agentScenario) await mountAgentLoopTestDependencies(ctx) else { await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionStore) } - await installProjectionSet(ctx, agentScenario) + await installProjectionSet(ctx) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' }) let history: SessionHistoryController | undefined if (scenario === 'first-history') { diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 5b82618b52..13b0d8b44f 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -27,8 +27,8 @@ en: e6402b28c0ed15fa zh: 6c07be9047b42056 /deepseek-harness-architecture/session-log: - en: 35fcd01052084a07 - zh: e16743dac147cb6e + en: f1729d805c20df36 + zh: 6b71b6039663e2e7 /deepseek-harness-architecture/capability-seams: en: a5bc20c3f2a1b370 zh: 91773a281410b2f4 diff --git a/docs/architecture.md b/docs/architecture.md index b821b9fa92..2e33a1102b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -123,7 +123,7 @@ Session consumers know only the current logical format. Header-only `stat` and ` **Model-visible means logged.** Every model request must be reconstructable from the log. New model-visible inputs require session events. Plugins changing existing message content register [pure message projections](subsystems/session.md#plugin-owned-message-projections); detached readers supply the same definitions explicitly. Tool changes are capability-independent; [Session tool history](../packages/core/session/README.md) supplies provider declarations. -**Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, and carriers batch cropped client views with `snapshot()`. Host readers require the registry during activation, await registration, and retain a handle for synchronous state reads. Releasing registration removes global participation; retained readers continue folding Session events. The shared `turnBoundary` definition belongs to `dsh-agent` ([registration lifecycle](../packages/session/session-projection/README.md)). +**Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, and carriers batch cropped client views with `snapshot()`. Domain services await registration during activation, retain private handles, and expose business methods for other plugins. Releasing registration removes global participation; retained handles continue folding Session events. `AgentRegistry` owns turn and step state and exposes `turnBoundaryOf(session)` ([registration lifecycle](../packages/session/session-projection/README.md)). [Working directories](subsystems/working-directory.md) supply user context and execution paths without changing original project identity, sandbox write roots, or existing process directories. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 403c4f3063..274ff87ae6 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -127,7 +127,7 @@ Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list` **模型可见即已记录。** 每个模型请求都必须能从日志重建。新增模型可见输入需要会话事件。修改现有消息内容的插件注册[纯消息投影](subsystems/session.zh.md#plugin-owned-message-projections),独立读取器显式传入相同的处理器。 工具变更不依赖能力;[Session 工具历史](../packages/core/session/README.zh.md)提供提供方声明。 -**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。host 读取方在激活时要求注册表,等待注册完成,并保留句柄以同步读取状态。释放注册会移除全局参与关系;已有读取方仍可继续折叠 Session 事件。共享的 `turnBoundary` 定义归 `dsh-agent` 所有([注册生命周期](../packages/session/session-projection/README.zh.md))。 +**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。领域服务在激活时等待注册完成,在内部保留句柄,并向其他插件提供业务方法。释放注册会移除全局参与关系;已有句柄仍可继续折叠 Session 事件。`AgentRegistry` 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`([注册生命周期](../packages/session/session-projection/README.zh.md))。 [工作目录](subsystems/working-directory.zh.md) 提供用户上下文和执行路径,不改变原始项目标识、沙箱写入根目录或已有进程目录。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2b9044a6e1..7a43647d96 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -59,7 +59,7 @@ export interface Config { ## `@deepseek-ai/dsh-agent-instructions` -- `inject`: `sessionProjections` · `workingDirectory` +- `inject`: `agents` · `workingDirectory` - `source`: [`packages/context/agent-instructions/src/config.ts:18`](../packages/context/agent-instructions/src/config.ts) ```ts config-catalog @@ -92,7 +92,7 @@ export interface Config { - `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) · `Volatile` (`@deepseek-ai/cosmokit`) -- `source`: [`packages/core/agent-loop/src/index.ts:254`](../packages/core/agent-loop/src/index.ts) +- `source`: [`packages/core/agent-loop/src/index.ts:250`](../packages/core/agent-loop/src/index.ts) ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -137,7 +137,7 @@ export type Config = PresetDefinition ## `@deepseek-ai/dsh-agent-preset-registry` -- `inject`: `loader` · `sessionProjections` +- `inject`: `loader` · `sessionProjections` · `agents` - `refs`: `Volatile` (`@deepseek-ai/cordis`) - `source`: [`packages/preset/agent-preset-registry/src/preset.ts:13`](../packages/preset/agent-preset-registry/src/preset.ts) @@ -950,7 +950,7 @@ export interface StagehandModelConfig { ## `@deepseek-ai/dsh-experimental-claude-code-mods` -- `inject`: `workingDirectory` · `sessionProjections` +- `inject`: `workingDirectory` · `agents` - `source`: [`packages/experimental/claude-code-mods/src/index.ts:53`](../packages/experimental/claude-code-mods/src/index.ts) ```ts config-catalog @@ -1459,8 +1459,8 @@ export interface HmrConfig extends ChokidarOptions { ## `@deepseek-ai/dsh-hooks-claude-code` -- `inject`: `shell` · `sessionProjections` · `workingDirectory` -- `source`: [`packages/experimental/hooks-claude-code/src/index.ts:53`](../packages/experimental/hooks-claude-code/src/index.ts) +- `inject`: `shell` · `agents` · `workingDirectory` +- `source`: [`packages/experimental/hooks-claude-code/src/index.ts:50`](../packages/experimental/hooks-claude-code/src/index.ts) ```ts config-catalog /** Plugin config: where the CC hook config lives + substitution roots. */ @@ -1498,8 +1498,8 @@ export interface Config { ## `@deepseek-ai/dsh-hooks-codex` -- `inject`: `shell` · `sessionProjections` · `workingDirectory` -- `source`: [`packages/experimental/hooks-codex/src/index.ts:53`](../packages/experimental/hooks-codex/src/index.ts) +- `inject`: `shell` · `agents` · `workingDirectory` +- `source`: [`packages/experimental/hooks-codex/src/index.ts:49`](../packages/experimental/hooks-codex/src/index.ts) ```ts config-catalog /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ @@ -2113,7 +2113,7 @@ export interface ReplayModelConfig { ## `@deepseek-ai/dsh-llm-retry` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/llm/llm-retry/src/index.ts:26`](../packages/llm/llm-retry/src/index.ts) +- `source`: [`packages/llm/llm-retry/src/index.ts:25`](../packages/llm/llm-retry/src/index.ts) ```ts config-catalog /** This policy executor has no config; providers own `retryPolicy`. */ @@ -2386,8 +2386,8 @@ export interface Config { ## `@deepseek-ai/dsh-plan-mode` -- `inject`: `tools` · `systemPrompt` · `sessionProjections` -- `source`: [`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts) +- `inject`: `tools` · `systemPrompt` · `sessionProjections` · `agents` +- `source`: [`packages/plan/plan-mode/src/index.ts:69`](../packages/plan/plan-mode/src/index.ts) ```ts config-catalog /** Deployment-owned plan guidance. */ @@ -2945,7 +2945,7 @@ export enum SessionTelemetryMode { ## `@deepseek-ai/dsh-session-title` -- `inject`: `sessions` · `sessionProjections` +- `inject`: `sessions` · `sessionProjections` · `agents` - `source`: [`packages/session/session-title/src/index.ts:56`](../packages/session/session-title/src/index.ts) ```ts config-catalog @@ -3593,7 +3593,7 @@ export type ShellDialect = 'bash' | 'pwsh' ## `@deepseek-ai/dsh-time-context` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/context/time-context/src/index.ts:55`](../packages/context/time-context/src/index.ts) +- `source`: [`packages/context/time-context/src/index.ts:56`](../packages/context/time-context/src/index.ts) ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ @@ -3612,7 +3612,7 @@ export interface Config { ## `@deepseek-ai/dsh-tmux-context` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/context/tmux-context/src/index.ts:45`](../packages/context/tmux-context/src/index.ts) +- `source`: [`packages/context/tmux-context/src/index.ts:47`](../packages/context/tmux-context/src/index.ts) ```ts config-catalog /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ @@ -3781,8 +3781,8 @@ export interface Config { ## `@deepseek-ai/dsh-tool-goal` -- `inject`: `agents` · `goals` · `tools` · `systemPrompt` · `sessionProjections` -- `source`: [`packages/goal/tool-goal/src/index.ts:33`](../packages/goal/tool-goal/src/index.ts) +- `inject`: `agents` · `goals` · `tools` · `systemPrompt` +- `source`: [`packages/goal/tool-goal/src/index.ts:32`](../packages/goal/tool-goal/src/index.ts) ```ts config-catalog /** Model policy and hard lower bounds for goal-state updates. */ @@ -3856,8 +3856,8 @@ export interface Config { ## `@deepseek-ai/dsh-tool-present` -- `inject`: `tools` · `fs` · `sessionProjections` · `workingDirectory` -- `source`: [`packages/deliverables/tool-present/src/index.ts:16`](../packages/deliverables/tool-present/src/index.ts) +- `inject`: `tools` · `fs` · `agents` · `workingDirectory` +- `source`: [`packages/deliverables/tool-present/src/index.ts:15`](../packages/deliverables/tool-present/src/index.ts) ```ts config-catalog /** Per-call delivery limit. */ @@ -3949,8 +3949,8 @@ export interface Config { ## `@deepseek-ai/dsh-tool-session-query` -- `inject`: `tools` · `systemPrompt` · `sessionQuery` · `sessionProjections` -- `source`: [`packages/experimental/tool-session-query/src/index.ts:27`](../packages/experimental/tool-session-query/src/index.ts) +- `inject`: `tools` · `systemPrompt` · `sessionQuery` · `agents` +- `source`: [`packages/experimental/tool-session-query/src/index.ts:28`](../packages/experimental/tool-session-query/src/index.ts) ```ts config-catalog /** Deployment-owned search count and timeout bounds. */ @@ -4006,7 +4006,7 @@ export interface Config { - `inject`: `tools` · `subagents` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.md) -- `source`: [`packages/subagent/tool-subagent/src/index.ts:51`](../packages/subagent/tool-subagent/src/index.ts) +- `source`: [`packages/subagent/tool-subagent/src/index.ts:50`](../packages/subagent/tool-subagent/src/index.ts) ```ts config-catalog /** Config: which registered provider this tool delegates to, plus child defaults. */ @@ -4075,7 +4075,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-todo` - `inject`: `tools` · `sessionProjections` -- `source`: [`packages/todo/tool-todo/src/index.ts:30`](../packages/todo/tool-todo/src/index.ts) +- `source`: [`packages/todo/tool-todo/src/index.ts:29`](../packages/todo/tool-todo/src/index.ts) ```ts config-catalog /** Model-facing todo tool configuration. */ @@ -4519,7 +4519,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `package` | `inject` | `source` | | --- | --- | --- | | `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) | -| `@deepseek-ai/dsh-agent` | — | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) | +| `@deepseek-ai/dsh-agent` | `sessionProjections` | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) | | `@deepseek-ai/dsh-api-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) | | `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) | | `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 59e65918f3..2e40740617 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -61,7 +61,7 @@ export interface Config { ## `@deepseek-ai/dsh-agent-instructions` -- `inject`: `sessionProjections` · `workingDirectory` +- `inject`: `agents` · `workingDirectory` - `source`: [`packages/context/agent-instructions/src/config.ts:18`](../packages/context/agent-instructions/src/config.ts) ```ts config-catalog @@ -94,7 +94,7 @@ export interface Config { - `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md) · `Volatile` (`@deepseek-ai/cosmokit`) -- `source`: [`packages/core/agent-loop/src/index.ts:254`](../packages/core/agent-loop/src/index.ts) +- `source`: [`packages/core/agent-loop/src/index.ts:250`](../packages/core/agent-loop/src/index.ts) ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -139,7 +139,7 @@ export type Config = PresetDefinition ## `@deepseek-ai/dsh-agent-preset-registry` -- `inject`: `loader` · `sessionProjections` +- `inject`: `loader` · `sessionProjections` · `agents` - `refs`: `Volatile` (`@deepseek-ai/cordis`) - `source`: [`packages/preset/agent-preset-registry/src/preset.ts:13`](../packages/preset/agent-preset-registry/src/preset.ts) @@ -952,7 +952,7 @@ export interface StagehandModelConfig { ## `@deepseek-ai/dsh-experimental-claude-code-mods` -- `inject`: `workingDirectory` · `sessionProjections` +- `inject`: `workingDirectory` · `agents` - `source`: [`packages/experimental/claude-code-mods/src/index.ts:53`](../packages/experimental/claude-code-mods/src/index.ts) ```ts config-catalog @@ -1461,8 +1461,8 @@ export interface HmrConfig extends ChokidarOptions { ## `@deepseek-ai/dsh-hooks-claude-code` -- `inject`: `shell` · `sessionProjections` · `workingDirectory` -- `source`: [`packages/experimental/hooks-claude-code/src/index.ts:53`](../packages/experimental/hooks-claude-code/src/index.ts) +- `inject`: `shell` · `agents` · `workingDirectory` +- `source`: [`packages/experimental/hooks-claude-code/src/index.ts:50`](../packages/experimental/hooks-claude-code/src/index.ts) ```ts config-catalog /** Plugin config: where the CC hook config lives + substitution roots. */ @@ -1500,8 +1500,8 @@ export interface Config { ## `@deepseek-ai/dsh-hooks-codex` -- `inject`: `shell` · `sessionProjections` · `workingDirectory` -- `source`: [`packages/experimental/hooks-codex/src/index.ts:53`](../packages/experimental/hooks-codex/src/index.ts) +- `inject`: `shell` · `agents` · `workingDirectory` +- `source`: [`packages/experimental/hooks-codex/src/index.ts:49`](../packages/experimental/hooks-codex/src/index.ts) ```ts config-catalog /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ @@ -2115,7 +2115,7 @@ export interface ReplayModelConfig { ## `@deepseek-ai/dsh-llm-retry` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/llm/llm-retry/src/index.ts:26`](../packages/llm/llm-retry/src/index.ts) +- `source`: [`packages/llm/llm-retry/src/index.ts:25`](../packages/llm/llm-retry/src/index.ts) ```ts config-catalog /** This policy executor has no config; providers own `retryPolicy`. */ @@ -2388,8 +2388,8 @@ export interface Config { ## `@deepseek-ai/dsh-plan-mode` -- `inject`: `tools` · `systemPrompt` · `sessionProjections` -- `source`: [`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts) +- `inject`: `tools` · `systemPrompt` · `sessionProjections` · `agents` +- `source`: [`packages/plan/plan-mode/src/index.ts:69`](../packages/plan/plan-mode/src/index.ts) ```ts config-catalog /** Deployment-owned plan guidance. */ @@ -2947,7 +2947,7 @@ export enum SessionTelemetryMode { ## `@deepseek-ai/dsh-session-title` -- `inject`: `sessions` · `sessionProjections` +- `inject`: `sessions` · `sessionProjections` · `agents` - `source`: [`packages/session/session-title/src/index.ts:56`](../packages/session/session-title/src/index.ts) ```ts config-catalog @@ -3595,7 +3595,7 @@ export type ShellDialect = 'bash' | 'pwsh' ## `@deepseek-ai/dsh-time-context` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/context/time-context/src/index.ts:55`](../packages/context/time-context/src/index.ts) +- `source`: [`packages/context/time-context/src/index.ts:56`](../packages/context/time-context/src/index.ts) ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ @@ -3614,7 +3614,7 @@ export interface Config { ## `@deepseek-ai/dsh-tmux-context` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/context/tmux-context/src/index.ts:45`](../packages/context/tmux-context/src/index.ts) +- `source`: [`packages/context/tmux-context/src/index.ts:47`](../packages/context/tmux-context/src/index.ts) ```ts config-catalog /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ @@ -3783,8 +3783,8 @@ export interface Config { ## `@deepseek-ai/dsh-tool-goal` -- `inject`: `agents` · `goals` · `tools` · `systemPrompt` · `sessionProjections` -- `source`: [`packages/goal/tool-goal/src/index.ts:33`](../packages/goal/tool-goal/src/index.ts) +- `inject`: `agents` · `goals` · `tools` · `systemPrompt` +- `source`: [`packages/goal/tool-goal/src/index.ts:32`](../packages/goal/tool-goal/src/index.ts) ```ts config-catalog /** Model policy and hard lower bounds for goal-state updates. */ @@ -3858,8 +3858,8 @@ export interface Config { ## `@deepseek-ai/dsh-tool-present` -- `inject`: `tools` · `fs` · `sessionProjections` · `workingDirectory` -- `source`: [`packages/deliverables/tool-present/src/index.ts:16`](../packages/deliverables/tool-present/src/index.ts) +- `inject`: `tools` · `fs` · `agents` · `workingDirectory` +- `source`: [`packages/deliverables/tool-present/src/index.ts:15`](../packages/deliverables/tool-present/src/index.ts) ```ts config-catalog /** Per-call delivery limit. */ @@ -3951,8 +3951,8 @@ export interface Config { ## `@deepseek-ai/dsh-tool-session-query` -- `inject`: `tools` · `systemPrompt` · `sessionQuery` · `sessionProjections` -- `source`: [`packages/experimental/tool-session-query/src/index.ts:27`](../packages/experimental/tool-session-query/src/index.ts) +- `inject`: `tools` · `systemPrompt` · `sessionQuery` · `agents` +- `source`: [`packages/experimental/tool-session-query/src/index.ts:28`](../packages/experimental/tool-session-query/src/index.ts) ```ts config-catalog /** Deployment-owned search count and timeout bounds. */ @@ -4008,7 +4008,7 @@ export interface Config { - `inject`: `tools` · `subagents` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.zh.md) -- `source`: [`packages/subagent/tool-subagent/src/index.ts:51`](../packages/subagent/tool-subagent/src/index.ts) +- `source`: [`packages/subagent/tool-subagent/src/index.ts:50`](../packages/subagent/tool-subagent/src/index.ts) ```ts config-catalog /** Config: which registered provider this tool delegates to, plus child defaults. */ @@ -4077,7 +4077,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-todo` - `inject`: `tools` · `sessionProjections` -- `source`: [`packages/todo/tool-todo/src/index.ts:30`](../packages/todo/tool-todo/src/index.ts) +- `source`: [`packages/todo/tool-todo/src/index.ts:29`](../packages/todo/tool-todo/src/index.ts) ```ts config-catalog /** Model-facing todo tool configuration. */ @@ -4521,7 +4521,7 @@ export interface Config { | `package` | `inject` | `source` | | --- | --- | --- | | `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) | -| `@deepseek-ai/dsh-agent` | — | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) | +| `@deepseek-ai/dsh-agent` | `sessionProjections` | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) | | `@deepseek-ai/dsh-api-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) | | `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) | | `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts) | diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 33f30e06ce..183b62184c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:200`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:196`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:69`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 7953fffda4..9015f77ea3 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,7 +10,7 @@ | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:200`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:196`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:69`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index bffa5a533d..2062734ebd 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -21,8 +21,8 @@ en: 9188862058c6986b zh: 9188862058c6986b /session-persistence-event-catalog/events/agent/agent-inbox-spliced-log-only: - en: bc3be962bc8738b1 - zh: e538aa8ff144468f + en: 269a193ddd48917f + zh: 63ef1beb457dbdd9 /session-persistence-event-catalog/events/agent-preset: en: 49891ca9be5ac123 zh: 49891ca9be5ac123 @@ -153,8 +153,8 @@ en: 4294f78d26ed985b zh: 4294f78d26ed985b /session-persistence-event-catalog/events/plan/plan-mode-log-only: - en: 9ae5537eeae238de - zh: 6be761c7fd83fdce + en: 862175bfe8692ef7 + zh: a8ab123bf674cf77 /session-persistence-event-catalog/events/request: en: a8df82440ebdf5f8 zh: a8df82440ebdf5f8 @@ -1395,8 +1395,8 @@ en: a74e3dfa51c987af zh: 6c3c87974d7a7260 /session-persistence-event-catalog/resolved-persistence-types/active~2: - en: c861888d6239b700 - zh: a9b2271d3c6b8737 + en: a3de6a6fe9672c7a + zh: a357481e27b3b81f /session-persistence-event-catalog/resolved-persistence-types/agentpreset: en: 1afbe8196f161601 zh: a320a36a8928817c @@ -1521,8 +1521,8 @@ en: 8a9199b1784c70f5 zh: 76c98bc74686fda0 /session-persistence-event-catalog/resolved-persistence-types/inserted-outcome-removedcount-start: - en: e77d5d524dd8b4ea - zh: 4dac28dda78a510f + en: fb9bed95931d510b + zh: dcaccd4254b4475c /session-persistence-event-catalog/resolved-persistence-types/interrupted-message-step-stream: en: ec10cd8e9c202a81 zh: e08f859bcb13f0d4 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index a0ff256594..f8363dc80e 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -185,7 +185,7 @@ Sources: [`packages/core/session/src/types.ts:432`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:88`](../packages/core/agent/src/types.ts) ### `agent-preset/*` @@ -735,7 +735,7 @@ Source: [`packages/interaction/permission-presets/src/index.ts:59`](../packages/ 'plan/mode': { active: boolean } ``` -Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/src/index.ts) ### `request/*` @@ -5761,7 +5761,7 @@ Array of `unknown` (opaque). SHA-256: `20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5` -Sources: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) +Sources: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/src/index.ts) | Property | Presence | Type | |---|---|---| @@ -6369,7 +6369,7 @@ Sources: [`packages/core/session/src/types.ts:428`](../packages/core/session/src SHA-256: `fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42` -Sources: [`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts) +Sources: [`packages/core/agent/src/types.ts:88`](../packages/core/agent/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 8999e2592c..8344b8cd46 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -187,7 +187,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:88`](../packages/core/agent/src/types.ts) ### `agent-preset/*` @@ -737,7 +737,7 @@ export type SessionEvent = { 'plan/mode': { active: boolean } ``` -来源:[`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/src/index.ts) ### `request/*` @@ -5763,7 +5763,7 @@ SHA-256: `b1664fa89f8eab49a14e7e3168d8bc4da4aa9fd76332f6c4a053d839e5276ae7` SHA-256: `20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5` -来源:[`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/src/index.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6371,7 +6371,7 @@ SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3` SHA-256: `fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42` -来源:[`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:88`](../packages/core/agent/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 7f6f2f787f..3291b0db74 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -22903,7 +22903,7 @@ "digest": "20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5", "names": [], "sources": [ - "packages/plan/plan-mode/src/index.ts:53" + "packages/plan/plan-mode/src/index.ts:52" ] }, { @@ -26047,7 +26047,7 @@ "digest": "fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42", "names": [], "sources": [ - "packages/core/agent/src/types.ts:96" + "packages/core/agent/src/types.ts:88" ] }, { diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 94bed76054..4033bc6756 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -35,3 +35,6 @@ /core/repo-wide-type-patterns/branded-ids: en: 10f8ac0f97e7c54a zh: 75cd2311aabfe464 +/core/turnboundaryprojection: + en: 3892ad06e90b25c1 + zh: ee6f592230ad89df diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 42f6d2e24a..9ae060186b 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -409,6 +409,26 @@ type Branded = string & { readonly [BRAND]: B } The two core IDs are `ToolCallId` (correlates a tool call with its result; dsh-llm) and `SessionId` (the shared live agent and durable session identity; dsh-session). Capability packages brand their own ids too, such as `JobId` in [jobs.md](jobs.md). +## `TurnBoundaryProjection` + +`ctx.agents.turnBoundaryOf(session)` reads recorded turn and step positions without requiring a live Agent. The registry owns this state; consumers do not register its projection definition. + +Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) + +```ts type-equiv +/** Turn and step positions recorded in a Session. */ +interface TurnBoundaryProjection { + /** Seq of the open turn's `turn/start`, or null between turns. */ + readonly openTurnStartSeq: OptionalSessionSeq + /** Seq of the latest `step/start` event, or null before the first step. */ + readonly lastStepStartSeq: OptionalSessionSeq + /** The latest step boundary (`step/start` or `step/end`) and its seq, or null before the first step boundary. */ + readonly lastStepBoundary: { readonly kind: 'start' | 'end'; readonly seq: SessionSeq } | null + /** Turn number of the latest `turn/start`; 0 before the first turn. */ + readonly lastTurn: number +} +``` + @@ -489,6 +509,13 @@ Source: [`packages/core/agent-loop/src/index.ts`](../../packages/core/agent-loop Registry of YAML-declared presets and the revisions live Agents retain. ```ts cordis-catalog +/** + * Read the preset recorded for a Session, including selections no longer in the roster. + * @param session - Session whose committed preset selection is read. + * @returns the recorded preset, or null when none was selected. + */ +presetForSession(session: Session): string | null + /** Register and eagerly load a definition; activation failure remains visible in the roster. * @param definition Parsed configuration supplied by the declaring plugin. * @returns Definition disposer after activation or its diagnostic settles; the declaring plugin owns it. @@ -578,7 +605,7 @@ async acquireScope(id?: string): Promise<{ key: ScopeKey } & AsyncDisposable> compositionInventory(): Promise ``` -Types: [ScopeKey](scope.md) +Types: [ScopeKey](scope.md) · [Session](session.md) Source: [`packages/preset/agent-preset-registry/src/index.ts`](../../packages/preset/agent-preset-registry/src/index.ts) @@ -591,6 +618,13 @@ Agent service (`ctx.agents`): tracks live agents and carries the initiating Agen Initiator methods provide same-process causal attribution only. Ambient presence is neither liveness proof nor authorization; subjects and owners remain explicit, as does identity at worker, process, persistence, and wire boundaries. Returned Promise boundaries drain during teardown, except a nested lineage that starts an owning-fiber unload is excluded from its own drain. ```ts cordis-catalog +/** + * Read the turn and step positions recorded in a Session. + * @param session - Session whose committed events are read. + * @returns the current turn and step positions; do not mutate the returned value. + */ +turnBoundaryOf(session: Session): TurnBoundaryProjection + /** * Read the Agent that initiated the inherited asynchronous driver chain. * Use this optional form for logging, tracing, metrics, or host attribution @@ -757,6 +791,8 @@ list(): Agent[] roots(): Agent[] ``` +Types: [Session](session.md) + Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 9714c7c62f..fcd3d50f36 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -421,6 +421,26 @@ type Branded = string & { readonly [BRAND]: B } 两个核心 ID 是 `ToolCallId`(关联工具调用及其结果;dsh-llm)和 `SessionId`(活跃 agent 与持久会话共享的标识;dsh-session)。能力包也会品牌化各自的 id,例如 [jobs.md](jobs.zh.md) 中的 `JobId`。 +## `TurnBoundaryProjection` + +`ctx.agents.turnBoundaryOf(session)` 读取已记录的轮次和步骤位置,无需活跃 Agent。注册表持有此状态;消费方不注册其投影定义。 + +来源: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) + +```ts type-equiv +/** Turn and step positions recorded in a Session. */ +interface TurnBoundaryProjection { + /** Seq of the open turn's `turn/start`, or null between turns. */ + readonly openTurnStartSeq: OptionalSessionSeq + /** Seq of the latest `step/start` event, or null before the first step. */ + readonly lastStepStartSeq: OptionalSessionSeq + /** The latest step boundary (`step/start` or `step/end`) and its seq, or null before the first step boundary. */ + readonly lastStepBoundary: { readonly kind: 'start' | 'end'; readonly seq: SessionSeq } | null + /** Turn number of the latest `turn/start`; 0 before the first turn. */ + readonly lastTurn: number +} +``` + @@ -501,6 +521,13 @@ Source: [`packages/core/agent-loop/src/index.ts`](../../packages/core/agent-loop Registry of YAML-declared presets and the revisions live Agents retain. ```ts cordis-catalog +/** + * Read the preset recorded for a Session, including selections no longer in the roster. + * @param session - Session whose committed preset selection is read. + * @returns the recorded preset, or null when none was selected. + */ +presetForSession(session: Session): string | null + /** Register and eagerly load a definition; activation failure remains visible in the roster. * @param definition Parsed configuration supplied by the declaring plugin. * @returns Definition disposer after activation or its diagnostic settles; the declaring plugin owns it. @@ -590,7 +617,7 @@ async acquireScope(id?: string): Promise<{ key: ScopeKey } & AsyncDisposable> compositionInventory(): Promise ``` -Types: [ScopeKey](scope.zh.md) +Types: [ScopeKey](scope.zh.md) · [Session](session.zh.md) Source: [`packages/preset/agent-preset-registry/src/index.ts`](../../packages/preset/agent-preset-registry/src/index.ts) @@ -603,6 +630,13 @@ Agent service (`ctx.agents`): tracks live agents and carries the initiating Agen Initiator methods provide same-process causal attribution only. Ambient presence is neither liveness proof nor authorization; subjects and owners remain explicit, as does identity at worker, process, persistence, and wire boundaries. Returned Promise boundaries drain during teardown, except a nested lineage that starts an owning-fiber unload is excluded from its own drain. ```ts cordis-catalog +/** + * Read the turn and step positions recorded in a Session. + * @param session - Session whose committed events are read. + * @returns the current turn and step positions; do not mutate the returned value. + */ +turnBoundaryOf(session: Session): TurnBoundaryProjection + /** * Read the Agent that initiated the inherited asynchronous driver chain. * Use this optional form for logging, tracing, metrics, or host attribution @@ -769,6 +803,8 @@ list(): Agent[] roots(): Agent[] ``` +Types: [Session](session.zh.md) + Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index.ts) diff --git a/docs/subsystems/token-meter.i18n.yaml b/docs/subsystems/token-meter.i18n.yaml index 1e666632b5..eff44c4e64 100644 --- a/docs/subsystems/token-meter.i18n.yaml +++ b/docs/subsystems/token-meter.i18n.yaml @@ -11,3 +11,6 @@ /token-meter/tokensurfacenode: en: e445d12b10f651d0 zh: e93be5fb9da1da15 +/token-meter/contextpressureprojection: + en: 78c8308e9f54fc38 + zh: f76658e7a2739807 diff --git a/docs/subsystems/token-meter.md b/docs/subsystems/token-meter.md index 8accb9cf9f..9476165e45 100644 --- a/docs/subsystems/token-meter.md +++ b/docs/subsystems/token-meter.md @@ -53,6 +53,44 @@ interface TokenSurfaceNode { Surface order is authoritative; replacement nodes can have higher durable seqs than later positional nodes. The snapshot is immutable and does not grow when the underlying replay fold advances. +## `ContextPressureProjection` + +`ctx.tokenMeter.contextPressure(session)` synchronously reads the latest recorded context pressure and capacity. These display values do not price a new request; use `measure()` for request pricing. + +Source: [`packages/llm/token-meter/src/projection.ts`](../../packages/llm/token-meter/src/projection.ts) + +```ts type-equiv +/** + * Approximate context occupancy for a status display. + * + * The fields, when present, are deliberately NOT one atomic request + * observation: each is a last-wins record of a different moment. Switching + * models can therefore pair a fresh capacity with the previous route's + * pressure until the next request reports usage. This is an intentional trade + * — the value is a user-facing reference, not a billing or gating input. See + * the token-meter README for the full rationale. + */ +interface ContextPressureProjection { + /** + * Provider-reported prompt size of the most recent request: uncached input + * plus cache reads and writes. Response output is excluded, so this does not + * grow as the current turn streams. Absent until a provider reports usage. + */ + pressureTokens?: number + /** + * What the NEXT request's prompt would cost: {@link pressureTokens} plus the + * heuristic repricing of everything the surface gained or lost since that + * sample. Only the delta is estimated, so the figure stays anchored to the + * provider while still reacting the moment a compaction shadows a span — + * which `pressureTokens` alone cannot do, since compaction reports no usage + * of its own. Absent until a provider reports usage. + */ + projectedTokens?: number + /** Newest recorded route capacity; absent when no adapter advertised one. */ + contextWindow?: number +} +``` + @@ -68,6 +106,13 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp Replay owner for one service-wide estimator and isolated per-session folds. ```ts cordis-catalog +/** + * Read the latest logged context occupancy and its projected surface adjustment. + * @param session - Session whose committed usage and request capacity are read. + * @returns context pressure; fields remain absent until their source events are recorded. + */ +contextPressure(session: Session): ContextPressureProjection + /** * Measure current request pressure and surface through the durable tail. * diff --git a/docs/subsystems/token-meter.zh.md b/docs/subsystems/token-meter.zh.md index 6c149ec3f2..07893eacce 100644 --- a/docs/subsystems/token-meter.zh.md +++ b/docs/subsystems/token-meter.zh.md @@ -53,6 +53,44 @@ interface TokenSurfaceNode { 表层顺序具有权威性;替换节点的持久 seq 可能高于位置排在其后的节点。该快照不可变,不会随底层回放折叠推进而增长。 +## `ContextPressureProjection` + +`ctx.tokenMeter.contextPressure(session)` 同步读取最新记录的上下文压力与容量。这些显示值不为新请求计价;请求计价使用 `measure()`。 + +来源: [`packages/llm/token-meter/src/projection.ts`](../../packages/llm/token-meter/src/projection.ts) + +```ts type-equiv +/** + * Approximate context occupancy for a status display. + * + * The fields, when present, are deliberately NOT one atomic request + * observation: each is a last-wins record of a different moment. Switching + * models can therefore pair a fresh capacity with the previous route's + * pressure until the next request reports usage. This is an intentional trade + * — the value is a user-facing reference, not a billing or gating input. See + * the token-meter README for the full rationale. + */ +interface ContextPressureProjection { + /** + * Provider-reported prompt size of the most recent request: uncached input + * plus cache reads and writes. Response output is excluded, so this does not + * grow as the current turn streams. Absent until a provider reports usage. + */ + pressureTokens?: number + /** + * What the NEXT request's prompt would cost: {@link pressureTokens} plus the + * heuristic repricing of everything the surface gained or lost since that + * sample. Only the delta is estimated, so the figure stays anchored to the + * provider while still reacting the moment a compaction shadows a span — + * which `pressureTokens` alone cannot do, since compaction reports no usage + * of its own. Absent until a provider reports usage. + */ + projectedTokens?: number + /** Newest recorded route capacity; absent when no adapter advertised one. */ + contextWindow?: number +} +``` + @@ -68,6 +106,13 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp Replay owner for one service-wide estimator and isolated per-session folds. ```ts cordis-catalog +/** + * Read the latest logged context occupancy and its projected surface adjustment. + * @param session - Session whose committed usage and request capacity are read. + * @returns context pressure; fields remain absent until their source events are recorded. + */ +contextPressure(session: Session): ContextPressureProjection + /** * Measure current request pressure and surface through the durable tail. * diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index af0cd38b46..0dd8e8ce38 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: 6cc2cd9ced70bb09 zh: 1c0e6cb4ba2ed637 /tool-schema-catalog/tool-package-map: - en: e18a3e3c916afb23 - zh: 4284b50a5e9bd12f + en: fb7e632265a9426c + zh: c2586fe58cb934d5 /tool-schema-catalog/deepseek-ai-dsh-plugin-manager: en: e0143916bb634762 zh: e0143916bb634762 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index f96fe0802d..85df254590 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -20,9 +20,9 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`, `stagehand_extract`, `stagehand_navigate`, `stagehand_observe`, `stagehand_screenshot`, `stagehand_tabs` | `ctx.browserUse`, `ctx.agents`, `ctx.tools`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userQuestions` | `tool/call`, `tool/result after an answer or timeout`, `late user/message` | - | ask_user_question keeps the original blocking behavior by default; set `mode: timed` to opt into a foreground timeout and pending result while the question remains answerable. In timed mode, `timeout: -1` keeps that call blocking indefinitely. | | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.workingDirectory (Agent executions)`, `ctx.tools`, `ctx.ptcRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` (see the PTC mode Agent Note). Under `ptc` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | -| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userQuestions (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-questions seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | +| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agents`, `ctx.userQuestions (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-questions seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.workingDirectory`, `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. With a job registry composed every call registers with the generic `ctx.jobs` runtime as it starts, collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; without one, or with `enableRunInBackground: false`, the tool registers a foreground-only schema without the `run_in_background` parameter. | -| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.workingDirectory`, `ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented after a successful final result`, `tool/result` | - | Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards. | +| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.workingDirectory`, `ctx.tools`, `ctx.fs`, `ctx.agents` | `tool/call`, `deliverables/presented after a successful final result`, `tool/result` | - | Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards. | | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.workingDirectory`, `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.shell`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.jobs` runtime and are collected/stopped through the `job_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-shell-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect_list`, `cordis_inspect_query` | `ctx.tools`, `ctx.cordisInspect` | `tool/call`, `tool/result` | - | Creator mode provides two read-only runtime inspection tools. The Cordis host runner supplies the inspection registry; Client queries require a connected page. Author persistent changes as bundles and install them with plugin_manager. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.workingDirectory`, `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | @@ -36,7 +36,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.workingDirectory`, `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-stdio`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflowEngine`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.workingDirectory`, `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | -| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | +| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `ctx.agents`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | | `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance selects its backend through `provider` and reads model-selection settings through `modelSelectionSettings`. Every delegation returns a child id promptly; local children accept follow-ups, while external backends report their single execution automatically. | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named discovery, interruption, and local-message tools over managed subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands and PTY sends are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index f7ed4a8c08..b4f833c305 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -24,9 +24,9 @@ | `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`、`stagehand_extract`、`stagehand_navigate`、`stagehand_observe`、`stagehand_screenshot`、`stagehand_tabs` | `ctx.browserUse`、`ctx.agents`、`ctx.tools`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | - | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`、`ctx.userQuestions` | `tool/call`、`tool/result after an answer or timeout`、`late user/message` | - | ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才启用前台超时与 pending 结果,同时问题仍可回答;timed 模式内 `timeout: -1` 让本次调用无限期阻塞。 | | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.workingDirectory (Agent executions)`、`ctx.tools`、`ctx.ptcRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: ptc` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `ptc` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 | -| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 | +| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agents`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.workingDirectory`、`ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`、`tool/result` | - | bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job 注册表时,每次调用一启动就注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;没有注册表或 `enableRunInBackground: false` 时,工具注册不带 `run_in_background` 参数的纯前台 schema。 | -| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.workingDirectory`、`ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented 在成功的最终结果之后`, `tool/result` | - | 交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。 | +| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.workingDirectory`、`ctx.tools`, `ctx.fs`, `ctx.agents` | `tool/call`, `deliverables/presented 在成功的最终结果之后`, `tool/result` | - | 交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。 | | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.workingDirectory`、`ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect_list`, `cordis_inspect_query` | `ctx.tools`, `ctx.cordisInspect` | `tool/call`, `tool/result` | - | 创造模式提供两个只读运行时检查工具。Cordis host runner 提供检查注册表;Client 查询需要已连接页面。持久化变更编写为组合包,再通过 plugin_manager 安装。 | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.workingDirectory`、`ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 | @@ -40,7 +40,7 @@ | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.workingDirectory`、`ctx.tools`、`ctx.lsp`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,因此其模型可见 schema 在更换提供方时保持稳定。运行时要求已注册提供方,例如 `@deepseek-ai/dsh-lsp-stdio`;如果没有提供方,查询会返回结构化 `LSP_UNAVAILABLE` 错误,而不会改变 schema。 | | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`、`ctx.workflowEngine`、`ctx.subagents`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents every fresh round)` | `tool/call`、`tool/result`、`workflow and child session events during execution` | - | 固定的前台工作流会在每个 Round 启动一个全新的结构化子级;模型只能选择不可变目标和可选的 Round 上限。 | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.workingDirectory`、`ctx.tools`、`ctx.agents`、`ctx.skills` | `tool/call`、`tool/result`、`user/message replacement catalogs via agent.inject()` | - | - | -| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 | +| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`ctx.agents`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 | | `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `provider` 选择后端,通过 `modelSelectionSettings` 读取模型选择设置。每次委派都会立即返回子代理 id;本地子代理接受后续消息,外部后端自动汇报单次执行结果。 | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是用于发现、中断托管 subagent 和向本地子代理发送消息的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令与 PTY 发送 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.i18n.yaml index 5d03bdc8c8..9af48f5ce7 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 727968e582c6eee6 zh: e01dc83384747012 /session-projection-registration-handles/change: - en: 06c3d4ee9031fb7e - zh: cf2b664bfdafe837 + en: 154904370da3336a + zh: 8e508300cf097a27 /session-projection-registration-handles/migration: - en: 50cde783ecaa35e8 - zh: 42e5574f58b049d6 + en: 2d27fb8cc2f18a79 + zh: 6d8461cd25c3a608 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md index 20d11a2b03..4014d86b93 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md +++ b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md @@ -10,12 +10,12 @@ English | [中文](guide.zh.md) `@deepseek-ai/dsh-session-projection` changes `SessionProjectionRegistry.register(definition)` from a synchronous disposer to `Promise>`. Registry-level `stateOf(session, key)` is removed. Registration errors now reject the returned promise. After the last registration is disposed, the unit leaves snapshots, checkpoints, and live notifications, but retained handles can still fold Session events on demand. -`@deepseek-ai/dsh-subagent` now requires `sessionProjections`. `@deepseek-ai/dsh-api-session-controller` removes `installModelSelectionProjection`; consumers register `modelSelectionProjection` instead. The controller also registers `agentPreset`, whose null value means the Session has no recorded preset; that key does not establish that a preset registry is available. `turnBoundaryProjectionDefinition` is exported by `@deepseek-ai/dsh-agent`; its `agent-loop` re-export remains available. +`@deepseek-ai/dsh-agent` and `@deepseek-ai/dsh-subagent` require `sessionProjections`. AgentRegistry owns turn and step state; consumers use `ctx.agents.turnBoundaryOf(session)` instead of importing `turnBoundaryProjectionDefinition`. Preset and context-pressure reads use `ctx.agentPresets.presetForSession(session)` and `ctx.tokenMeter.contextPressure(session)`. The API controller and mods bridge no longer register those definitions independently; their optional-service behavior applies when the owning service is absent. `@deepseek-ai/dsh-api-session-controller` also removes `installModelSelectionProjection`. ## Migration -1. Declare `sessionProjections` as a required plugin injection and mount `SessionProjectionRegistry` before consumers, including `SubagentRuntime` in custom compositions. -2. In an asynchronous function-plugin `apply()` or service `[Service.init]()`, await `ctx.sessionProjections.register(definition)` and retain the returned handle before publishing dependent operations. Keep existing function-plugin namespace exports. -3. Replace `registry.stateOf(session, key)` with `handle.stateOf(session)`. Import the owning package's projection definition when a reader needs to acquire a handle itself. Replace stored unregister callbacks with `handle.dispose`, which always returns `Promise`, and await explicit disposal; fiber disposal still releases registrations automatically. Await registration when handling initialization failures. +1. Mount `SessionProjectionRegistry` before `AgentRegistry`, `SubagentRuntime`, and other projection owners in custom compositions. Consumers of domain data inject the owning service. +2. For a plugin's own definitions, await `ctx.sessionProjections.register(definition)` in asynchronous `apply()` or `[Service.init]()` and retain the handle privately before publishing dependent operations. Keep existing function-plugin namespace exports. +3. Replace `registry.stateOf(session, key)` with a business service method for another domain, or `handle.stateOf(session)` inside the owner. Replace stored unregister callbacks with `handle.dispose`, which always returns `Promise`, and await explicit disposal; fiber disposal still releases registrations automatically. Await registration when handling initialization failures. 4. Replace `installModelSelectionProjection(ctx)` with `await ctx.sessionProjections.register(modelSelectionProjection)`, importing the definition from `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts`. 5. Typecheck custom plugins, then verify activation, a Session state read, and owner disposal. A released reader remains usable, while the released unit disappears from new snapshots once its final registration is gone. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.zh.md index fd52f33077..b48ff66064 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.zh.md @@ -10,12 +10,12 @@ description: "Session 投影注册改为异步,并返回保留读取能力的 `@deepseek-ai/dsh-session-projection` 的 `SessionProjectionRegistry.register(definition)` 从同步释放函数改为返回 `Promise>`。注册表级 `stateOf(session, key)` 已移除,注册错误现在使返回的 Promise 拒绝。最后一次注册释放后,该投影单元不再参与快照、检查点和实时通知,但保留的句柄仍可按需折叠 Session 事件。 -`@deepseek-ai/dsh-subagent` 现在必需注入 `sessionProjections`。`@deepseek-ai/dsh-api-session-controller` 移除了 `installModelSelectionProjection`;消费者改为注册 `modelSelectionProjection`。控制器也注册 `agentPreset`,其 null 值表示 Session 未记录预设;该键不代表预设注册表可用。`turnBoundaryProjectionDefinition` 由 `@deepseek-ai/dsh-agent` 导出;`agent-loop` 的再导出仍然可用。 +`@deepseek-ai/dsh-agent` 与 `@deepseek-ai/dsh-subagent` 依赖 `sessionProjections`。AgentRegistry 持有轮次和步骤状态;消费方使用 `ctx.agents.turnBoundaryOf(session)`,不再导入 `turnBoundaryProjectionDefinition`。预设与上下文压力分别通过 `ctx.agentPresets.presetForSession(session)` 和 `ctx.tokenMeter.contextPressure(session)` 读取。API 控制器与 mods 桥接不再独立注册这些定义;所属服务缺席时采用各自的可选服务行为。`@deepseek-ai/dsh-api-session-controller` 同时移除了 `installModelSelectionProjection`。 ## 迁移 -1. 将 `sessionProjections` 声明为插件的必需注入,并在消费者之前挂载 `SessionProjectionRegistry`,包括自定义组合中的 `SubagentRuntime`。 -2. 在函数插件的异步 `apply()` 或服务的 `[Service.init]()` 中,等待 `ctx.sessionProjections.register(definition)`,在发布依赖操作前保留返回的句柄。保留既有的函数插件命名空间导出。 -3. 将 `registry.stateOf(session, key)` 替换为 `handle.stateOf(session)`。读取方需要自行取得句柄时,导入归属包的投影定义。将保存的注销回调替换为始终返回 `Promise` 的 `handle.dispose`,并等待显式释放完成;fiber 释放仍会自动解除注册。处理初始化错误时须等待注册完成。 +1. 在自定义组合中,先挂载 `SessionProjectionRegistry`,再挂载 `AgentRegistry`、`SubagentRuntime` 及其他投影所有者。领域数据的消费方注入所属服务。 +2. 对插件自己的定义,在异步 `apply()` 或 `[Service.init]()` 中等待 `ctx.sessionProjections.register(definition)`,并在提供依赖操作前于内部保存句柄。保留现有函数插件命名空间导出。 +3. 将 `registry.stateOf(session, key)` 替换为其他领域的业务服务方法,或所有者内部的 `handle.stateOf(session)`。将保存的取消注册回调替换为始终返回 `Promise` 的 `handle.dispose`,并等待显式释放完成;fiber 释放仍会自动释放注册。处理初始化失败时须等待注册。 4. 将 `installModelSelectionProjection(ctx)` 替换为 `await ctx.sessionProjections.register(modelSelectionProjection)`,从 `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts` 导入该定义。 5. 对自定义插件执行类型检查,再验证激活、Session 状态读取与 owner 释放。已释放的读取句柄仍可使用,而投影单元在最后一次注册解除后会从新快照中消失。 diff --git a/packages/api/job-controller/tests/host-harness.ts b/packages/api/job-controller/tests/host-harness.ts index 85609166b0..4a8fca3bfe 100644 --- a/packages/api/job-controller/tests/host-harness.ts +++ b/packages/api/job-controller/tests/host-harness.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import type { Agent } from '@deepseek-ai/dsh-agent' import type { JobAppendOptions, JobHandle, JobOutcome } from '@deepseek-ai/dsh-jobs' @@ -9,6 +10,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' /** A registry with a tiny ring so eviction is cheap to reach. */ export async function harness(): Promise { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry, { retainBytes: 64, settledRetainBytes: 64 }) ctx.jobs.attachController('job-controller-test') diff --git a/packages/api/job-controller/tests/kill.host.spec.ts b/packages/api/job-controller/tests/kill.host.spec.ts index 2a14afac2d..bfaf0c2141 100644 --- a/packages/api/job-controller/tests/kill.host.spec.ts +++ b/packages/api/job-controller/tests/kill.host.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import type { Agent } from '@deepseek-ai/dsh-agent' import type { JobId, JobOutcome } from '@deepseek-ai/dsh-jobs' @@ -45,6 +46,7 @@ async function harness(): Promise<{ }> { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) ctx.jobs.attachController('kill-test') diff --git a/packages/api/job-controller/tests/observe.host.spec.ts b/packages/api/job-controller/tests/observe.host.spec.ts index 583862eeac..92c680ed07 100644 --- a/packages/api/job-controller/tests/observe.host.spec.ts +++ b/packages/api/job-controller/tests/observe.host.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import type { Agent } from '@deepseek-ai/dsh-agent' import { JobId } from '@deepseek-ai/dsh-jobs' @@ -215,6 +216,7 @@ describe('JobController', () => { async function controllerHarness() { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(TypertRegistry) @@ -247,6 +249,7 @@ describe('JobController', () => { it('waits for the job registry instead of serving without one', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TypertRegistry) ctx.plugin(JobController, {}) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 87382e312d..ff8ee89c2f 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: ffe8f507e1ac31c7 - zh: 3cd25c71b42a85c1 + en: 8fcfa14545bd9d1c + zh: c10c2bba792699ef /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 66a7186d60..fbc0aeaa3b 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -27,7 +27,7 @@ Desktop analytics follows the [product collection policy](../../client/product-a ## Use this package -The controller registers the `agentPreset` projection independently of preset execution support. Its value is the recorded preset id or null; key presence does not indicate that `agentPresets` is composed. +The controller reads a Session's recorded preset through the optional `agentPresets.presetForSession(session)` service method. A null result means no preset was recorded; an absent service means preset identity is unavailable to the controller. History pages and follow opening snapshots carry one `{ type: 'event', event: SessionWireEvent }` record per durable Session event. The Client retains each accepted record as one durable `SessionEventLikeEntry`; Assistant token boundaries remain inside the compact stream on `assistant/message` or `assistant/attempt`. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index a4406446ae..d63735689f 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" ## 使用本包 -控制器独立于预设执行能力注册 `agentPreset` 投影。其值为已记录的预设 id 或 null;键存在不代表组合中包含 `agentPresets`。 +控制器通过可选服务方法 `agentPresets.presetForSession(session)` 读取 Session 已记录的预设。null 结果表示未记录预设;服务缺席表示控制器无法获知预设身份。 历史页与 follow opening 快照为每个持久 Session 事件携带一条 `{ type: 'event', event: SessionWireEvent }` record。Client 把每条已接受 record 保留为一个持久 `SessionEventLikeEntry`;Assistant token 边界保留在 `assistant/message` 或 `assistant/attempt` 的紧凑流内。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;控制器不解析工具定义、不运行展示转换器,也不附加 UI 数据。 diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index 697cfb91de..6c967c5c10 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -3,12 +3,12 @@ import { randomUUID } from 'node:crypto' import { mkdir } from 'node:fs/promises' import type { Context } from '@deepseek-ai/cordis' -import { installModelSelection, turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' +import { installModelSelection } from '@deepseek-ai/dsh-agent' import type { - Agent, AgentOptions, AgentSetup, ModelSelection as AgentModelSelection, ModelSelectionRef, TurnBoundaryProjection, + Agent, AgentOptions, AgentSetup, ModelSelection as AgentModelSelection, ModelSelectionRef, } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' -import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' +import type {} from '@deepseek-ai/dsh-agent-preset-registry' import { brandString } from '@deepseek-ai/dsh-brand' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -171,8 +171,6 @@ export async function inspectApiSession( /** Owns every operation that may create, resume, or configure a Web Agent. */ export class ApiSessionAgentController { private modelSelection!: ProjectionHandle - private agentPreset!: ProjectionHandle - private turnBoundary!: ProjectionHandle private readonly resumes = new Map>() private readonly claims = new Map>() private readonly selections = new WeakMap() @@ -188,8 +186,6 @@ export class ApiSessionAgentController { async initialize(): Promise { const ctx = this.ctx this.modelSelection = await ctx.sessionProjections.register(modelSelectionProjection) - this.agentPreset = await ctx.sessionProjections.register(agentPresetProjectionDefinition) - this.turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) ctx.typert.lookups.configure('agent', async (sessionId: SessionId) => { const found = await this.resolveAgent(sessionId) if ('error' in found) throw found.error @@ -373,10 +369,10 @@ export class ApiSessionAgentController { /** * Read the current Agent preset from the Session projection. * @param session - live Session whose projection state is available. - * @returns the current preset, or undefined when the Session has no recorded preset. + * @returns the current preset, or undefined without a preset service or recorded selection. */ presetForSession(session: Session): string | undefined { - return this.agentPreset.stateOf(session) ?? undefined + return this.ctx.get('agentPresets')?.presetForSession(session) ?? undefined } /** @@ -554,7 +550,7 @@ export class ApiSessionAgentController { /** Whether no turn has started, by the predicate the preset registry's `select` enforces. */ private isBlank(session: Session): boolean { - const boundary = this.turnBoundary.stateOf(session) + const boundary = this.ctx.agents.turnBoundaryOf(session) return boundary.openTurnStartSeq === null && boundary.lastTurn === 0 } @@ -601,7 +597,7 @@ export class ApiSessionAgentController { /** * Read the current Agent preset from an all-projections observation. * @param observation - exact Session observation carrying its projection snapshot. - * @returns the current preset, or undefined when the Session has no recorded preset. + * @returns the current preset, or undefined when the capability is absent. */ presetForObservation(observation: SessionObservation): string | undefined { if (observation.projections === undefined) { diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 030db3eae1..9dba50297c 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionId } from '@deepseek-ai/dsh-session' @@ -34,6 +35,7 @@ async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentControl roots.push(ctx) await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) await ctx.sessionProjections.register(agentPresetProjectionDefinition) diff --git a/packages/api/session-controller/tests/archived-session-gate.host.spec.ts b/packages/api/session-controller/tests/archived-session-gate.host.spec.ts index 79bad41b7c..d7ab911e57 100644 --- a/packages/api/session-controller/tests/archived-session-gate.host.spec.ts +++ b/packages/api/session-controller/tests/archived-session-gate.host.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -16,6 +17,7 @@ interface Harness { async function harness(): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const archived: SessionId[] = [] ctx.provide('workspaceRegistry', { get archivedSessionIds() { return archived } } as never) @@ -107,6 +109,7 @@ describe('Archived-session gate: lifetime', () => { it('stops rejecting once the plugin fiber is disposed', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const archived: SessionId[] = [] ctx.provide('workspaceRegistry', { get archivedSessionIds() { return archived } } as never) diff --git a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts index f43ce3f116..eb2093ea18 100644 --- a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts +++ b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-preset-registry' import { createUserMessage } from '@deepseek-ai/dsh-llm' @@ -47,6 +48,7 @@ async function baseContext(): Promise { const ctx = new Context() await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) ctx.provide('agentDefaultModel', { diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index ff5f980250..246d512896 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -1,6 +1,7 @@ import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent, Inbox, ModelSelectionRef } from '@deepseek-ai/dsh-agent' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' @@ -36,6 +37,7 @@ async function commandHarness( }> { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) await ctx.sessionProjections.register(subagentIdentityProjectionDefinition) @@ -126,6 +128,7 @@ describe('Session queue commands', () => { it('preserves the cold Agent resolver rejection', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const error = new RemoteError('session/agent-busy', 'owned by a child', { reason: 'subagent-owned' }) const controller = new SessionCommandController(ctx, { diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index 4c7bc2ed5a..aa7422918e 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' @@ -35,6 +36,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(CommandRuntime) const session = ctx.sessions.create(SESSION, { @@ -321,13 +323,10 @@ describe('Session file uploads', () => { new AbortController().signal, ) await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]), USER_SOURCE) - first.ctx.emit('session/event', first.agent.session, { - type: 'user/message', - data: createUserMessage({ - content: [{ type: 'text', text: 'extension event' }], - source: { kind: 'user', rpcId: 1 } as never, - }), - } as never) + first.agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'extension event' }], + source: { kind: 'user', rpcId: 1 } as never, + }), { surfaceOp: 'append' }) expect(first.uploads.resolve(first.agent, observed.receiptId)).toEqual(observed.file) first.agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'observed' }], diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index 45d3a523ec..cbee7aa031 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' @@ -23,6 +24,7 @@ describe('SessionController facade', () => { it('owns Host service methods and publishes Agent lifecycle projections', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const sessionId = SessionId('controller-session') const header: SessionHeader = { @@ -144,6 +146,7 @@ describe('SessionController facade', () => { async (outcome) => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const sessionId = SessionId(`background-${outcome}`) const header: SessionHeader = { @@ -200,6 +203,7 @@ describe('SessionController facade', () => { it('waits for an admitted background promotion during teardown', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const sessionId = SessionId('background-disposal') const header: SessionHeader = { diff --git a/packages/api/session-controller/tests/projection-discovery.host.spec.ts b/packages/api/session-controller/tests/projection-discovery.host.spec.ts index 56dc88fd5d..150b82d415 100644 --- a/packages/api/session-controller/tests/projection-discovery.host.spec.ts +++ b/packages/api/session-controller/tests/projection-discovery.host.spec.ts @@ -47,8 +47,8 @@ async function harness(withPersistence = true) { const ctx = new Context() contexts.push(ctx) await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.sessionProjections.register(subagentCatalogProjectionDefinition) await ctx.sessionProjections.register(titleProjectionDefinition) if (withPersistence) { diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index dd1a68f610..1544d49c4c 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -9,6 +9,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' @@ -81,6 +82,7 @@ describe('sessions.list cold merge', () => { const ctx = new Context() try { await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const historical = header('format-historical', 100) const current = header('format-current', 200) @@ -246,6 +248,7 @@ describe('attached updatedAt tracks human prompts', () => { it('ignores pickup and non-prompt work after the latest human message', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) await new Promise(resolve => setTimeout(resolve, 0)) @@ -390,6 +393,7 @@ describe('Remote Agent and Session lookup policy', () => { it('keeps queue-item-not-found for a cold session when no persistence backend is composed', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), @@ -410,6 +414,7 @@ describe('Remote Agent and Session lookup policy', () => { const ctx = new Context() await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const sessionId = sid('session-remote-cold') const meta = header(sessionId, 1000) @@ -450,6 +455,7 @@ describe('Remote Agent and Session lookup policy', () => { const ctx = new Context() await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const coldId = sid('session-remote-cold-child') const coldMeta = header(coldId, 1000, { @@ -494,6 +500,7 @@ describe('Remote Agent and Session lookup policy', () => { const ctx = new Context() await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const sessionId = sid('session-remote-resumed-child') const meta = header(sessionId, 1000) @@ -529,6 +536,7 @@ describe('subagent ownership fence', () => { it('reads a cold child without an Agent and rejects generic resume or adoption', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const sessionId = sid('session-child') const meta = header('session-child', 1000, { @@ -607,6 +615,7 @@ describe('subagent ownership fence', () => { it('no longer treats a descriptor-only cold child without origin as subagent-owned', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const sessionId = sid('session-legacy-child') const meta = header('session-legacy-child', 1000, { @@ -644,6 +653,7 @@ describe('subagent ownership fence', () => { it('rejects origin-marked and runtime-owned live children from generic controls', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const parentSession = ctx.sessions.create(sid('session-parent'), { meta: { cwd: '/proj' } }) const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent @@ -703,6 +713,7 @@ describe('subagent ownership fence', () => { it('does not classify an ordinary fork from an inherited ancestor descriptor', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const session = ctx.sessions.create(sid('session-ordinary-fork'), { seed: [{ @@ -733,6 +744,7 @@ describe('subagent ownership fence', () => { it('canonicalizes a supplied browser zone on the exact prompt and rejects invalid names', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } }) const followup = vi.fn() @@ -804,6 +816,7 @@ describe('degenerate composition (no persistence, no factory)', () => { it('lists no cold rows and reports an absent point source as not found', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) @@ -825,6 +838,7 @@ describe('degenerate composition (no persistence, no factory)', () => { it('maps a missing direct persistence read to session-not-found', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const inspect = vi.fn() const stat = vi.fn(() => Promise.resolve(undefined)) @@ -851,6 +865,7 @@ describe('sessions.prompt synchronous rejection', () => { it('rejects content without non-whitespace text or an attachment before delivery or Session events', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const session = ctx.sessions.create(sid('session-empty-prompt')) const followup = vi.fn() @@ -947,6 +962,7 @@ describe('sessions.prompt synchronous rejection', () => { it('maps a synchronous send throw (disposed/invalid input) to agent-busy with the reason attached', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const session = ctx.sessions.create(sid('session-throwing')) // A live structural stub whose delivery verbs throw synchronously, the @@ -980,6 +996,7 @@ describe('sessions.prompt synchronous rejection', () => { it('classifies a raced cold-resume ID collision as agent-busy', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const sessionId = sid('race-resume') const meta: SessionHeader = header('race-resume', 1000) diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts index 87fbd2456e..417112f78e 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic' @@ -32,6 +33,7 @@ async function composed(workspaces: readonly Workspace[] = []): Promise const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { personaPrefix: '' }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) ctx.provide('workspaceRegistry', { list: () => workspaces, archivedSessionIds: [] } as never) diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts index bf0c163914..f1954ed935 100644 --- a/packages/api/session-controller/tests/session-history-journal.host.spec.ts +++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent, type AssistantStreamFrame } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionStore from '@deepseek-ai/dsh-session' import { LlmAttemptId, ToolCallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' @@ -50,6 +51,7 @@ function appendExtension(session: Session, type: string, data: unknown): Session async function harness(): Promise<{ ctx: Context }> { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) return { ctx } diff --git a/packages/api/session-controller/tests/session-list-blank.host.spec.ts b/packages/api/session-controller/tests/session-list-blank.host.spec.ts index 7ea81ea4bc..f433d20c1b 100644 --- a/packages/api/session-controller/tests/session-list-blank.host.spec.ts +++ b/packages/api/session-controller/tests/session-list-blank.host.spec.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' @@ -22,6 +23,7 @@ import { createSessionTestRemote, type TestSessionRemote } from './test-remote.t async function harness(): Promise<{ ctx: Context; remote: TestSessionRemote; attach: (session: Session) => Promise }> { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) return { ctx, diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index 6af6983f70..c5619d59aa 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -8,6 +8,7 @@ import { describe, expect, it, vi, onTestFinished } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import AttachmentStore from '@deepseek-ai/dsh-attachment' import LlmRuntime, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' @@ -96,6 +97,7 @@ async function harness(logged?: { await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', [ { provider: 'deepseek-official', id: 'deepseek-chat', name: 'DeepSeek Chat' }, diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index a2ab39254a..157d6202bb 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -2,6 +2,7 @@ import { resolve } from 'node:path' import * as nativeCommand from '@deepseek-ai/dsh-native-command' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionStore from '@deepseek-ai/dsh-session' import FsLocal from '@deepseek-ai/dsh-fs-local' import { onTestFinished } from 'vitest' @@ -14,6 +15,7 @@ import { async function context(): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(FsLocal) onTestFinished(() => ctx.fiber.dispose()) diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts index 459ea0253a..2308ef77c0 100644 --- a/packages/api/session-controller/tests/session-presets.host.spec.ts +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -5,8 +5,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' @@ -64,7 +64,9 @@ async function harness(options: HarnessOptions = {}) { const ctx = new Context() contexts.push(ctx) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) + const preset = await ctx.sessionProjections.register(agentPresetProjectionDefinition) const presets = options.presets const selections: { sessionId: SessionId; agentPreset: string }[] = [] const resumed: SessionId[] = [] @@ -87,6 +89,7 @@ async function harness(options: HarnessOptions = {}) { } ctx.provide('agentPresets', { defaultId: presets[0], + presetForSession: (session: Session) => preset.stateOf(session), resolve: (id?: string) => Promise.resolve(id === undefined ? undefined : options.resolving?.get(id)) .then(() => ({ id: known(id) })), mount: (_ctx: Context, id?: string) => Promise.resolve().then(() => ({ id: usable(id) })), @@ -165,8 +168,6 @@ async function harness(options: HarnessOptions = {}) { defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), cwd, }) - await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) - const preset = await ctx.sessionProjections.register(agentPresetProjectionDefinition) const presetOf = (id: SessionId): string | null | undefined => { const session = ctx.sessions.get(id) return session === undefined ? undefined : preset.stateOf(session) diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index 0b565dfd33..b8848c2084 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -166,8 +166,8 @@ describe('session.history projections block', () => { it('keeps the v0 numeric seed cut on the wire while logical headers expose only lineage', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) const parent = ctx.sessions.create(SessionId('wire-seed-parent'), { meta: { cwd: '/workspace' } }) parent.append('turn/start', { turn: 1 }) parent.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -578,8 +578,8 @@ describe('session.list projections column', () => { await ctx.plugin(StorageJson, { root }) await ctx.plugin(StorageDomain, { backend: 'json' }) await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.sessionProjections.register(privatePromptUnit()) await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) const gateway = await remote(ctx) @@ -633,8 +633,8 @@ describe('session.list projections column', () => { await ctx.plugin(StorageJson, { root }) await ctx.plugin(StorageDomain, { backend: 'json' }) await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.sessionProjections.register(titleProjectionDefinition) await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) return ctx diff --git a/packages/api/session-controller/tests/session-search.host.spec.ts b/packages/api/session-controller/tests/session-search.host.spec.ts index b96b6e9463..9b14011fbe 100644 --- a/packages/api/session-controller/tests/session-search.host.spec.ts +++ b/packages/api/session-controller/tests/session-search.host.spec.ts @@ -57,8 +57,8 @@ function hit(id: string, index = 0): SessionSearchHit { async function baseContext(): Promise { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) return ctx } diff --git a/packages/api/session-controller/tests/session-skills.host.spec.ts b/packages/api/session-controller/tests/session-skills.host.spec.ts index 939ef7a269..5181f37c7b 100644 --- a/packages/api/session-controller/tests/session-skills.host.spec.ts +++ b/packages/api/session-controller/tests/session-skills.host.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' @@ -41,6 +42,7 @@ function observation( async function context(): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) provideWorkingDirectoryFixture(ctx, '/catalog/default') return ctx diff --git a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts index 7d7d5b384f..9534b8b66d 100644 --- a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts +++ b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts @@ -1,6 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SESSION_FORMAT_VERSION, SessionSeq, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' @@ -49,6 +50,7 @@ async function bench() { const ctx = new Context() contexts.push(ctx) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: () => Promise.resolve([header]), @@ -193,6 +195,7 @@ describe('SessionController subagent catalog', () => { const ctx = new Context() contexts.push(ctx) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) const controller = await createSessionTestController(ctx, defaults) diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index cd365762ac..6e692037ae 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -35,7 +35,6 @@ "@deepseek-ai/dsh-home-paths": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", - "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*" }, diff --git a/packages/context/agent-instructions/src/index.ts b/packages/context/agent-instructions/src/index.ts index 31070a9102..ec752e0715 100644 --- a/packages/context/agent-instructions/src/index.ts +++ b/packages/context/agent-instructions/src/index.ts @@ -9,14 +9,12 @@ * @module @deepseek-ai/dsh-agent-instructions */ -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-working-directory' import type { Context } from '@deepseek-ai/cordis' import { isDeepStrictEqual } from 'node:util' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session, UserMessage } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-projection' import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts' import { findProjectRoot, loadBaselineInstructionSet } from './files.ts' @@ -24,6 +22,7 @@ import { trimmedInstructionDigest } from './digest.ts' import { applyInstructionVersionUpdates, baselineInstructionState, + name, reconcileInstructionContext, agentInstructionsMessage, type InstructionVersionCache, @@ -31,8 +30,9 @@ import { } from './state.ts' import { instructionCandidateGroup, instructionScopeKey, scopeForDisplayPath, type AgentInstructionChange } from './render.ts' -export { Config } -export { name } from './state.ts' +export { Config, name } +/** Services required by workspace instruction projection. */ +export const inject = ['agents', 'workingDirectory'] export { discoverBaselineInstructionFiles, loadBaselineInstructions, @@ -44,8 +44,6 @@ export type { export { renderAgentInstructions } from './render.ts' export type { RenderedAgentInstructions, TruncatedInstruction } from './render.ts' -export const inject = ['sessionProjections', 'workingDirectory'] - function visibleBaselineSource( agent: Agent, authorityMessages: readonly UserMessage[], @@ -84,13 +82,7 @@ function filePathFromExecution(exec: ToolExecution): string | undefined { return filePath.length > 0 ? filePath : undefined } -/** Register the agent-instructions plugin after its projections are ready. - * @param ctx - plugin context. - * @param config - workspace instruction policy. - * @returns when the plugin is ready. - */ -export async function apply(ctx: Context, config: Config): Promise { - const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) +export function apply(ctx: Context, config: Config): void { const resolved: ResolvedConfig = resolveConfig(config) const instructionVersions: InstructionVersionCache = new WeakMap() const baselinePreparations = new WeakMap { } const stepIsOpen = (session: Session): boolean => { - const boundary = turnBoundary.stateOf(session) + const boundary = ctx.agents.turnBoundaryOf(session) return boundary.openTurnStartSeq !== null && boundary.lastStepBoundary?.kind === 'start' && boundary.lastStepBoundary.seq > boundary.openTurnStartSeq diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index a4df1f6d21..692bc3e388 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -12,7 +12,7 @@ import type { ContextFormed } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, SessionSeq, type SessionEvent, type SurfaceIntent, type UserMessage } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsDirEntry, @@ -239,8 +239,8 @@ class BlockingReadFileSystem extends RecordingFileSystem { async function mountAgentInstructionsPlugin(ctx: Context, config: AgentInstructionsConfig): Promise>> { if (ctx.get('workingDirectory') === undefined) provideWorkingDirectoryFixture(ctx) - if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) - await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + if (ctx.get('sessionProjections') === undefined) if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) + if (ctx.get('agents') === undefined) await ctx.plugin(AgentRegistry) return ctx.plugin(AgentInstructions, config) } @@ -1248,8 +1248,8 @@ describe('workspace context request injection', () => { } }) - it('requires projections without making the optional filesystem a static dependency', () => { - expect(AgentInstructions.inject).toEqual(['sessionProjections', 'workingDirectory']) + it('requires agents without making the optional filesystem a static dependency', () => { + expect(AgentInstructions.inject).toEqual(['agents', 'workingDirectory']) }) it('does not inject baseline context when no filesystem provider is present', async () => { diff --git a/packages/context/agent-instructions/tsconfig.json b/packages/context/agent-instructions/tsconfig.json index 56237a330b..f8940375c5 100644 --- a/packages/context/agent-instructions/tsconfig.json +++ b/packages/context/agent-instructions/tsconfig.json @@ -23,9 +23,6 @@ { "path": "../../core/session" }, - { - "path": "../../session/session-projection" - }, { "path": "../../core/tools" }, diff --git a/packages/context/file-reference-local/tests/service.spec.ts b/packages/context/file-reference-local/tests/service.spec.ts index 19ec85c3f6..88cef08184 100644 --- a/packages/context/file-reference-local/tests/service.spec.ts +++ b/packages/context/file-reference-local/tests/service.spec.ts @@ -26,9 +26,9 @@ async function harness(): Promise { await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(ToolRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(WorkingDirectory) return ctx } @@ -64,6 +64,7 @@ describe('LocalFileReferenceService', () => { it('defers guidance until optional prompt and tool services become available', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) ctx.provide('workingDirectory', { ensure: vi.fn() }) await ctx.plugin(LocalFileReferenceService) diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index efc8447e90..b896bd5217 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { agentEvents, installModelSelection, type Agent, type ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, installModelSelection, type Agent, type ModelSelectionRef } from '@deepseek-ai/dsh-agent' import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compaction' import LlmRuntime, { createDeveloperMessage, createMessage, createSystemMessage, createToolResultMessage, createUserMessage, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' @@ -50,6 +50,7 @@ async function harness(config: Config = {}): Promise { // The live registry and the title unit it hosts: discovery labels an // attached session from its projection cut, never from its log. await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) // Shipped base values: this suite only needs the unit the service registers. await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 }) await ctx.plugin(TestSessionQueryEngine) diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index d1049f4203..e38ff615fa 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -49,6 +49,7 @@ const timeContextStateSchema = zod.object({ /** Folded time-context readings. */ type TimeContextProjection = zod.infer +/** The agent registry that owns pre-step processing. */ export const inject = ['agents', 'sessionProjections'] /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts index fec71d8d69..fea575008d 100644 --- a/packages/context/tmux-context/src/index.ts +++ b/packages/context/tmux-context/src/index.ts @@ -26,6 +26,7 @@ import type {} from '@deepseek-ai/dsh-session-projection' import type { ShellExecutor, ShellRunResult } from '@deepseek-ai/dsh-shell' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' + declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { /** Location attribution; readers preserve the content without this producer. @@ -39,6 +40,7 @@ declare module '@deepseek-ai/dsh-llm' { /** Cordis plugin name used by loader diagnostics. */ export const name = 'tmux-context' +/** The agent registry that owns pre-step processing. */ export const inject = ['agents', 'sessionProjections'] /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index ae15f20a46..33a7aabeba 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -93,8 +93,8 @@ async function mount( withBash = false, ): Promise<{ ctx: Context; bash: FakeBash | undefined }> { const ctx = new Context() - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) let bash: FakeBash | undefined if (withBash) { await ctx.plugin(FakeBash) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 65797b988f..20154c10fc 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -13,7 +13,6 @@ import type { CancelOptions, InboxTarget, InboxState, - TurnBoundaryProjection, PreStepDecision, RequestErrorAction, } from '@deepseek-ai/dsh-agent' @@ -33,7 +32,6 @@ import type { EpochHeader, RequestContext, Session, SessionId, SessionSeq, TurnE import { canonicalHeader, headerEquals, ToolCallRecovery } from '@deepseek-ai/dsh-session' import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' -import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { Context } from '@deepseek-ai/cordis' import { ReactLoopInbox } from './inbox.ts' import { RuntimeContextProjection } from './runtime-context.ts' @@ -126,15 +124,15 @@ export class ReactLoopAgent implements Agent { public readonly id: SessionId, public readonly options: AgentOptions, public readonly session: Session, - inbox: ProjectionHandle, - boundary: ProjectionHandle, + readInbox: () => InboxState, + lastTurn: number, ) { this.requestSurfaceGeneration = session.surface.contentGeneration this.dispatch = agentEvents(loopCtx, this) this.scope = createScope(loopCtx, this) this.ctx = this.scope.ctx - this.inbox = new ReactLoopInbox(() => inbox.stateOf(session), session, this.dispatch) - this.phase = { kind: 'idle', lastTurn: boundary.stateOf(session).lastTurn } + this.inbox = new ReactLoopInbox(readInbox, session, this.dispatch) + this.phase = { kind: 'idle', lastTurn } this.runtimeContext = new RuntimeContextProjection(this.ctx, session) this.systemPrompt = new SystemPromptProjection(session) } diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 60d4e2e8c5..54476ac913 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -10,18 +10,16 @@ import { Context, FiberState, Service } from '@deepseek-ai/cordis' import { randomUUID } from 'node:crypto' import z from '@deepseek-ai/schemastery' import { brandString } from '@deepseek-ai/dsh-brand' -import { - turnBoundaryProjectionDefinition, - type Agent, - type AgentFactory, - type AgentHandle, - type AgentOptions, - type AgentSetup, - type CreateAgentOptions, - type ResumeAgentOptions, - type SessionStartSource, - type InboxState, - type TurnBoundaryProjection, +import type { + Agent, + AgentFactory, + AgentHandle, + AgentOptions, + AgentSetup, + CreateAgentOptions, + ResumeAgentOptions, + SessionStartSource, + InboxState, } from '@deepseek-ai/dsh-agent' import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { interruptedTurnClosers, SessionLogOffset, SessionPreparation } from '@deepseek-ai/dsh-session' @@ -36,8 +34,6 @@ import { inboxProjectionDefinition } from './inbox.ts' import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts' import type {} from './runtime-context.ts' -export { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' - /** Fiber states that cannot own or serve a new lifecycle. */ const INACTIVE_STATES: ReadonlySet = new Set([ FiberState.UNLOADING, @@ -313,7 +309,6 @@ export class AgentLoop extends Service implements AgentFactory { /** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */ private readonly runtime: { ctx: Context } private inbox!: ProjectionHandle - private turnBoundary!: ProjectionHandle constructor(ctx: Context, config: Config) { super(ctx, 'agentLoop') @@ -323,8 +318,6 @@ export class AgentLoop extends Service implements AgentFactory { maxParallelToolCalls: config.maxParallelToolCalls, } validateConfiguredAgents(this.config.agents) - // Register only after every config validation above has passed, so a - // rejected constructor leaves no projection unit behind. this.ownership = new FactoryOwnership(ctx.fiber) this.runtime = { ctx } } @@ -332,9 +325,8 @@ export class AgentLoop extends Service implements AgentFactory { protected async [Service.init](): Promise { const ctx = this.runtime.ctx const ownership = this.ownership - // Agents retain readers while fiber disposal removes global projection registrations. + // The inbox remains readable until agent teardown finishes. ctx.effect(() => () => ownership.dispose(), 'agentLoop.transactions()') - this.turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) this.inbox = await ctx.sessionProjections.register(inboxProjectionDefinition) ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()') ctx.systemPrompt.variable('provider', context => context.agent?.options.provider) @@ -467,6 +459,7 @@ export class AgentLoop extends Service implements AgentFactory { : new Error(`agent "${id}" creation aborted`, { cause: callerSignal.reason }) } const loopCtx = this.runtime.ctx + const lastTurn = loopCtx.agents.turnBoundaryOf(session).lastTurn // Deactivation fuses three owners, each with its own reason: the caller's // cancellation signal, the owner fiber's unload, and factory teardown. @@ -550,10 +543,10 @@ export class AgentLoop extends Service implements AgentFactory { } } const untrack = this.ownership.track(dispose) - const { inbox, turnBoundary } = this + const inbox = this.inbox try { unfollowOwner = ownerCtx.effect(function* () { - const created = machine = new ReactLoopAgent(loopCtx, id, options, session, inbox, turnBoundary) + const created = machine = new ReactLoopAgent(loopCtx, id, options, session, () => inbox.stateOf(session), lastTurn) machineReady.resolve() // Adopt the scope on its original fiber so it cannot unload before teardown. // A loop already unloading during minting owns the provisional scope itself. diff --git a/packages/core/agent-loop/tests/request-freeze.spec.ts b/packages/core/agent-loop/tests/request-freeze.spec.ts index ccb4c89770..b0b4c22ff8 100644 --- a/packages/core/agent-loop/tests/request-freeze.spec.ts +++ b/packages/core/agent-loop/tests/request-freeze.spec.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { turnBoundaryProjectionDefinition, type Agent } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { createAssistantMessage, createUserMessage, isAgentLoopRequest } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, ToolSchema } from '@deepseek-ai/dsh-llm' @@ -74,9 +74,9 @@ describe('loop-owned request freezing', () => { expect(Object.isFrozen(userEvent.data.content)).toBe(false) expect(Object.isFrozen(assistantEvent.data.message)).toBe(false) ctx.effect(() => ctx.sessions.enter(session)) + const inbox = await loopCtx.sessionProjections.register(inboxProjectionDefinition) const agent = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session, - await loopCtx.sessionProjections.register(inboxProjectionDefinition), - await loopCtx.sessionProjections.register(turnBoundaryProjectionDefinition)) + () => inbox.stateOf(session), ctx.agents.turnBoundaryOf(session).lastTurn) cleanups.push(async () => { agent.cancel({ kind: 'disposed' }) await agent.whenIdle() @@ -126,8 +126,7 @@ describe('loop-owned request freezing', () => { expect(freeze.mock.calls.filter(([value]) => value === userEvent.data)).toHaveLength(1) expect(freeze.mock.calls.filter(([value]) => value === replacement.data)).toHaveLength(1) const resumed = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session, - await loopCtx.sessionProjections.register(inboxProjectionDefinition), - await loopCtx.sessionProjections.register(turnBoundaryProjectionDefinition)) + () => inbox.stateOf(session), ctx.agents.turnBoundaryOf(session).lastTurn) cleanups.push(async () => { resumed.cancel({ kind: 'disposed' }) await resumed.whenIdle() diff --git a/packages/core/agent-loop/tests/resource-lifecycle.spec.ts b/packages/core/agent-loop/tests/resource-lifecycle.spec.ts index 21fcee429f..eececf3f92 100644 --- a/packages/core/agent-loop/tests/resource-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/resource-lifecycle.spec.ts @@ -71,7 +71,7 @@ it.each([0, 1, 2])('retains Agent resources until all work exits with %i failed expect(scopeDisposed).toBe(true) expect(abortHandled).toBe(1) expect(projections.checkpoint(agent.session).inbox).toBeUndefined() - expect(projections.checkpoint(agent.session).turnBoundary).toBeUndefined() + expect(projections.checkpoint(agent.session).turnBoundary).toBeDefined() expect(ctx.agents.get(agent.id)).toBeUndefined() expect(ctx.sessions.get(agent.session.id)).toBeUndefined() }) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index befdc09642..8956e55244 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-agent/use-this-package: - en: b48322540e229083 - zh: 05aec0854397f4c3 + en: 197d9f5c8671d103 + zh: c622822905725d5f /deepseek-ai-dsh-agent/use-this-package/create-or-resume-an-agent: en: e305441a5a4a9b44 zh: 3e997b12ce0ce04c @@ -39,8 +39,8 @@ en: 348b9ecd0890c585 zh: d0f73d28d0d2c6f1 /deepseek-ai-dsh-agent/understand-the-implementation/durable-inbox: - en: f6802c7f94f623b3 - zh: cc3885db80765970 + en: 8f3fe9303468d735 + zh: 52162010ae1ed29e /deepseek-ai-dsh-agent/understand-the-implementation/source-map: en: 7e09b94c79dc97ca zh: 3f9c5455c4dc563a diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 9da1f06f64..5678895970 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -25,7 +25,7 @@ Use `dsh-agent` to create or resume live agents, send follow-up or steering inpu ## Use this package -Mount `dsh-agent` wherever live agents exist: it provides `ctx.agents` and the `Agent` handle that plugins, UI, hooks, and orchestrators work against. The service is inert until a driver registers a factory — the shipped driver is `dsh-agent-loop`, so the smallest useful composition loads both. +Mount `dsh-agent` with `dsh-session-projection`: it provides `ctx.agents`, the `Agent` handle, and `turnBoundaryOf(session)` for recorded turn and step positions. Creating or resuming an Agent also requires a driver to register a factory; the shipped driver is `dsh-agent-loop`. ### Create or resume an agent @@ -86,7 +86,7 @@ The package is built on one separation: the public `Agent` surface and registry ### Durable inbox -`Agent.inbox` exposes only the structural `Inbox` interface and the projection vocabulary stays in this package. dsh-agent-loop owns the package-internal `ReactLoopInbox` and the standard `inbox` projection; constructing its concrete inbox ensures that the projection registry owns one registration for the durable `agent/inbox/spliced` fold. The registry remains the sole owner of the live `{ 'next-turn', 'next-step' }` state. Reconstruction rejects unsafe or out-of-range splice coordinates and duplicate `MessageId` values across both pending lists and reports the offending event seq. +`Agent.inbox` exposes only the structural `Inbox` interface and the projection vocabulary stays in this package. dsh-agent-loop owns the package-internal `ReactLoopInbox` and the standard `inbox` projection; the loop service acquires one registration for the durable `agent/inbox/spliced` fold and gives each inbox a state-reading callback. The registry remains the sole owner of the live `{ 'next-turn', 'next-step' }` state. Reconstruction rejects unsafe or out-of-range splice coordinates and duplicate `MessageId` values across both pending lists and reports the offending event seq. `Inbox` exposes pending `nextTurn` and `nextStep` messages and mutates them through `append`, `prepend`, `replace`, `remove`, `clear`, and `splice`. Ordinary removals and `clear()` are durable cancellations. At a step boundary, the loop's internal implementation claims pending input through pure deletion splices. Live notifications are deliberately per-message and minimal: `agent/inbox/inserted { message }`, `agent/inbox/claimed { message, turn }`, and `agent/inbox/discarded { message }`. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 0166f96a80..cd394d7657 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -在存在实时 agent 的任何地方挂载 `dsh-agent`:它提供 `ctx.agents` 以及插件、UI、钩子和编排器所面向编程的 `Agent` 句柄。在没有驱动器注册工厂之前,该服务保持惰性——随附驱动器是 `dsh-agent-loop`,因此最小的可用组合需要同时加载两者。 +将 `dsh-agent` 与 `dsh-session-projection` 一起挂载:它提供 `ctx.agents`、`Agent` 句柄,以及读取已记录轮次和步骤位置的 `turnBoundaryOf(session)`。创建或恢复 Agent 还需要驱动器注册工厂;随附驱动器是 `dsh-agent-loop`。 ### 创建或恢复 agent @@ -86,7 +86,7 @@ await handle.agent.whenIdle() ### 持久 inbox -`Agent.inbox` 只暴露结构型 `Inbox` 接口,投影词汇仍位于本包。dsh-agent-loop 持有包内部的 `ReactLoopInbox` 与标准 `inbox` 投影;构造具体 inbox 时会确保投影注册表为持久 `agent/inbox/spliced` fold 持有一份注册。注册表继续作为实时 `{ 'next-turn', 'next-step' }` 状态的唯一所有者。重建过程会拒绝不安全或越界的 splice 坐标,以及跨两份待处理列表重复的 `MessageId`,并报告出错事件的 seq。 +`Agent.inbox` 只暴露结构型 `Inbox` 接口,投影词汇仍位于本包。dsh-agent-loop 持有包内部的 `ReactLoopInbox` 与标准 `inbox` 投影;loop 服务为持久 `agent/inbox/spliced` fold 获取一份注册,并向每个 inbox 提供状态读取回调。注册表继续作为实时 `{ 'next-turn', 'next-step' }` 状态的唯一所有者。重建过程会拒绝不安全或越界的 splice 坐标,以及跨两份待处理列表重复的 `MessageId`,并报告出错事件的 seq。 `Inbox` 暴露待处理的 `nextTurn` 与 `nextStep` 消息,并通过 `append`、`prepend`、`replace`、`remove`、`clear` 与 `splice` 变更它们。普通删除和 `clear()` 都是持久取消。在步骤边界,循环的内部实现会通过纯删除 splice 领取待处理输入。实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserted { message }`、`agent/inbox/claimed { message, turn }` 与 `agent/inbox/discarded { message }`。 diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 59b0cc5eba..270aadbef9 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -11,14 +11,16 @@ import { AsyncLocalStorage } from 'node:async_hooks' 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, SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import { installTurnArchiveAdmission } from './archive-admission.ts' -import type { Agent } from './types.ts' +import type { Agent, TurnBoundaryProjection } from './types.ts' import type { AgentOptions, SessionStartSource } from './runtime-types.ts' +import { turnBoundaryProjectionDefinition } from './projection.ts' export * from './runtime-types.ts' export * from './types.ts' -export { turnBoundaryProjectionDefinition } from './projection.ts' +export type * from './projection.ts' export * from './consumed-work.ts' export * from './model-selection.ts' export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' @@ -243,6 +245,9 @@ interface FactorySlot { * nested lineage that starts an owning-fiber unload is excluded from its own drain. */ export class AgentRegistry extends Service { + static inject = ['sessionProjections'] + + private turnBoundaryProjection!: ProjectionHandle private store = new Map() private factory: FactorySlot | undefined private readonly initiators = new AsyncLocalStorage() @@ -284,6 +289,19 @@ export class AgentRegistry extends Service { installTurnArchiveAdmission(ctx, sessionId => this.get(sessionId)) } + protected async [Service.init](): Promise { + this.turnBoundaryProjection = await this.ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + } + + /** + * Read the turn and step positions recorded in a Session. + * @param session - Session whose committed events are read. + * @returns the current turn and step positions; do not mutate the returned value. + */ + turnBoundaryOf(session: Session): TurnBoundaryProjection { + return this.turnBoundaryProjection.stateOf(session) + } + /** * Read the Agent that initiated the inherited asynchronous driver chain. * Use this optional form for logging, tracing, metrics, or host attribution diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 5cbc81df38..2045e9ca11 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -66,15 +66,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { } } -/** - * Turn and step boundaries folded from one agent session log. - * - * Reader contract: the key is registered by `dsh-agent-loop` and absent - * otherwise. Without agent-loop no turn events exist, so readers treat an - * absent key as "no open turn / no boundaries" — capability absence, not a - * corrupt state. A reader whose behavior has no safe fallback for that - * absence (the step-open decision, for example) may fail loud instead. - */ +/** Turn and step positions recorded in a Session. */ export interface TurnBoundaryProjection { /** Seq of the open turn's `turn/start`, or null between turns. */ readonly openTurnStartSeq: OptionalSessionSeq diff --git a/packages/core/agent/tests/agent-initiator.spec.ts b/packages/core/agent/tests/agent-initiator.spec.ts index c86e0edfcf..7b7a5a13bb 100644 --- a/packages/core/agent/tests/agent-initiator.spec.ts +++ b/packages/core/agent/tests/agent-initiator.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { runInNewContext } from 'node:vm' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' @@ -15,6 +16,7 @@ async function harness(): Promise<{ dispose: () => Promise }> { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(AgentRegistry) return { ctx, diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 21d90151cb..39516873f0 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -1,7 +1,8 @@ -import { describe, expect, expectTypeOf, it } from 'vitest' +import { describe, expect, expectTypeOf, it, onTestFinished } from 'vitest' import { Context, Service, symbols } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { @@ -39,8 +40,33 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { } describe('AgentRegistry', () => { + it('waits for its projection owner and reads turn positions without a loop', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const fiber = ctx.plugin(AgentRegistry) + expect(ctx.get('agents')).toBeUndefined() + await ctx.plugin(SessionProjectionRegistry) + await fiber + const session = Session.create(SessionId('turn-positions')) + expect(ctx.agents.turnBoundaryOf(session)).toEqual({ + openTurnStartSeq: null, lastStepStartSeq: null, lastStepBoundary: null, lastTurn: 0, + }) + const turn = session.append('turn/start', { turn: 3 }) + const step = session.append('step/start', { turn: 3, step: 1 }) + expect(ctx.agents.turnBoundaryOf(session)).toEqual({ + openTurnStartSeq: turn.seq, lastStepStartSeq: step.seq, + lastStepBoundary: { kind: 'start', seq: step.seq }, lastTurn: 3, + }) + session.append('turn/end', { turn: 3, reason: { kind: 'completed' } }) + expect(ctx.agents.turnBoundaryOf(session)).toMatchObject({ openTurnStartSeq: null, lastTurn: 3 }) + expect(ctx.sessionProjections.checkpoint(session).turnBoundary).toBeDefined() + await fiber.dispose() + expect(ctx.sessionProjections.checkpoint(session).turnBoundary).toBeUndefined() + }) + it('contributes Agent lookup and scoped Context providers while Typert is live', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) const agentFiber = ctx.plugin(AgentRegistry) await agentFiber await ctx.plugin(TypertRegistry) @@ -69,6 +95,7 @@ describe('AgentRegistry', () => { it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const lifecycle: string[] = [] ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`)) @@ -88,6 +115,7 @@ describe('AgentRegistry', () => { it('rejects an agent whose registry and session identities differ', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const agent = stubAgent('agent-id', { session: Session.create(SessionId('session-id')) }) @@ -98,6 +126,7 @@ describe('AgentRegistry', () => { it('tracks runtime creator ownership separately from registry order', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const root = stubAgent('root') const child = stubAgent('child') @@ -119,6 +148,7 @@ describe('AgentRegistry', () => { it('rolls an entry back and pairs a partially delivered creation when a listener throws', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const lifecycle: string[] = [] ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`)) @@ -132,6 +162,7 @@ describe('AgentRegistry', () => { it('rolls back asynchronous creation rejection and contains every disposal-listener failure', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const warnings: string[] = [] const heard: string[] = [] @@ -155,6 +186,7 @@ describe('AgentRegistry', () => { it('awaits each creation listener before the next listener and registration completion', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const entered = Promise.withResolvers() const release = Promise.withResolvers() @@ -185,6 +217,7 @@ describe('AgentRegistry', () => { it('separates entry from announcement and stale/idempotent detach cannot remove a replacement', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const lifecycle: string[] = [] ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`)) @@ -209,6 +242,7 @@ describe('AgentRegistry', () => { it('defers detach requested by a creation listener until that dispatch unwinds', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const order: string[] = [] const agent = stubAgent('reentrant') @@ -304,6 +338,7 @@ describe('AgentRegistry factory seam', () => { it('requires a factory and delegates through the calling context', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await expect(ctx.agents.create({ sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/) const { factory, calls } = stubFactory() @@ -323,6 +358,7 @@ describe('AgentRegistry factory seam', () => { it('keeps the runtime parent in options separately from the caller context', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const { factory, calls } = stubFactory() ctx.agents.setFactory(factory) @@ -337,6 +373,7 @@ describe('AgentRegistry factory seam', () => { it('rejects a second factory and clears the slot with its owner (HMR)', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const owner = await ctx.plugin(Object.assign((inner: Context) => { inner.agents.setFactory(stubFactory().factory) @@ -349,6 +386,7 @@ describe('AgentRegistry factory seam', () => { it('canonicalizes an already traced Service before tracing it for the caller', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const states = new WeakMap() class TracedFactory extends Service implements AgentFactory { diff --git a/packages/core/agent/tests/archive-admission.spec.ts b/packages/core/agent/tests/archive-admission.spec.ts index 0f84de4fe0..8813d4e66f 100644 --- a/packages/core/agent/tests/archive-admission.spec.ts +++ b/packages/core/agent/tests/archive-admission.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent, AgentCancelCause, AgentStatus, CancelOptions } from '@deepseek-ai/dsh-agent' import type { SessionActivity } from '@deepseek-ai/dsh-workspace' @@ -46,6 +47,7 @@ function stop(ctx: Context, sessionId: SessionId): Promise { describe('Turn archive admission', () => { it('reports the turn family only while the session\'s Agent is running, ahead of later providers', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const agent = await liveAgent(ctx, 'busy', 'running') await liveAgent(ctx, 'quiet', 'idle') @@ -61,6 +63,7 @@ describe('Turn archive admission', () => { it('cancels a running turn with the user cause and without keeping the inbox, and leaves the rest alone', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const running = await liveAgent(ctx, 'stop-me', 'running') const idle = await liveAgent(ctx, 'already-idle', 'idle') @@ -74,6 +77,7 @@ describe('Turn archive admission', () => { it('stops answering once the registry fiber is disposed', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(AgentRegistry) const agent = await liveAgent(ctx, 'watched', 'running') expect(await ask(ctx, agent.id)).toEqual([{ kind: 'turn' }]) diff --git a/packages/deliverables/tool-present/package.json b/packages/deliverables/tool-present/package.json index 6876a3ac26..317eba55d7 100644 --- a/packages/deliverables/tool-present/package.json +++ b/packages/deliverables/tool-present/package.json @@ -42,7 +42,6 @@ "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", - "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*" }, diff --git a/packages/deliverables/tool-present/src/index.ts b/packages/deliverables/tool-present/src/index.ts index afc6ab82a8..41edef2e12 100644 --- a/packages/deliverables/tool-present/src/index.ts +++ b/packages/deliverables/tool-present/src/index.ts @@ -1,15 +1,14 @@ /** Scoped tool that declares filesystem deliveries in their owning Session. */ -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-working-directory' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { FsError } from '@deepseek-ai/dsh-fs' import { defineTool, type ToolExecution } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-session-projection' import type { Session } from '@deepseek-ai/dsh-session' import type { PresentedFile } from './types.ts' +/** Stable Loader identity. */ export const name = 'tool-present' /** Per-call delivery limit. */ @@ -23,16 +22,15 @@ export const Config: z = z.object({ maxFiles: z.number().default(8), }) -export const inject = ['tools', 'fs', 'sessionProjections', 'workingDirectory'] +/** Services used by the scoped delivery tool. */ +export const inject = ['tools', 'fs', 'agents', 'workingDirectory'] /** * Register present with durable file references in its tool result. * @param ctx - agent-scoped services. * @param config - maximum files per call. - * @returns when the plugin is ready. */ -export async function apply(ctx: Context, config: Config): Promise { - const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) +export function apply(ctx: Context, config: Config): void { if (!Number.isSafeInteger(config.maxFiles) || config.maxFiles < 1) { throw new Error('present requires a positive integer maxFiles') } @@ -77,7 +75,7 @@ export async function apply(ctx: Context, config: Config): Promise { }, async execute(args, exec) { if (exec.agent === undefined) throw new Error('present requires an agent Session') - const boundary = turnBoundary.stateOf(exec.agent.session) + const boundary = ctx.agents.turnBoundaryOf(exec.agent.session) if (boundary.openTurnStartSeq === null) throw new Error('present requires an open turn') if (args.files.length === 0 || args.files.length > config.maxFiles) throw new Error(`present accepts 1 to ${config.maxFiles} files`) const cwd = await ctx.workingDirectory.ensure(exec.agent, exec.signal) diff --git a/packages/deliverables/tool-present/tests/present.spec.ts b/packages/deliverables/tool-present/tests/present.spec.ts index 94fae8366f..10d1221db4 100644 --- a/packages/deliverables/tool-present/tests/present.spec.ts +++ b/packages/deliverables/tool-present/tests/present.spec.ts @@ -11,7 +11,6 @@ import { createScope, type Scope } from '@deepseek-ai/dsh-scope' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineTool } from '@deepseek-ai/dsh-tools' import type { PresentedFile } from '../src/types.ts' @@ -59,10 +58,9 @@ async function setup() { cleanups.push(() => ctx.fiber.dispose()) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: root }) - await ctx.plugin(SessionProjectionRegistry) - await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) const fiber = ctx.plugin(Present, { maxFiles: 2 }) await fiber const owner = await agent(ctx, root) @@ -154,7 +152,7 @@ it('validates deployment limits before registering the tool', async () => { cleanups.push(() => ctx.fiber.dispose()) await ctx.plugin(SessionProjectionRegistry) for (const config of [{ maxFiles: 0 }, { maxFiles: 1.5 }, { maxFiles: Number.POSITIVE_INFINITY }]) { - await expect(Present.apply(ctx, config)).rejects.toThrow('positive integer maxFiles') + expect(() => { Present.apply(ctx, config) }).toThrow('positive integer maxFiles') } }) diff --git a/packages/deliverables/tool-present/tsconfig.json b/packages/deliverables/tool-present/tsconfig.json index 0e428995b8..24ab42a2f7 100644 --- a/packages/deliverables/tool-present/tsconfig.json +++ b/packages/deliverables/tool-present/tsconfig.json @@ -26,9 +26,6 @@ { "path": "../../core/session" }, - { - "path": "../../session/session-projection" - }, { "path": "../../core/tools" }, diff --git a/packages/experimental/agent-team/src/index.ts b/packages/experimental/agent-team/src/index.ts index fd1ea84a29..a37e4e22b3 100644 --- a/packages/experimental/agent-team/src/index.ts +++ b/packages/experimental/agent-team/src/index.ts @@ -9,8 +9,8 @@ import { steerHostSubagentPrompt } from '@deepseek-ai/dsh-subagent/internal' import { TeamActivity } from './activity.ts' import { errorMessage, TeamError } from './error.ts' import { TeamJournal } from './journal.ts' -import { teamProjectionDefinition } from './projection.ts' import { TeamRuntimeLifecycle } from './lifecycle.ts' +import { teamProjectionDefinition } from './projection.ts' import { resolveActiveMember, TeamRoster } from './roster.ts' import type { TeamMembership } from './roster.ts' import { TeamTaskBoard } from './task-board.ts' diff --git a/packages/experimental/browser-use-runtime/tests/mcp.spec.ts b/packages/experimental/browser-use-runtime/tests/mcp.spec.ts index d9c4465447..fdb9a6a749 100644 --- a/packages/experimental/browser-use-runtime/tests/mcp.spec.ts +++ b/packages/experimental/browser-use-runtime/tests/mcp.spec.ts @@ -148,6 +148,7 @@ it('waits for SystemPrompt and ToolRuntime before reserving the provider', async const ctx = new Context() contexts.push(ctx) await ctx.plugin(BrowserUse) + await ctx.plugin(Projections) await ctx.plugin(Agents) provideWorkingDirectoryFixture(ctx) const tools = ctx.plugin(Tools) diff --git a/packages/experimental/browser-use-runtime/tests/resources.spec.ts b/packages/experimental/browser-use-runtime/tests/resources.spec.ts index f838191d44..7fadb86163 100644 --- a/packages/experimental/browser-use-runtime/tests/resources.spec.ts +++ b/packages/experimental/browser-use-runtime/tests/resources.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -11,6 +12,7 @@ const contexts: Context[] = [] async function fixture() { const ctx = new Context() contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) async function owner(id: string) { const fiber = ctx.plugin(() => {}) diff --git a/packages/experimental/claude-code-mods/README.i18n.yaml b/packages/experimental/claude-code-mods/README.i18n.yaml index 68c4c306d6..f208b59a1b 100644 --- a/packages/experimental/claude-code-mods/README.i18n.yaml +++ b/packages/experimental/claude-code-mods/README.i18n.yaml @@ -9,8 +9,8 @@ en: bf54b493565f1624 zh: 6f28a518f7b0dea8 /deepseek-ai-dsh-experimental-claude-code-mods/summary: - en: e21a08cf7ad99a77 - zh: df7634372a42d9b2 + en: 2b8cee860a1ada68 + zh: 185cf00e144270dc /deepseek-ai-dsh-experimental-claude-code-mods/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -24,8 +24,8 @@ en: 2b04be6b58e61a74 zh: f4854198d8999d11 /deepseek-ai-dsh-experimental-claude-code-mods/use-this-package/which-members-your-mod-can-call: - en: bf4f9f971aeb1d17 - zh: 83804163ca9039d5 + en: 563ff4127a48a194 + zh: 2acf06dfcfcc6457 /deepseek-ai-dsh-experimental-claude-code-mods/use-this-package/test-a-mod: en: 5848fccf79f41e0a zh: 62bb20c4d7e4d8c2 @@ -78,8 +78,8 @@ en: ca31263f47f654d9 zh: 17d55e6641705627 /deepseek-ai-dsh-experimental-claude-code-mods/known-limitations-and-deferred-work: - en: 99aaa73c8ef9c609 - zh: 46fab480bd2b1706 + en: 42cdaecb2a072efe + zh: 09419c1d94f3d95a /deepseek-ai-dsh-experimental-claude-code-mods/known-limitations-and-deferred-work/dev-note: en: 4d1e26f4cc9c3c1c zh: d80209b56c56b11c diff --git a/packages/experimental/claude-code-mods/README.md b/packages/experimental/claude-code-mods/README.md index 52dc778067..af51499ead 100644 --- a/packages/experimental/claude-code-mods/README.md +++ b/packages/experimental/claude-code-mods/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Run [Claude Code mods](https://code.claude.com/docs/en/plugins/mods/overview) inside agent runs: wrap a mod's `register(on, options)` with `defineMod`, mount it as a plugin after this bridge, and its hooks guard tool calls, rewrite prompts, add commands and tools, read session facts, and draw a band above the prompt through the same `$`, `e`, `next` chain. The bridge requires `dsh-working-directory` and `dsh-session-projection`, and each `$` call rides a composed harness service. It is an alpha interface-compatibility demonstration: an unserved event is reported at load, an unserved `$` member fails naming the gap, and [the compatibility page](../../../docs/subsystems/claude-code-mods.md) lists every difference. +Run [Claude Code mods](https://code.claude.com/docs/en/plugins/mods/overview) inside agent runs: wrap a mod's `register(on, options)` with `defineMod`, mount it as a plugin after this bridge, and its hooks guard tool calls, rewrite prompts, add commands and tools, read session facts, and draw a band above the prompt through the same `$`, `e`, `next` chain. The bridge requires `dsh-working-directory` and `dsh-agent`, and each `$` call rides a composed harness service. It is an alpha interface-compatibility demonstration: an unserved event is reported at load, an unserved `$` member fails naming the gap, and [the compatibility page](../../../docs/subsystems/claude-code-mods.md) lists every difference. ## Table of Contents @@ -87,7 +87,7 @@ The band is one instance per session, drawn by the first mod in load order whose | `$.command` | `register`, `run`, `list` | `ctx.commands`, scoped to the agent whose event is running | | `$.tool` | `register`, `call`, `list` | `ctx.tools`; a registered tool is named `mcp____`; contexts a tool defers to the next request are injected into the session | | `$.prompt` | `submit` | `agent.followup()` as a `user`-sourced message, framed as a message from the mod unless `asUser`; the `prompt.submit` it raises carries `origin: { kind: 'plugin', name }` | -| `$.session` | `id`, `cwd`, `root`, `model`, `turns`, `messages`, `usage`, `version` | the agent's Session and the `turnBoundary` and `contextPressure` projections; `cwd` reports the committed current directory through `ctx.workingDirectory`; `root` keeps the original project | +| `$.session` | `id`, `cwd`, `root`, `model`, `turns`, `messages`, `usage`, `version` | the agent's Session, `agents.turnBoundaryOf()`, and optional `tokenMeter.contextPressure()`; `cwd` reports the committed current directory through `ctx.workingDirectory`; `root` keeps the original project | | `$.state` | `get`, `set` | memory held for the session, addressed by the `{ plugin, key }` a mod names; a read during `ui.render` subscribes the band | | `$.store` | `get`, `set`, `delete`, `keys` | the `claude_code_mods` storage domain, one JSON object per plugin, 4 MiB | | `$.clock` | `now`, `sleep`, `after`, `every` | timers owned by the session whose event scheduled them; `sleep` rejects when the event is cancelled | @@ -206,7 +206,7 @@ These limits describe where a Claude Code mod behaves differently through this b - **One process, many sessions** — a mod's module-level variables and `$.env.set` writes are shared by every session and plugin in the process, where Claude Code runs one session per process; keep per-session values in `$.state`. `session.start` and `session.end` fire for root agents only. - **No sandbox, no static analysis, no hot reload** — the hooks module runs in-process with Node's globals and the process's full authority (its environment, network, filesystem, and tools); the `$`-only access rule, `claude plugin validate`, type generation, `--plugin-dir` watching, and the in-session mod authoring flow are not implemented. Mount only mods you would run as a plugin. Mounting a mod again re-runs `register` on the same evaluated module, so module-level variables keep their values. - **`turn.complete` text** — the `{ text }` a hook returns reaches the host log, not a line under the answer; `durationMs` counts from the turn's `turn/start`. -- **`$.session.usage`** — `window` is `0` and `percent` absent until the route's context window and a provider usage report are known through the token meter; `rateLimits` is always empty. `$.fs.stat` reports `mtimeMs: 0`. +- **`$.session.usage`** — `window` is `0` and `percent` absent until the route's context window and a provider usage report are known through the token meter; without the token-meter service, token counts are absent and `window` is `0`. `rateLimits` is always empty. `$.fs.stat` reports `mtimeMs: 0`. - **`plugin.json` and `hooks.json`** — not read; `defineMod` carries the identity, and a plugin's settings hooks need `@deepseek-ai/dsh-hooks-claude-code`. diff --git a/packages/experimental/claude-code-mods/README.zh.md b/packages/experimental/claude-code-mods/README.zh.md index 6b11df8418..9b460694d8 100644 --- a/packages/experimental/claude-code-mods/README.zh.md +++ b/packages/experimental/claude-code-mods/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在智能体运行中执行 [Claude Code 模组](https://code.claude.com/docs/en/plugins/mods/overview):用 `defineMod` 包装模组的 `register(on, options)`,在本桥接之后作为插件挂载,其钩子即可通过同一套 `$`、`e`、`next` 链守卫工具调用、改写提示词、添加命令与工具、读取会话事实,并在提示框上方绘制一条横幅。桥接需要 `dsh-working-directory` 和 `dsh-session-projection`,每次 `$` 调用都落在一个已组合的 harness 服务上。它是 alpha 阶段的接口兼容性演示:未服务的事件在加载时报告,未服务的 `$` 成员以指明缺口的消息失败,[兼容性页面](../../../docs/subsystems/claude-code-mods.zh.md) 列出了全部差异。 +在智能体运行中执行 [Claude Code 模组](https://code.claude.com/docs/en/plugins/mods/overview):用 `defineMod` 包装模组的 `register(on, options)`,在本桥接之后作为插件挂载,其钩子即可通过同一套 `$`、`e`、`next` 链守卫工具调用、改写提示词、添加命令与工具、读取会话事实,并在提示框上方绘制一条横幅。桥接需要 `dsh-working-directory` 和 `dsh-agent`,每次 `$` 调用都落在一个已组合的 harness 服务上。它是 alpha 阶段的接口兼容性演示:未服务的事件在加载时报告,未服务的 `$` 成员以指明缺口的消息失败,[兼容性页面](../../../docs/subsystems/claude-code-mods.zh.md) 列出了全部差异。 ## 目录 @@ -87,7 +87,7 @@ Blast Radius 的 DSH 包装使用当前运行的可执行文件提供 Node 计 | `$.command` | `register`、`run`、`list` | `ctx.commands`,限定在事件所属的智能体 | | `$.tool` | `register`、`call`、`list` | `ctx.tools`;注册的工具名为 `mcp____`;工具推迟到下一请求的上下文会注入会话 | | `$.prompt` | `submit` | `agent.followup()`,作为 `user` 来源的消息,除非 `asUser` 否则套上"来自模组"的框架;它触发的 `prompt.submit` 带 `origin: { kind: 'plugin', name }` | -| `$.session` | `id`、`cwd`、`root`、`model`、`turns`、`messages`、`usage`、`version` | 智能体的 Session 及 `turnBoundary`、`contextPressure` 投影;`cwd` 通过 `ctx.workingDirectory` 报告已提交的当前目录;`root` 保留原始项目 | +| `$.session` | `id`、`cwd`、`root`、`model`、`turns`、`messages`、`usage`、`version` | 智能体的 Session、`agents.turnBoundaryOf()` 和可选的 `tokenMeter.contextPressure()`;`cwd` 通过 `ctx.workingDirectory` 报告已提交的当前目录;`root` 保留原始项目 | | `$.state` | `get`、`set` | 为会话持有的内存,以模组命名的 `{ plugin, key }` 寻址;`ui.render` 期间的读取会让横幅订阅 | | `$.store` | `get`、`set`、`delete`、`keys` | `claude_code_mods` 存储域,每插件一个 JSON 对象,4 MiB | | `$.clock` | `now`、`sleep`、`after`、`every` | 由安排它们的事件所属会话拥有的定时器;事件取消时 `sleep` 拒绝 | @@ -206,7 +206,7 @@ Message from the "" mod: - **一个进程,多个会话** — 模组的模块级变量与 `$.env.set` 写入由进程内所有会话与插件共享,而 Claude Code 每进程一个会话;按会话的值请放在 `$.state`。`session.start` 与 `session.end` 只为根智能体触发。 - **无沙箱、无静态分析、无热重载** — 钩子模块以 Node 全局对象在进程内运行,拥有本进程的全部权限(环境变量、网络、文件系统与工具);仅 `$` 访问规则、`claude plugin validate`、类型生成、`--plugin-dir` 监视与会话内模组编写流程均未实现。只挂载你愿意作为插件运行的模组。再次挂载模组会在同一已求值模块上重新运行 `register`,模块级变量保留其值。 - **`turn.complete` 文本** — 钩子返回的 `{ text }` 到达宿主日志,而非答案下方的一行;`durationMs` 从该轮的 `turn/start` 起算。 -- **`$.session.usage`** — 在 token 计量器得知路由的上下文窗口与提供商用量报告之前,`window` 为 `0` 且无 `percent`;`rateLimits` 始终为空。`$.fs.stat` 报告 `mtimeMs: 0`。 +- **`$.session.usage`** — 在 token 计量器得知路由的上下文窗口与提供商用量报告之前,`window` 为 `0` 且无 `percent`;未挂载 token-meter 服务时,token 数缺席且 `window` 为 `0`。`rateLimits` 始终为空。`$.fs.stat` 报告 `mtimeMs: 0`。 - **`plugin.json` 与 `hooks.json`** — 不读取;`defineMod` 承载身份,插件的设置钩子需要 `@deepseek-ai/dsh-hooks-claude-code`。 diff --git a/packages/experimental/claude-code-mods/package.json b/packages/experimental/claude-code-mods/package.json index 13ea2530f6..e0b8f0de7b 100644 --- a/packages/experimental/claude-code-mods/package.json +++ b/packages/experimental/claude-code-mods/package.json @@ -62,7 +62,6 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", - "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-storage-domain": "workspace:*", "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", diff --git a/packages/experimental/claude-code-mods/src/host-ops.ts b/packages/experimental/claude-code-mods/src/host-ops.ts index 7b61a18a4c..84f04d6138 100644 --- a/packages/experimental/claude-code-mods/src/host-ops.ts +++ b/packages/experimental/claude-code-mods/src/host-ops.ts @@ -9,8 +9,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import type {} from '@deepseek-ai/dsh-session-projection' -import { turnBoundaryProjectionDefinition, type Agent } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' // Type-only: activate the `ctx.get('subprocess')` Context declaration. import type {} from '@deepseek-ai/dsh-subprocess' import type { CommandDefinition } from '@deepseek-ai/dsh-commands' @@ -19,7 +18,7 @@ import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import { scopeOf } from '@deepseek-ai/dsh-scope' import type { WorkingDirectoryService } from '@deepseek-ai/dsh-working-directory' -import { contextPressureProjectionDefinition } from '@deepseek-ai/dsh-token-meter' +import type {} from '@deepseek-ai/dsh-token-meter' import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' import type { Domain } from '@deepseek-ai/dsh-storage-domain' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' @@ -158,14 +157,12 @@ function sizeOf(info: { size?: number } | undefined): number { } /** - * Build the op table with the required session projection registry and composed harness services. + * Build the op table over the composed harness services. * @param options - the context, aliases, limits, and registration tracking. - * @returns engine behaviors by event name, after both session projections are registered. + * @returns engine behaviors by event name. */ -export async function createHostOps(options: HostOpsOptions): Promise> { +export function createHostOps(options: HostOpsOptions): OpTable { const { ctx, aliases } = options - const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) - const contextPressure = await ctx.sessionProjections.register(contextPressureProjectionDefinition) let storeDomain: Promise> | undefined function store(): Promise> { @@ -405,14 +402,14 @@ export async function createHostOps(options: HostOpsOptions): Promise { const agent = requireAgent(context, 'session.turns') - return turnBoundary.stateOf(agent.session).lastTurn + return ctx.agents.turnBoundaryOf(agent.session).lastTurn }, 'session.messages': (_input, context) => sessionMessages(requireAgent(context, 'session.messages').session.deriveMessages(), aliases), 'session.usage': (_input, context): SessionUsage => { const agent = requireAgent(context, 'session.usage') - const pressure = contextPressure.stateOf(agent.session) - const tokens = pressure.pressureTokens - const window = pressure.contextWindow ?? 0 + const pressure = ctx.get('tokenMeter')?.contextPressure(agent.session) + const tokens = pressure?.pressureTokens + const window = pressure?.contextWindow ?? 0 return { startedAt: agent.session.header.createdAt, context: { diff --git a/packages/experimental/claude-code-mods/src/index.ts b/packages/experimental/claude-code-mods/src/index.ts index f715a74d7f..b86713bff7 100644 --- a/packages/experimental/claude-code-mods/src/index.ts +++ b/packages/experimental/claude-code-mods/src/index.ts @@ -10,7 +10,7 @@ * @module @deepseek-ai/dsh-experimental-claude-code-mods */ -import { Service, type Context } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-working-directory' import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' @@ -208,25 +208,22 @@ export class ClaudeCodeMods extends TypertRemoteService { bandRows: z.number().default(10), }) - // Session initialization requires its directory owner and projection registry; other services are read on demand. - static inject = ['workingDirectory', 'sessionProjections'] + // Session initialization requires its directory and turn-boundary reader; other services are read on demand. + static inject = ['workingDirectory', 'agents'] - private engine!: ModsEngine + private readonly engine: ModsEngine private readonly registrations = new Map void>>() - private surfaces!: SurfaceTable + private readonly surfaces: SurfaceTable - constructor(ctx: Context, private readonly config: Config) { + constructor(ctx: Context, config: Config) { super(ctx, 'claudeCodeMods', { namespace: 'claudeCodeMods' }) - for (const key of ['hookTimeoutMs', 'catchTimeoutMs', 'processTimeoutMs', 'bandColumns', 'bandRows'] as const) { - const value = config[key] - if (value !== undefined) assertPositive(key, value) - } - } - - protected async [Service.init](): Promise { - const { ctx, config } = this // The schema supplies every default; a direct construction passes the fields it needs. const { hookTimeoutMs = 10_000, catchTimeoutMs = 1_000, processTimeoutMs = 30_000, bandColumns = 120, bandRows = 10 } = config + assertPositive('hookTimeoutMs', hookTimeoutMs) + assertPositive('catchTimeoutMs', catchTimeoutMs) + assertPositive('processTimeoutMs', processTimeoutMs) + assertPositive('bandColumns', bandColumns) + assertPositive('bandRows', bandRows) const aliases = createToolNameAliases(config.toolAliases) const callOrigins = new Map() const modCommands = new Set() @@ -262,7 +259,7 @@ export class ClaudeCodeMods extends TypertRemoteService { }, 0).unref() } const modSubmissions = new Map() - const ops = await createHostOps({ + const ops = createHostOps({ ctx, aliases, processTimeoutMs, registrations: this.registrations, callOrigins, modCommands, modTools, redraw, submitted: (messageId, mod) => { modSubmissions.set(messageId, mod) }, }) diff --git a/packages/experimental/claude-code-mods/tests/bridge.spec.ts b/packages/experimental/claude-code-mods/tests/bridge.spec.ts index 7243febfc6..e5d0d19775 100644 --- a/packages/experimental/claude-code-mods/tests/bridge.spec.ts +++ b/packages/experimental/claude-code-mods/tests/bridge.spec.ts @@ -535,7 +535,7 @@ describe('the mods API over harness services', () => { expect(out.session).toEqual({ id: 'a1', cwd: h.workspace, root: h.workspace, model: 'mock', turns: 1, version: MODS_API_VERSION, - usage: { startedAt: agent.session.header.createdAt, context: { tokens: 10, window: 0 }, rateLimits: [] }, + usage: { startedAt: agent.session.header.createdAt, context: { window: 0 }, rateLimits: [] }, messages: [ { role: 'user', text: 'hello', toolUses: [] }, { role: 'user', text: `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: ${JSON.stringify(h.workspace)}.`, toolUses: [] }, @@ -951,7 +951,7 @@ describe('loading diagnostics and configuration', () => { }) it('is a Service plugin the Loader keeps by its default export', () => { - expect(ClaudeCodeMods.inject).toEqual(['workingDirectory', 'sessionProjections']) + expect(ClaudeCodeMods.inject).toEqual(['workingDirectory', 'agents']) const loader = Object.create(Loader.prototype) as Loader const unwrapped: unknown = loader.unwrapExports({ default: ClaudeCodeMods }) expect(unwrapped).toBe(ClaudeCodeMods) diff --git a/packages/experimental/claude-code-mods/tests/define-mod.spec.ts b/packages/experimental/claude-code-mods/tests/define-mod.spec.ts index 987c2db123..af4b86f1d0 100644 --- a/packages/experimental/claude-code-mods/tests/define-mod.spec.ts +++ b/packages/experimental/claude-code-mods/tests/define-mod.spec.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import ClaudeCodeMods, { defineMod } from '../src/index.ts' @@ -36,6 +37,7 @@ describe('defineMod', () => { const bridge = ctx.plugin(ClaudeCodeMods, {}) expect(ctx.get('claudeCodeMods')).toBeUndefined() await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await bridge const fiber = await ctx.plugin(plugin, { history: 3 }) await fiber.await() @@ -57,6 +59,7 @@ describe('defineMod', () => { fibers.push(ctx.fiber) provideWorkingDirectoryFixture(ctx) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(ClaudeCodeMods, {}) const fiber = ctx.plugin(slow, {}) await vi.waitFor(() => { expect(release).toBeDefined() }) @@ -75,6 +78,7 @@ describe('defineMod', () => { fibers.push(ctx.fiber) provideWorkingDirectoryFixture(ctx) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(ClaudeCodeMods, {}) const fiber = await ctx.plugin(bare, {}) await fiber.await() diff --git a/packages/experimental/claude-code-mods/tests/host-ops.spec.ts b/packages/experimental/claude-code-mods/tests/host-ops.spec.ts index 750a68adf9..2a86de5e61 100644 --- a/packages/experimental/claude-code-mods/tests/host-ops.spec.ts +++ b/packages/experimental/claude-code-mods/tests/host-ops.spec.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import type { Message } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -38,8 +38,9 @@ const mod: LoadedMod = { name: 'unit-mod', version: undefined, root: '/mods/unit async function setup(ctx = new Context()) { contexts.push(ctx) if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) + if (ctx.get('agents') === undefined) await ctx.plugin(AgentRegistry) const registrations = new Map void>>() - const ops = await createHostOps({ + const ops = createHostOps({ ctx, aliases: createToolNameAliases(), processTimeoutMs: 5_000, diff --git a/packages/experimental/claude-code-mods/tsconfig.json b/packages/experimental/claude-code-mods/tsconfig.json index a4aad54eac..2b285b1ded 100644 --- a/packages/experimental/claude-code-mods/tsconfig.json +++ b/packages/experimental/claude-code-mods/tsconfig.json @@ -44,9 +44,6 @@ { "path": "../../llm/token-meter" }, - { - "path": "../../session/session-projection" - }, { "path": "../../storage/storage-domain" }, diff --git a/packages/experimental/hooks-claude-code/package.json b/packages/experimental/hooks-claude-code/package.json index 99015f344d..07c4fd4b5c 100644 --- a/packages/experimental/hooks-claude-code/package.json +++ b/packages/experimental/hooks-claude-code/package.json @@ -37,7 +37,6 @@ "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*" }, "devDependencies": { diff --git a/packages/experimental/hooks-claude-code/src/index.ts b/packages/experimental/hooks-claude-code/src/index.ts index 1c79429b80..5e2b59d3d8 100644 --- a/packages/experimental/hooks-claude-code/src/index.ts +++ b/packages/experimental/hooks-claude-code/src/index.ts @@ -8,12 +8,10 @@ * @module @deepseek-ai/dsh-hooks-claude-code */ -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-working-directory' import { readFileSync } from 'node:fs' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type {} from '@deepseek-ai/dsh-session-projection' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' @@ -44,11 +42,10 @@ import type { SubagentRunId } from '@deepseek-ai/dsh-subagent' import { parseClaudeCodeConfig, type ClaudeCodeHookConfig } from './config.ts' export const name = 'hooks-claude-code' - -export const inject = ['shell', 'sessionProjections', 'workingDirectory'] - -// `shell` runs hooks and `sessionProjections` supplies turn numbers; the rest +// `shell` runs hooks and `agents` supplies turn numbers; the rest // are read opportunistically via ctx.get so a deployment can omit them. +export const inject = ['shell', 'agents', 'workingDirectory'] + /** Plugin config: where the CC hook config lives + substitution roots. */ export interface Config { /** @@ -101,12 +98,7 @@ function assertPositiveInteger(name: string, value: number): void { } } -/** Register the hooks-claude-code plugin after its projections are ready. - * @param ctx - plugin context. - * @param config - hook configuration path and execution limits. - * @returns when the plugin is ready. - */ -export async function apply(ctx: Context, config: Config): Promise { +export function apply(ctx: Context, config: Config): void { // Validate before config parsing so a bad value cannot be hidden by its early return. const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) @@ -128,8 +120,6 @@ export async function apply(ctx: Context, config: Config): Promise { return } - const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) - // Emit-shaped points run detached, so track their chains; disposal aborts // active hooks and drains continuations before resolving. const detached = createDetachedRuns() @@ -251,7 +241,7 @@ export async function apply(ctx: Context, config: Config): Promise { // --- PreToolUse → PreToolDecision. Matcher subject is the tool name. --- ctx.on('tools/pre-execute', async (exec, next): Promise => { - const turn = lastTurn(exec.agent) + const turn = lastTurn(ctx, exec.agent) const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} } @@ -260,7 +250,7 @@ export async function apply(ctx: Context, config: Config): Promise { // --- PostToolUse → PostToolDecision. Matcher subject is the tool name. --- ctx.on('tools/post-execute', async (exec, result, next): Promise => { - const turn = lastTurn(exec.agent) + const turn = lastTurn(ctx, exec.agent) const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) const context = contextFrom(merged) if (merged.decision === 'deny') { @@ -310,12 +300,6 @@ export async function apply(ctx: Context, config: Config): Promise { detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child, directory: 'committed' } : {}, signal: detached.signal }) .catch((error: unknown) => { ctx.logger.warn(`hooks-claude-code: SubagentStop hook failed: ${String(error)}`) })) }) - - function lastTurn(agent: Agent | undefined): number { - if (!agent) return 0 - const boundary = turnBoundary.stateOf(agent.session) - return boundary.lastTurn - } } /** @@ -329,6 +313,12 @@ const SUBAGENT_TYPE = 'general-purpose' // --- Per-event stdin payloads (the CC DIALECT shape). Field names match CC's // hook input schema; this is the part a bridge owns. --- +/** The last open turn number in the agent's log, or 0 without an agent. */ +function lastTurn(ctx: Context, agent: Agent | undefined): number { + if (!agent) return 0 + return ctx.agents.turnBoundaryOf(agent.session).lastTurn +} + /** Flatten content blocks to the text a hook payload carries (the common case). */ function blocksToText(content: ContentBlock[]): string { return content.filter((b): b is Extract => b.type === 'text').map(b => b.text).join('') diff --git a/packages/experimental/hooks-claude-code/tests/bridge.spec.ts b/packages/experimental/hooks-claude-code/tests/bridge.spec.ts index f6b012bc28..3096a8459f 100644 --- a/packages/experimental/hooks-claude-code/tests/bridge.spec.ts +++ b/packages/experimental/hooks-claude-code/tests/bridge.spec.ts @@ -467,12 +467,12 @@ describe('hooks-claude-code bridge — load resilience', () => { // "cannot get property … without inject". Guard the shape directly. expect('default' in HooksClaude).toBe(false) expect(HooksClaude.name).toBe('hooks-claude-code') - expect(HooksClaude.inject).toEqual(['shell', 'sessionProjections', 'workingDirectory']) + expect(HooksClaude.inject).toEqual(['shell', 'agents', 'workingDirectory']) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(HooksClaude) as Record expect(unwrapped).toBe(HooksClaude) expect(unwrapped.name).toBe('hooks-claude-code') - expect(unwrapped.inject).toEqual(['shell', 'sessionProjections', 'workingDirectory']) + expect(unwrapped.inject).toEqual(['shell', 'agents', 'workingDirectory']) expect(typeof unwrapped.apply).toBe('function') }) }) diff --git a/packages/experimental/hooks-claude-code/tests/coverage-cases.ts b/packages/experimental/hooks-claude-code/tests/coverage-cases.ts index bea498dae6..82ea12294f 100644 --- a/packages/experimental/hooks-claude-code/tests/coverage-cases.ts +++ b/packages/experimental/hooks-claude-code/tests/coverage-cases.ts @@ -381,7 +381,7 @@ export function defineCoverageCases(group: CoverageGroup): void { // Direct apply with only configPath — bypasses schemastery's defaults, so // the bridge must run on the raw minimal config (the per-hook timeout is // the protocol lib's reference default, not a config knob). - await HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') }) + HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) diff --git a/packages/experimental/hooks-codex/package.json b/packages/experimental/hooks-codex/package.json index 61ddc09cbd..d40fd4d55c 100644 --- a/packages/experimental/hooks-codex/package.json +++ b/packages/experimental/hooks-codex/package.json @@ -36,7 +36,6 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*" }, "devDependencies": { diff --git a/packages/experimental/hooks-codex/src/index.ts b/packages/experimental/hooks-codex/src/index.ts index 356e64393d..2d9b3c3daf 100644 --- a/packages/experimental/hooks-codex/src/index.ts +++ b/packages/experimental/hooks-codex/src/index.ts @@ -11,12 +11,10 @@ // Each dialect bridge keeps its complete dependency list visible at the entry // point; a cross-package facade for imports alone would add indirection. /* jscpd:ignore-start */ -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-working-directory' import { readFileSync } from 'node:fs' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type {} from '@deepseek-ai/dsh-session-projection' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' @@ -28,7 +26,7 @@ declare module '@deepseek-ai/dsh-llm' { import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' -import type { PostToolDecision, PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' +import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import { appendHookInvoked, appendHookResult, @@ -42,12 +40,10 @@ import { type MergedHookOutcome, } from '@deepseek-ai/dsh-hook-protocol' import { parseCodexConfig, type CodexHookConfig } from './config.ts' +/* jscpd:ignore-end */ export const name = 'hooks-codex' - -export const inject = ['shell', 'sessionProjections', 'workingDirectory'] - -/* jscpd:ignore-end */ +export const inject = ['shell', 'agents', 'workingDirectory'] /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ export interface Config { @@ -87,12 +83,7 @@ function assertPositiveInteger(name: string, value: number): void { } } -/** Register the hooks-codex plugin after its projections are ready. - * @param ctx - plugin context. - * @param config - hook configuration path and execution limits. - * @returns when the plugin is ready. - */ -export async function apply(ctx: Context, config: Config): Promise { +export function apply(ctx: Context, config: Config): void { // Validate before config parsing so a bad value cannot be hidden by its early return. const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) @@ -110,13 +101,8 @@ export async function apply(ctx: Context, config: Config): Promise { return } - const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) const model = config.model ?? '' - function lastTurn(agent: Agent | undefined): number { - return agent === undefined ? 0 : turnBoundary.stateOf(agent.session).lastTurn - } - // SessionStart is the one emit-shaped (detached) point Codex has: track its // run chains so disposal aborts a still-running hook process and drains the // continuation (docs/defensive-patterns/general.md: dispose must reach quiescence). @@ -208,6 +194,7 @@ export async function apply(ctx: Context, config: Config): Promise { .catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) }) detached.track(run) await run + /* jscpd:ignore-end */ }) // UserPromptSubmit → PreStepDecision. Codex supports reject, not rewrite or ask. @@ -238,26 +225,18 @@ export async function apply(ctx: Context, config: Config): Promise { // PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored). ctx.on('tools/pre-execute', async (exec, next): Promise => { - const turn = lastTurn(exec.agent) - const payload = toolPayload(exec, 'PreToolUse', model, turn) - const merged = await runPoint('PreToolUse', exec.name, payload, { - ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal, - }) + const turn = lastTurn(ctx, exec.agent) + const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) + /* jscpd:ignore-end */ if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } return next() }) // PostToolUse → PostToolDecision (block with feedback, or attach context). ctx.on('tools/post-execute', async (exec, result, next): Promise => { - const turn = lastTurn(exec.agent) - const payload = { - ...toolPayload(exec, 'PostToolUse', model, turn), - tool_response: blocksToText(result.content), - } + const turn = lastTurn(ctx, exec.agent) /* jscpd:ignore-start */ - const merged = await runPoint('PostToolUse', exec.name, payload, { - ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal, - }) + const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) const context = contextFrom(merged) if (merged.decision === 'deny') { return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} } @@ -281,13 +260,8 @@ export async function apply(ctx: Context, config: Config): Promise { // avoid continuing the same turn indefinitely. It is always false here, so an // unconditionally blocking hook force-continues every step until it self-limits. ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise => { - const payload = { - ...base(agent, 'Stop', model), - turn_id: String(lastTurn(agent)), - stop_hook_active: false, - last_assistant_message: null, - } - const merged = await runPoint('Stop', '', payload, { agent, turn, signal }) + const merged = await runPoint('Stop', '', { ...turnBase(ctx, agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn, signal }) + /* jscpd:ignore-end */ if (merged.decision === 'deny') { // A blocking Stop hook forces continuation; a block with no reason (exit 2, // empty stderr) still forces it — fall back to a generic steering line @@ -304,6 +278,10 @@ export async function apply(ctx: Context, config: Config): Promise { // These small payload helpers intentionally remain next to the dialect shape; // sharing them would pull bridge-only agent/LLM dependencies into hook-protocol. /* jscpd:ignore-start */ +function lastTurn(ctx: Context, agent: Agent | undefined): number { + if (!agent) return 0 + return ctx.agents.turnBoundaryOf(agent.session).lastTurn +} function blocksToText(content: ContentBlock[]): string { return content.filter((b): b is Extract => b.type === 'text').map(b => b.text).join('') @@ -323,20 +301,9 @@ function base(agent: Agent | undefined, event: string, model: string): Record { - return { - ...base(exec.agent, event, model), - turn_id: String(turn), - tool_name: exec.name, - tool_input: { command: commandOf(exec.arguments) }, - tool_use_id: exec.callId, - } +/** Base + turn_id, for the turn-scoped events (PreToolUse/PostToolUse/UserPromptSubmit/Stop). */ +function turnBase(ctx: Context, agent: Agent | undefined, event: string, model: string): Record { + return { ...base(agent, event, model), turn_id: String(lastTurn(ctx, agent)) } } /** Extract a `command` string from a tool call's parsed arguments, else ''. */ @@ -347,3 +314,15 @@ function commandOf(args: unknown): string { } return '' } + +function preToolPayload(ctx: Context, exec: ToolExecution, model: string): Record { + // `tool_name` is the REAL tool name (matching the `exec.name` matcher subject); + // a hardcoded constant would disagree with what the matcher tests and make a + // config's tool matcher never fire. `tool_input` keeps Codex's `{ command }` + // shape (its shell payload), derived from the call's `command` arg when present. + return { ...turnBase(ctx, exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId } +} + +function postToolPayload(ctx: Context, exec: ToolExecution, result: ToolExecutionResult, model: string): Record { + return { ...turnBase(ctx, exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) } +} diff --git a/packages/experimental/hooks-codex/tests/bridge.spec.ts b/packages/experimental/hooks-codex/tests/bridge.spec.ts index 242eb91819..8089e0c092 100644 --- a/packages/experimental/hooks-codex/tests/bridge.spec.ts +++ b/packages/experimental/hooks-codex/tests/bridge.spec.ts @@ -287,12 +287,12 @@ describe('hooks-codex bridge', () => { it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => { expect('default' in HooksCodex).toBe(false) expect(HooksCodex.name).toBe('hooks-codex') - expect(HooksCodex.inject).toEqual(['shell', 'sessionProjections', 'workingDirectory']) + expect(HooksCodex.inject).toEqual(['shell', 'agents', 'workingDirectory']) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(HooksCodex) as Record expect(unwrapped).toBe(HooksCodex) expect(unwrapped.name).toBe('hooks-codex') - expect(unwrapped.inject).toEqual(['shell', 'sessionProjections', 'workingDirectory']) + expect(unwrapped.inject).toEqual(['shell', 'agents', 'workingDirectory']) expect(typeof unwrapped.apply).toBe('function') }) }) diff --git a/packages/experimental/hooks-codex/tests/coverage-cases.ts b/packages/experimental/hooks-codex/tests/coverage-cases.ts index 2ab643a50d..f3458daeab 100644 --- a/packages/experimental/hooks-codex/tests/coverage-cases.ts +++ b/packages/experimental/hooks-codex/tests/coverage-cases.ts @@ -326,7 +326,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) ctx.logger.warn = warn as never // Direct apply (schema bypass) → the `model ?? ''` fallback is exercised. - await HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') }) + HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) diff --git a/packages/experimental/session-title-all-prompts-llm/tests/provider.spec.ts b/packages/experimental/session-title-all-prompts-llm/tests/provider.spec.ts index 282662b1a0..648fd59039 100644 --- a/packages/experimental/session-title-all-prompts-llm/tests/provider.spec.ts +++ b/packages/experimental/session-title-all-prompts-llm/tests/provider.spec.ts @@ -1,10 +1,10 @@ +import AgentRegistry from '@deepseek-ai/dsh-agent' import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, onTestFinished } from 'vitest' import LlmRuntime, { createUserMessage, LlmAdapter, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SessionTitleService, { SessionTitleProviderId, type SessionTitleUserMessage, @@ -92,7 +92,7 @@ async function harness( await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, TITLE_CONFIG) const adapter = new RecordingAdapter(script, reasoningEfforts) ctx.llm.registerAdapter(['title-route'], adapter) @@ -215,7 +215,7 @@ describe('all-messages LLM title provider', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.plugin(AgentRegistry) // A one-byte fallback cap cannot derive a title from the multibyte message, // so the service supplies no currentTitle to the provider. await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 1, maxTitleBytes: 80 }) diff --git a/packages/experimental/tool-session-query/package.json b/packages/experimental/tool-session-query/package.json index 32f3903015..51d9143100 100644 --- a/packages/experimental/tool-session-query/package.json +++ b/packages/experimental/tool-session-query/package.json @@ -31,7 +31,6 @@ "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", - "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", diff --git a/packages/experimental/tool-session-query/src/index.ts b/packages/experimental/tool-session-query/src/index.ts index 2337b43402..1e1d2f38d2 100644 --- a/packages/experimental/tool-session-query/src/index.ts +++ b/packages/experimental/tool-session-query/src/index.ts @@ -5,7 +5,6 @@ */ import type { Context } from '@deepseek-ai/cordis' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -13,9 +12,11 @@ import { toolInput } from './input.ts' import { operations } from './operations.ts' import { presentation } from './presentation.ts' +/** Cordis plugin name used by Loader diagnostics. */ export const name = 'tool-session-query' -export const inject = ['tools', 'systemPrompt', 'sessionQuery', 'sessionProjections'] +/** Capability services required by the model-facing consumer. */ +export const inject = ['tools', 'systemPrompt', 'sessionQuery', 'agents'] /** Default maximum number of authorized search hits returned by one call. */ export const DEFAULT_MAX_SEARCH_RESULTS = 100 @@ -52,14 +53,8 @@ const PROMPT_TEXT = + 'events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with ' + 'session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data.' -/** - * Register all five tools and their shared model guidance. - * @param ctx - owning plugin context. - * @param config - search limits. - * @returns when the tools and their projection reader are ready. - */ -export async function apply(ctx: Context, config: Config): Promise { - const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) +/** Register all five tools and their shared model guidance. */ +export function apply(ctx: Context, config: Config): void { const resolved = resolveConfig(config) ctx.systemPrompt.section({ name: 'tool:session-query', @@ -73,7 +68,7 @@ export async function apply(ctx: Context, config: Config): Promise { parameters: toolInput.sessionSearchParameters, output: TEXT_OUTPUT, timeoutMs: resolved.searchTimeoutMs, - execute: (args, exec) => operations.executeSessionSearch(ctx, turnBoundary, args, exec, resolved.maxSearchResults), + execute: (args, exec) => operations.executeSessionSearch(ctx, args, exec, resolved.maxSearchResults), presentCall: presentation.presentSessionSearchCall, })) @@ -83,7 +78,7 @@ export async function apply(ctx: Context, config: Config): Promise { parameters: toolInput.eventSearchParameters, output: TEXT_OUTPUT, timeoutMs: resolved.searchTimeoutMs, - execute: (args, exec) => operations.executeEventSearch(ctx, turnBoundary, args, exec, resolved.maxSearchResults), + execute: (args, exec) => operations.executeEventSearch(ctx, args, exec, resolved.maxSearchResults), presentCall: presentation.presentEventSearchCall, })) @@ -93,7 +88,7 @@ export async function apply(ctx: Context, config: Config): Promise { parameters: toolInput.targetSessionParameter, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => operations.executeSessionTrace(ctx, turnBoundary, args, exec), + execute: (args, exec) => operations.executeSessionTrace(ctx, args, exec), presentCall: presentation.presentSessionTraceCall, })) @@ -106,7 +101,7 @@ export async function apply(ctx: Context, config: Config): Promise { }, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => operations.executeEventTrace(ctx, turnBoundary, args, exec), + execute: (args, exec) => operations.executeEventTrace(ctx, args, exec), presentCall: args => presentation.presentEventTargetCall('Trace event', args), })) @@ -121,7 +116,7 @@ export async function apply(ctx: Context, config: Config): Promise { }, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => operations.executeEventRead(ctx, turnBoundary, args, exec), + execute: (args, exec) => operations.executeEventRead(ctx, args, exec), presentCall: args => presentation.presentEventTargetCall('Read event', args), })) } diff --git a/packages/experimental/tool-session-query/src/operations.ts b/packages/experimental/tool-session-query/src/operations.ts index 2727447537..1d10d0a34b 100644 --- a/packages/experimental/tool-session-query/src/operations.ts +++ b/packages/experimental/tool-session-query/src/operations.ts @@ -5,8 +5,6 @@ */ import type { Context } from '@deepseek-ai/cordis' -import type { TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' -import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import { HarnessError } from '@deepseek-ai/dsh-llm' import { SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -56,12 +54,11 @@ interface SearchCollection { async function executeSessionSearch( ctx: Context, - turnBoundary: ProjectionHandle, args: SessionSearchArgs, exec: ToolRunContext, maxResults: number, ): Promise { - const caller = workspaceAccess.callerOf(exec, turnBoundary) + const caller = workspaceAccess.callerOf(exec, ctx) const cwd = caller.header.cwd if (cwd === undefined) { throw new HarnessError( @@ -119,12 +116,11 @@ async function executeSessionSearch( async function executeEventSearch( ctx: Context, - turnBoundary: ProjectionHandle, args: EventSearchArgs, exec: ToolRunContext, maxResults: number, ): Promise { - const caller = workspaceAccess.callerOf(exec, turnBoundary) + const caller = workspaceAccess.callerOf(exec, ctx) const sessionId = workspaceAccess.targetId(args, caller) await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) const query = toolInput.normalizeQuery(args.query) @@ -168,11 +164,10 @@ async function executeEventSearch( async function executeSessionTrace( ctx: Context, - turnBoundary: ProjectionHandle, args: SessionTargetArgs, exec: ToolRunContext, ): Promise { - const caller = workspaceAccess.callerOf(exec, turnBoundary) + const caller = workspaceAccess.callerOf(exec, ctx) const sessionId = workspaceAccess.targetId(args, caller) await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) const trace = await serviceBoundary.call(ctx, exec.signal, 'session lineage trace', () => @@ -201,13 +196,12 @@ async function executeSessionTrace( async function executeEventTrace( ctx: Context, - turnBoundary: ProjectionHandle, args: EventTargetArgs, exec: ToolRunContext, ): Promise { toolInput.assertNonNegativeSafeInteger('seq', args.seq) const seq = SessionSeq(args.seq) - const caller = workspaceAccess.callerOf(exec, turnBoundary) + const caller = workspaceAccess.callerOf(exec, ctx) const sessionId = workspaceAccess.targetId(args, caller) await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) const trace = await serviceBoundary.call(ctx, exec.signal, 'event trace', () => @@ -219,7 +213,6 @@ async function executeEventTrace( async function executeEventRead( ctx: Context, - turnBoundary: ProjectionHandle, args: EventReadArgs, exec: ToolRunContext, ): Promise { @@ -227,7 +220,7 @@ async function executeEventRead( const seq = SessionSeq(args.seq) if (args.before !== undefined) toolInput.assertNonNegativeSafeInteger('before', args.before) if (args.after !== undefined) toolInput.assertNonNegativeSafeInteger('after', args.after) - const caller = workspaceAccess.callerOf(exec, turnBoundary) + const caller = workspaceAccess.callerOf(exec, ctx) const sessionId = workspaceAccess.targetId(args, caller) await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) const window = await serviceBoundary.call(ctx, exec.signal, 'event read', () => diff --git a/packages/experimental/tool-session-query/src/workspace-access.ts b/packages/experimental/tool-session-query/src/workspace-access.ts index 836763cb3e..60bf265129 100644 --- a/packages/experimental/tool-session-query/src/workspace-access.ts +++ b/packages/experimental/tool-session-query/src/workspace-access.ts @@ -17,7 +17,6 @@ import type { SessionRecord, } from '@deepseek-ai/dsh-session-query' import type { ToolRunContext } from '@deepseek-ai/dsh-tools' -import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import { serviceBoundary } from './service-boundary.ts' interface Caller { @@ -53,7 +52,7 @@ interface DescendantVisit { readonly next: DescendantVisit | undefined } -function callerOf(exec: ToolRunContext, turnBoundary: ProjectionHandle): Caller { +function callerOf(exec: ToolRunContext, ctx: Context): Caller { const agent = exec.agent if (agent === undefined) { throw new HarnessError( @@ -64,7 +63,7 @@ function callerOf(exec: ToolRunContext, turnBoundary: ProjectionHandle { - await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.plugin(AgentRegistry) } describe('tool-session-query with the real SQLite provider', () => { diff --git a/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts b/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts index c5dd7fddfc..6dcc87cbb1 100644 --- a/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, ToolCallId, HarnessError , createMessage } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS, TimeoutReason } from '@deepseek-ai/dsh-timeout' @@ -15,7 +15,6 @@ import SessionStore, { type SessionId as SessionIdValue, } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SessionQueryEngine, { SessionQueryError, SessionSearchCursor, @@ -209,7 +208,7 @@ interface Mounted { } async function registerTurnBoundary(ctx: Context): Promise { - await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.plugin(AgentRegistry) } async function mount( @@ -330,14 +329,14 @@ describe('registration and schemas', () => { it('fails invalid direct config before registering anything', async () => { const mounted = await mount() for (const maxSearchResults of [0, 1.5, Number.NaN]) { - await expect(ToolSessionQuery.apply(mounted.ctx, { maxSearchResults })) - .rejects.toThrow('maxSearchResults') + expect(() => { ToolSessionQuery.apply(mounted.ctx, { maxSearchResults }) }) + .toThrow('maxSearchResults') } for (const searchTimeoutMs of [0, 1.5, Number.POSITIVE_INFINITY, MAX_TIMER_DELAY_MS + 1]) { - await expect(ToolSessionQuery.apply(mounted.ctx, { searchTimeoutMs })) - .rejects.toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`) + expect(() => { ToolSessionQuery.apply(mounted.ctx, { searchTimeoutMs }) }) + .toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`) } - await expect(ToolSessionQuery.apply(new Context(), {})).rejects.toThrow() + expect(() => { ToolSessionQuery.apply(new Context(), {}) }).toThrow() }) it('expresses the complete Node timer range in the Loader config schema', () => { @@ -1661,6 +1660,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(FakeQuery) + await ctx.plugin(AgentRegistry) await ctx.plugin(ToolSessionQuery) const session = createSession(ctx, 'no-boundary-caller', '/work') const result = await ctx.tools.execute({ diff --git a/packages/experimental/tool-session-query/tsconfig.json b/packages/experimental/tool-session-query/tsconfig.json index c3636a6deb..71a98f903b 100644 --- a/packages/experimental/tool-session-query/tsconfig.json +++ b/packages/experimental/tool-session-query/tsconfig.json @@ -35,9 +35,6 @@ { "path": "../../util/timeout" }, - { - "path": "../../session/session-projection" - }, { "path": "../../core/agent" } diff --git a/packages/experimental/tool-terminal/tests/tools.spec.ts b/packages/experimental/tool-terminal/tests/tools.spec.ts index fcc0a10405..3efbaf7ca3 100644 --- a/packages/experimental/tool-terminal/tests/tools.spec.ts +++ b/packages/experimental/tool-terminal/tests/tools.spec.ts @@ -4,6 +4,7 @@ import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { renderToolsSdk } from '@deepseek-ai/dsh-tools' @@ -114,6 +115,7 @@ async function setupBase(jobs: boolean) { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) const stub = stubBackend() diff --git a/packages/experimental/worktree/tests/harness.ts b/packages/experimental/worktree/tests/harness.ts index c38be4d57c..369c13fa2f 100644 --- a/packages/experimental/worktree/tests/harness.ts +++ b/packages/experimental/worktree/tests/harness.ts @@ -71,8 +71,8 @@ export async function repository(parent = tmpdir()): Promise { export async function harness(root: string, config: Config = {}, mode: SandboxMode = 'danger-full-access'): Promise { const ctx = new Context() try { - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(LocalFileSystem, { cwd: root }) await ctx.plugin(LocalSubprocessRuntime) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 1167405bb7..aa98e5b079 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -135,6 +135,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Registry of YAML-declared presets and the revisions live Agents retain.', description: 'Registry of YAML-declared presets and the revisions live Agents retain.', methods: [ + { + signature: 'presetForSession(session: Session): string | null', + description: 'Read the preset recorded for a Session, including selections no longer in the roster.', + parameters: [{ name: 'session', description: 'Session whose committed preset selection is read.' }], + returns: 'the recorded preset, or null when none was selected.', + }, { signature: 'async register(definition: PresetDefinition): Promise<() => Promise>', description: 'Register and eagerly load a definition; activation failure remains visible in the roster.', @@ -226,6 +232,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Agent service (`ctx.agents`): tracks live agents and carries the initiating Agent through one process-local asynchronous driver chain.', description: 'Agent service (`ctx.agents`): tracks live agents and carries the initiating Agent through one process-local asynchronous driver chain. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory.\n\nInitiator methods provide same-process causal attribution only. Ambient presence is neither liveness proof nor authorization; subjects and owners remain explicit, as does identity at worker, process, persistence, and wire boundaries. Returned Promise boundaries drain during teardown, except a nested lineage that starts an owning-fiber unload is excluded from its own drain.', methods: [ + { + signature: 'turnBoundaryOf(session: Session): TurnBoundaryProjection', + description: 'Read the turn and step positions recorded in a Session.', + parameters: [{ name: 'session', description: 'Session whose committed events are read.' }], + returns: 'the current turn and step positions; do not mutate the returned value.', + }, { signature: 'currentInitiator(): Agent | undefined', description: 'Read the Agent that initiated the inherited asynchronous driver chain. Use this optional form for logging, tracing, metrics, or host attribution that also supports agentless calls. When a parent creates a child, setup reports the causal parent while the setup callback\'s Agent parameter identifies the child.', @@ -3315,6 +3327,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Replay owner for one service-wide estimator and isolated per-session folds.', description: 'Replay owner for one service-wide estimator and isolated per-session folds.', methods: [ + { + signature: 'contextPressure(session: Session): ContextPressureProjection', + description: 'Read the latest logged context occupancy and its projected surface adjustment.', + parameters: [{ name: 'session', description: 'Session whose committed usage and request capacity are read.' }], + returns: 'context pressure; fields remain absent until their source events are recorded.', + }, { signature: 'measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement', description: 'Measure current request pressure and surface through the durable tail.\n\nThe effective envelope\'s routed provider/model selects the request-image pricing every node is priced under: a route whose adapter declares image pricing charges each retained image its visual tokens plus its model-visible text, while other routes keep the fixed heuristic. Provider usage is reused only when the latest successful call\'s canonical request envelope matches `requestHeader` and its total is no lower than that call\'s full route-priced anchor; otherwise the complete envelope and surface are repriced. The anchor includes all surface nodes immediately before the assistant message, including inputs admitted after step/start.\n\n`requestHeader` replaces the latest logged envelope for pressure and node pricing; the node set always describes the current session surface. Every call clones those positional nodes, so measurement is O(surface).', @@ -5098,6 +5116,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ContentBlockType', declaration: 'export type ContentBlockType = keyof ContentBlockMap;', }, + { + name: 'ContextPressureProjection', + declaration: 'export interface ContextPressureProjection {\n pressureTokens?: number;\n projectedTokens?: number;\n contextWindow?: number;\n}', + }, { name: 'ContinuableCreateRequest', declaration: 'export interface ContinuableCreateRequest {\n readonly cwd: string;\n readonly sessionId: SessionId;\n readonly parent: Agent;\n readonly signal: AbortSignal;\n}', @@ -8222,6 +8244,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TranslationSpec', declaration: 'export type TranslationSpec = AnonymousTranslationSpec | PaidTranslationSpec;', }, + { + name: 'TurnBoundaryProjection', + declaration: 'export interface TurnBoundaryProjection {\n readonly openTurnStartSeq: OptionalSessionSeq;\n readonly lastStepStartSeq: OptionalSessionSeq;\n readonly lastStepBoundary: {\n readonly kind: \'start\' | \'end\';\n readonly seq: SessionSeq;\n } | null;\n readonly lastTurn: number;\n}', + }, { name: 'TurnCompleteInput', declaration: 'export interface TurnCompleteInput {\n turnId: string;\n answer: string;\n durationMs: number;\n isAborted: boolean;\n reason: \'answer\' | \'aborted\' | \'error\';\n agentId?: string;\n usage?: TurnUsage;\n}', diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 4e5401c09e..f218d8ca9b 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandRuntime from '@deepseek-ai/dsh-commands' import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session' @@ -53,6 +54,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } async function harness(): Promise { const ctx = new Context() await ctx.plugin(CommandRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionStore) const plugin = await ctx.plugin(commandFeedback) diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index 273657e579..4508e8fa39 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -7,6 +7,7 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandRuntime from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -57,6 +58,7 @@ describe('/feedback real Loader composition through cordis.yml', () => { const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ "- name: '@deepseek-ai/dsh-agent'", + "- name: '@deepseek-ai/dsh-session-projection'", "- name: '@deepseek-ai/dsh-session'", "- name: '@deepseek-ai/dsh-commands'", "- name: '@deepseek-ai/dsh-command-feedback'", @@ -69,6 +71,7 @@ describe('/feedback real Loader composition through cordis.yml', () => { context.loader.builtins.include = Include const modules = new Map([ ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-commands', CommandRuntime], ['@deepseek-ai/dsh-command-feedback', CommandFeedback], diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index d0cbd0ef03..1e84bc9b48 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -1,3 +1,4 @@ +import AgentRegistry from '@deepseek-ai/dsh-agent' /** * Consumer API tests over a fake provider and the real policy collaborator: schemas, * validation, formatting, typed errors, intent dispatch, and observation-driven authorization. @@ -8,7 +9,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime' import { createScope, type Scope } from '@deepseek-ai/dsh-scope' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import { ToolCallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { type ToolResult } from '@deepseek-ai/dsh-tools' @@ -863,7 +863,7 @@ describe('sandbox escalation API (write/edit)', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SessionProjectionRegistry) - await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.plugin(AgentRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write' }) await ctx.plugin(SandboxingFakeFs) await ctx.plugin(FsPolicy) diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 74b8ce5dd6..710939691f 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -76,13 +76,11 @@ async function setup( contexts.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) if (options.sandboxMode === undefined) { await ctx.plugin(LocalFileSystem, { cwd: root }) } else { - // SandboxPolicy declares the registry as a required injection; mount it - // before the policy activates. - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicy, { mode: options.sandboxMode, workspaceRoot: root }) await ctx.plugin(SandboxedFileSystem, { cwd: root }) } @@ -616,6 +614,7 @@ describe('tool-str-replace-editor', () => { contexts.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: root }) Object.defineProperty(ctx.fs, 'sandboxMode', { value: 'read-only' }) diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 490a4c814d..8948fe916b 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -172,8 +172,8 @@ describe('GoalService creation and replay', () => { it('also resolves the default when constructed directly without Cordis config normalization', async () => { const ctx = new Context() - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) const stub = stubAgent('goal-direct-construction') await ctx.agents.register(stub.agent) class DirectGoalService extends GoalService { @@ -188,8 +188,8 @@ describe('GoalService creation and replay', () => { it('declares no resource readers on goals/get, leaving its Session resource to the Agent lookup', async () => { const ctx = new Context() - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) const goals = new GoalService(ctx) const marker = remoteMethods(goals).find(method => method.method === 'get') expect(marker).toMatchObject({ method: 'get' }) @@ -198,8 +198,8 @@ describe('GoalService creation and replay', () => { it('rejects invalid direct configuration', async () => { const ctx = new Context() - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await expect(ctx.plugin(GoalService, { defaultMaxGoalRounds: -1 })).rejects.toThrow(expect.objectContaining({ code: 'GOAL_INVALID_MAX_ROUNDS', })) @@ -212,8 +212,8 @@ describe('GoalService creation and replay', () => { appendRound(first.session, created, 2) const ctx = new Context() - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService) const resumed = stubAgent('seeded-goal', first.session.snapshotEvents()) await ctx.agents.register(resumed.agent) @@ -282,8 +282,8 @@ describe('GoalService creation and replay', () => { it('removes the service and projection with the providing fiber', async () => { const ctx = new Context() - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(GoalService) const first = ctx.goals const stub = stubAgent('goal-hmr') @@ -510,8 +510,8 @@ describe('GoalService mutations', () => { it('does not delegate goal persistence to agent injection', async () => { const ctx = new Context() - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService) const stub = stubAgent('goal-independent-injection') stub.agent.inject = () => { throw new Error('injection must not be called') } diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 64071e4038..f71559bb8e 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -60,8 +60,8 @@ async function liveAgent(ctx: Context, session: Session): Promise { async function harness(withGoal: boolean): Promise { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) if (withGoal) await ctx.plugin(GoalService) const session = ctx.sessions.create() const agent = await liveAgent(ctx, session) diff --git a/packages/goal/tool-goal/README.i18n.yaml b/packages/goal/tool-goal/README.i18n.yaml index 1006276ef8..ee11ff3200 100644 --- a/packages/goal/tool-goal/README.i18n.yaml +++ b/packages/goal/tool-goal/README.i18n.yaml @@ -30,8 +30,8 @@ en: 4e9fdb5a89f4baa3 zh: 50d182467ae1f3a7 /deepseek-ai-dsh-tool-goal/understand-the-implementation/design: - en: 3be550d69f7167f1 - zh: 0becdbd7b7395e40 + en: 1c51463a2abd9514 + zh: b02fbbd125d96bf6 /deepseek-ai-dsh-tool-goal/understand-the-implementation/source-map: en: 4d5c648787409868 zh: b4938bfe660f18aa diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 23b457e6c8..adb65e4f48 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -68,7 +68,7 @@ This section explains how the tools enforce authority and render output; the obs ### Design -- **Authority at execution.** The plugin awaits its turn-boundary reader before registering tools. Every call resolves the exact live agent, its inherited `AgentRegistry` initiator, running status, and an open turn; `create`, `edit`, `pause`, and `resume` additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. A durable paused goal fails the `resume` action with `GOAL_TOOL_RESUME_PAUSED`; the user-facing command or Web control owns that transition. Durable fork lineage does not demote a resumed root; live subagent ownership does. +- **Authority at execution.** The plugin requires `agents` and reads turn state through `turnBoundaryOf(session)`. Every call resolves the exact live agent, its inherited `AgentRegistry` initiator, running status, and an open turn; `create`, `edit`, `pause`, and `resume` additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. A durable paused goal fails the `resume` action with `GOAL_TOOL_RESUME_PAUSED`; the user-facing command or Web control owns that transition. Durable fork lineage does not demote a resumed root; live subagent ownership does. - **Host attestation of human input.** `{ kind: 'user' }` is assigned by `Agent.followup()` and `steer()` when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority. - **System-prompt guidance with the configured threshold.** The package registers one `tool:goal` system-prompt section whose fixed text interpolates `blockedAfterConsecutiveRounds`; the same value is the hard lower bound enforced at execution. - **Wrap-up context for terminal rounds.** A successful autonomous `complete` or `blocked` defers a closing `` or `` instruction so the model addresses the user once before the turn ends; direct-human mutations never defer this context. diff --git a/packages/goal/tool-goal/README.zh.md b/packages/goal/tool-goal/README.zh.md index e7d67715c4..6f3da635bd 100644 --- a/packages/goal/tool-goal/README.zh.md +++ b/packages/goal/tool-goal/README.zh.md @@ -68,7 +68,7 @@ kind: "package-reference" ### 设计 -- **执行时权限。** 插件等待轮次边界读取句柄就绪后才注册工具。每次调用都解析精确活跃 agent、其继承的 `AgentRegistry` initiator、running 状态与开放轮次;`create`、`edit`、`pause` 和 `resume` 还要求运行时根 agent 的当前轮次中存在已接受的 `{ kind: 'user' }` 消息或 steering 事件。持久的 paused goal 会让 `resume` 以 `GOAL_TOOL_RESUME_PAUSED` 失败;面向用户的命令或 Web 控件拥有该转换。持久 fork 谱系不会降低已恢复根 agent 的等级;活跃 subagent 所有权会降低。 +- **执行时权限。** 插件依赖 `agents`,并通过 `turnBoundaryOf(session)` 读取轮次状态。每次调用都解析精确活跃 agent、其继承的 `AgentRegistry` initiator、running 状态与开放轮次;`create`、`edit`、`pause` 和 `resume` 还要求运行时根 agent 的当前轮次中存在已接受的 `{ kind: 'user' }` 消息或 steering 事件。持久的 paused goal 会让 `resume` 以 `GOAL_TOOL_RESUME_PAUSED` 失败;面向用户的命令或 Web 控件拥有该转换。持久 fork 谱系不会降低已恢复根 agent 的等级;活跃 subagent 所有权会降低。 - **人类输入的宿主证明。** `Agent.followup()` 与 `steer()` 会在调用方省略 source 时分配 `{ kind: 'user' }`,因此插件、调度器与其他非人类生产方必须传入自己的 source,不能继承人类权限。 - **带配置阈值的系统提示词指引。** 本包注册一个 `tool:goal` 系统提示词章节,其固定文本插入 `blockedAfterConsecutiveRounds`;同一数值就是执行时强制执行的硬下限。 - **终局 Round 的结束上下文。** 成功的自主 `complete` 或 `blocked` 会延后一条 `` 或 `` 结束指令,让模型在轮次结束前向用户做一次交代;人类直接变更绝不会延后该上下文。 diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index e5c0e9d8e7..ce2d48e51d 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -35,8 +35,7 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", - "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-session-projection": "workspace:*" + "@deepseek-ai/cordis": "workspace:~" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index 07de4e68ae..77568e3746 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -1,12 +1,11 @@ /** Execution-time authority checks for the model-facing goal tools. */ import type { Context } from '@deepseek-ai/cordis' -import type { Agent, TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import type { GoalView } from '@deepseek-ai/dsh-goal' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { SessionEvent, SessionSeq } from '@deepseek-ai/dsh-session' import type { ToolRunContext } from '@deepseek-ai/dsh-tools' -import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' /** The calling agent plus the immutable event cut and open-turn start seq used for authority checks. */ export interface GoalToolExecution { @@ -27,12 +26,12 @@ function reject(message: string, code = 'GOAL_TOOL_AUTHORITY_REQUIRED'): never { /** Resolve the immutable event cut and open-turn boundary without copying the turn suffix. */ function openTurnEvents( - turnBoundary: ProjectionHandle, + ctx: Context, agent: Agent, ): Pick { // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() - const boundary = turnBoundary.stateOf(agent.session) + const boundary = ctx.agents.turnBoundaryOf(agent.session) if (boundary.openTurnStartSeq === null) { reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') } @@ -43,14 +42,9 @@ function openTurnEvents( * Resolve and authenticate the calling agent and its driver boundary. * @param ctx - Context carrying the live agent registry. * @param exec - Tool execution metadata supplied by the registry. - * @param turnBoundary - Ready reader for the calling Session's turn boundary. * @returns The authenticated agent, immutable event cut, and open-turn boundary. */ -export function goalToolExecution( - ctx: Context, - exec: ToolRunContext, - turnBoundary: ProjectionHandle, -): GoalToolExecution { +export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolExecution { const agent = exec.agent if (agent === undefined) { return reject('goal tools require a calling agent', 'GOAL_TOOL_AGENT_REQUIRED') @@ -62,7 +56,7 @@ export function goalToolExecution( 'GOAL_TOOL_DRIVER_REQUIRED', ) } - return { agent, ...openTurnEvents(turnBoundary, agent) } + return { agent, ...openTurnEvents(ctx, agent) } } /** Whether the captured open turn contains an event accepted by `predicate`. */ diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 962d0b8f98..482d614160 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -5,7 +5,6 @@ */ import type { Context } from '@deepseek-ai/cordis' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' import z from '@deepseek-ai/schemastery' import { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal' @@ -27,7 +26,7 @@ import { import { renderWrapupContext } from './wrapup.ts' export const name = 'tool-goal' -export const inject = ['agents', 'goals', 'tools', 'systemPrompt', 'sessionProjections'] +export const inject = ['agents', 'goals', 'tools', 'systemPrompt'] /** Model policy and hard lower bounds for goal-state updates. */ export interface Config { @@ -185,9 +184,8 @@ function present(title: string, kind: 'read' | 'other', rawInput?: unknown): Gen } /** Register the three Codex-shaped goal tools and their shared policy section. */ -export async function apply(ctx: Context, config: Config): Promise { +export function apply(ctx: Context, config: Config): void { const resolved = resolveConfig(config) - const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) ctx.systemPrompt.section({ name: 'tool:goal', order: ctx.systemPrompt.getSectionOrder('TOOL_GOAL'), @@ -200,7 +198,7 @@ export async function apply(ctx: Context, config: Config): Promise { parameters: {}, output: GOAL_OUTPUT, execute(_args, exec) { - const execution = goalToolExecution(ctx, exec, turnBoundary) + const execution = goalToolExecution(ctx, exec) return Promise.resolve(goalValue(ctx.goals.get(execution.agent))) }, presentCall: () => present('Read current goal', 'read'), @@ -222,7 +220,7 @@ export async function apply(ctx: Context, config: Config): Promise { }, output: GOAL_OUTPUT, execute(args, exec) { - const execution = goalToolExecution(ctx, exec, turnBoundary) + const execution = goalToolExecution(ctx, exec) requireDirectHuman(ctx, execution) const goal = ctx.goals.create(execution.agent, { objective: args.objective, @@ -255,7 +253,7 @@ export async function apply(ctx: Context, config: Config): Promise { }, output: GOAL_OUTPUT, execute(args, exec) { - const execution = goalToolExecution(ctx, exec, turnBoundary) + const execution = goalToolExecution(ctx, exec) const ref = goalRef(args.goal_id, args.revision) const replacements = { ...hasText(args.objective) ? { objective: args.objective } : {}, diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 08d5e46589..233ed1526f 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -193,7 +193,7 @@ describe('goal tool registration and presentation', () => { it('has the Loader-safe namespace export shape', () => { expect('default' in toolGoal).toBe(false) expect(toolGoal.name).toBe('tool-goal') - expect(toolGoal.inject).toEqual(['agents', 'goals', 'tools', 'systemPrompt', 'sessionProjections']) + expect(toolGoal.inject).toEqual(['agents', 'goals', 'tools', 'systemPrompt']) const loader = Object.create(Loader.prototype) as Loader expect(loader.unwrapExports(toolGoal)).toBe(toolGoal) }) @@ -206,7 +206,7 @@ describe('goal tool registration and presentation', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) await ctx.plugin(GoalService) - await expect(toolGoal.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 })).rejects.toThrow( + expect(() => { toolGoal.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 }) }).toThrow( 'blockedAfterConsecutiveRounds must be a positive safe integer', ) expect(ctx.tools.get('get_goal')).toBeUndefined() @@ -220,7 +220,7 @@ describe('goal tool registration and presentation', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) await ctx.plugin(GoalService) - await toolGoal.apply(ctx, {}) + toolGoal.apply(ctx, {}) const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal') expect(section?.text).toContain('at least 3 consecutive goal rounds') }) diff --git a/packages/goal/tool-goal/tsconfig.json b/packages/goal/tool-goal/tsconfig.json index fc97dd09aa..d5c34d4e29 100644 --- a/packages/goal/tool-goal/tsconfig.json +++ b/packages/goal/tool-goal/tsconfig.json @@ -34,9 +34,6 @@ }, { "path": "../goal" - }, - { - "path": "../../session/session-projection" } ] } diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts index a3df0f8d74..e9cb9a38c3 100644 --- a/packages/interaction/permission-presets/src/index.ts +++ b/packages/interaction/permission-presets/src/index.ts @@ -331,8 +331,7 @@ export class PermissionPresetService extends TypertRemoteService { } private permissionState(session: Session): PermissionProjectionState { - const state = this.projection.stateOf(session) - return state + return this.projection.stateOf(session) } /** diff --git a/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts index 5ba9c7f544..7218538e69 100644 --- a/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { createToolResultMessage, ToolCallId, type ToolSchema } from '@deepseek-ai/dsh-llm' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { Session, SessionId, SessionSeq, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' @@ -69,6 +70,7 @@ interface OptionSchemaShape { async function setup(config: toolAskUser.Config = { mode: 'timed', timeout: -1 }) { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) diff --git a/packages/interaction/user-questions/tests/user-questions.spec.ts b/packages/interaction/user-questions/tests/user-questions.spec.ts index 0cbb2a5031..1b03fa746a 100644 --- a/packages/interaction/user-questions/tests/user-questions.spec.ts +++ b/packages/interaction/user-questions/tests/user-questions.spec.ts @@ -198,6 +198,7 @@ describe('UserQuestionService', () => { it('rejects a live runtime-owned agent before reaching the provider', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } @@ -220,6 +221,7 @@ describe('UserQuestionService', () => { it('reaches the provider for a lineage-bearing session resumed as a runtime root', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const p = provider('yes') @@ -250,6 +252,7 @@ describe('UserQuestionService', () => { it('rejects a stale agent object that reuses a live id', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } @@ -389,8 +392,8 @@ function liveAgent(id: string): LiveAgent { async function timedContext(): Promise { const ctx = new Context() - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) return ctx } @@ -834,10 +837,12 @@ describe('late replies', () => { it('answers nothing when the composition carries no Session projections', async () => { const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) const agent = liveAgent('late-no-projections') - ctx.agents.enter(agent, undefined) + ctx.provide('agents', { + get: (id: SessionId) => id === agent.id ? agent : undefined, + roots: () => [agent], + } as never) + await ctx.plugin(UserQuestionService) askInLog(agent, timedCallId) continueInLog(agent, timedCallId) diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 538c956140..1457e8413b 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -5,6 +5,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' import { ToolCallId } from '@deepseek-ai/dsh-llm/brand' import { createUserMessage } from '@deepseek-ai/dsh-llm' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeKey } from '@deepseek-ai/dsh-scope' @@ -117,6 +118,7 @@ function producer(overrides: ProducerOverrides = {}) { async function harness(config: JobsConfig = {}) { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry, config) ctx.jobs.attachController('test-controller') @@ -183,6 +185,7 @@ describe('LocalJobRegistry.start', () => { it('refuses an owner whose own composition attaches no controller', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) // Two standing preset mounts over one registry; only the first loads the @@ -204,6 +207,7 @@ describe('LocalJobRegistry.start', () => { it('lets a controller attached without a scope serve every owner', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) // The host-plane composition's own controls: no scope, so the global layer @@ -911,6 +915,7 @@ describe('LocalJobRegistry owner cleanup', () => { it('registers owner cleanup on the agent scope rather than the jobs fiber', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const tasksFiber = await ctx.plugin(LocalJobRegistry) ctx.jobs.attachController('test-controller') @@ -977,6 +982,7 @@ describe('LocalJobRegistry owner cleanup', () => { describe('LocalJobRegistry disposal', () => { it('cancels live jobs, awaits settlement, and announces the teardown to outside subscribers', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(LocalJobRegistry) await ctx.plugin(Object.assign((inner: Context) => { @@ -1007,6 +1013,7 @@ describe('LocalJobRegistry disposal', () => { it('force-fails a throwing cancel so service disposal does not await producer done', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(LocalJobRegistry) ctx.jobs.attachController('test-controller') @@ -1044,6 +1051,7 @@ describe('LocalJobRegistry disposal', () => { it('detaches owner effects from still-live agent scopes when the service unloads', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const tasksFiber = await ctx.plugin(LocalJobRegistry) ctx.jobs.attachController('test-controller') @@ -1069,6 +1077,7 @@ describe('LocalJobRegistry disposal', () => { it('drops a scoped layer when its registrations dispose', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) const standing = createScope(ctx, {}) diff --git a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts index 0e69eca22c..d2e9daa981 100644 --- a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts +++ b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts @@ -4,6 +4,7 @@ import { ToolCallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry, { emitAgentEvent } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' @@ -45,6 +46,7 @@ async function setup(config: ToolJobs.Config = {}, registry: RegistryConfig = {} const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) const agentsFiber = await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry, registry) const toolsFiber = await ctx.plugin(ToolJobs, config) @@ -182,6 +184,7 @@ describe('tool-jobs setup', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await expect(ctx.plugin(ToolJobs, { waitTimeoutMs: 100, maxWaitTimeoutMs: 50 })) @@ -201,6 +204,7 @@ describe('tool-jobs setup', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) try { @@ -299,6 +303,7 @@ describe('tool-jobs setup', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) ToolJobs.apply(ctx, {}) @@ -704,6 +709,7 @@ describe('completion notices across scoped mounts', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 698065e66e..6952c493e2 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -13,6 +13,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context, LoggerLevel } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LocalAttachments from '@deepseek-ai/dsh-attachment-local' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import LlmRuntime, { BlockAssembler, createAssistantMessage, createSystemMessage, createToolResultMessage, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm' @@ -148,6 +149,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', () const unversionedModule = pathToFileURL(join(pluginRoot, 'plugin.mjs')).href const ctx = await boot() await ctx.plugin(Loader) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionStore) await ctx.plugin(DeepSeekLlmApiExtensionRegistry) diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index 9ad719a67d..5c401272fa 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -10,6 +10,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import LlmRuntime from '@deepseek-ai/dsh-llm' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { credentialRef } from '@deepseek-ai/dsh-credentials' import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local' @@ -60,6 +61,7 @@ async function loadComposition( " name: '@deepseek-ai/dsh-session'", '- id: agents', " name: '@deepseek-ai/dsh-agent'", + "- name: '@deepseek-ai/dsh-session-projection'", '- id: deepseek-llm-api-extensions', " name: '@deepseek-ai/dsh-deepseek-llm-api-extensions'", '- id: session-log-deepseek', @@ -94,6 +96,7 @@ async function loadComposition( ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-deepseek-llm-api-extensions', DeepSeekLlmApiExtensionRegistry], ['@deepseek-ai/dsh-session-log-deepseek', SessionLogDeepSeek], ['@deepseek-ai/dsh-plugin-package-inventory-deepseek', DeepSeekPluginPackageInventory], diff --git a/packages/llm/llm-deepseek/tests/runtime.e2e.ts b/packages/llm/llm-deepseek/tests/runtime.e2e.ts index 4738fe9b6a..6321512705 100644 --- a/packages/llm/llm-deepseek/tests/runtime.e2e.ts +++ b/packages/llm/llm-deepseek/tests/runtime.e2e.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LlmRuntime, { createToolResultMessage, createUserMessage, ToolCallId, ReasoningEffortId, createMessage, createSystemMessage } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import AttachmentStore, { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' @@ -228,6 +229,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () const ctx = new Context() contexts.push(ctx) await ctx.plugin(Loader) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 60cca5ba2f..016d19f400 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -19,7 +19,6 @@ export type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts' export { RetryId } from './brand.ts' export const name = 'llm-retry' - export const inject = ['agents', 'sessionProjections'] /** This policy executor has no config; providers own `retryPolicy`. */ diff --git a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts index 28793ba945..17b4a558f8 100644 --- a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts +++ b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts @@ -48,8 +48,8 @@ async function harness( await ctx.plugin(Loader) if (packageService) await ctx.plugin(PluginPackages) ctx.loader.builtins.include = Include - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(AgentPresets, { default: 'fixture' }) await ctx.plugin(DeepSeekLlmApiExtensionRegistry) const inventory = enabled === undefined @@ -260,6 +260,7 @@ describe('DeepSeek plugin package inventory', () => { const ctx = new Context() contexts.push(ctx) await ctx.plugin(Loader) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(DeepSeekLlmApiExtensionRegistry) await ctx.plugin(PluginInventory) diff --git a/packages/llm/plugin-package-inventory-deepseek/tests/private-package-composition.spec.ts b/packages/llm/plugin-package-inventory-deepseek/tests/private-package-composition.spec.ts index ce1c4c96cb..3b8607402c 100644 --- a/packages/llm/plugin-package-inventory-deepseek/tests/private-package-composition.spec.ts +++ b/packages/llm/plugin-package-inventory-deepseek/tests/private-package-composition.spec.ts @@ -8,6 +8,7 @@ import { Context, FiberState } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import * as PluginInventory from '../src/index.ts' @@ -41,6 +42,7 @@ it.each([true, false])('reports an unversioned package by name alongside a versi const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ '- name: cordis:agents', + '- name: cordis:projections', '- name: cordis:deepseek-extensions', '- name: cordis:plugin-inventory', `- name: ${JSON.stringify(privateUrl)}`, @@ -54,6 +56,7 @@ it.each([true, false])('reports an unversioned package by name alongside a versi await ctx.plugin(Loader) ctx.loader.builtins.include = Include ctx.loader.builtins.agents = AgentRegistry + ctx.loader.builtins.projections = SessionProjectionRegistry ctx.loader.builtins['deepseek-extensions'] = DeepSeekLlmApiExtensionRegistry ctx.loader.builtins['plugin-inventory'] = PluginInventory await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml index 17a68fbe02..ba794c93da 100644 --- a/packages/llm/token-meter/README.i18n.yaml +++ b/packages/llm/token-meter/README.i18n.yaml @@ -9,8 +9,8 @@ en: e51cc1114d193318 zh: f45cbb692bc8de69 /deepseek-ai-dsh-token-meter/summary: - en: 106e3eda9a85bdd1 - zh: 051bc764bf7a1912 + en: 7f5142b836b0338a + zh: 2735005241e2aae2 /deepseek-ai-dsh-token-meter/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -24,8 +24,8 @@ en: 58de3b364ab5af6b zh: c5440af43d321e56 /deepseek-ai-dsh-token-meter/use-this-package/session-projections: - en: 6720ae9c7a0602b2 - zh: 699a0b9927300aa3 + en: afa4b026b55ec1ba + zh: 9805b2924b6a786b /deepseek-ai-dsh-token-meter/use-this-package/composition: en: 1a0b7f49e01cba08 zh: 666213a1024767c5 diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index 51eee2f2ae..214970a2f1 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `ctx.tokenMeter` to estimate a session's current request and context pressure or price one message. Measurements replay the durable session log, remain deterministic, and make no model calls, so compaction, occupancy displays, and telemetry can share one result. When session projections are available, consumers can read `tokenUsage`, `contextPressure`, and `contextBreakdown`; text and routes without image pricing use an approximate fixed heuristic, declared visual-token pricing applies when available, and files are priced as model-visible handle text. Provider-reported usage is reused only for an identical request envelope; the package adds no model-visible content and makes no loop decisions. +Use `ctx.tokenMeter` to estimate a session's current request and context pressure or price one message. Measurements replay the durable session log, remain deterministic, and make no model calls, so compaction, occupancy displays, and telemetry can share one result. The service requires session projections; consumers can read `tokenUsage`, `contextPressure`, and `contextBreakdown`; text and routes without image pricing use an approximate fixed heuristic, declared visual-token pricing applies when available, and files are priced as model-visible handle text. Provider-reported usage is reused only for an identical request envelope; the package adds no model-visible content and makes no loop decisions. ## Table of Contents @@ -46,7 +46,7 @@ The measurement anchor includes the priced surface immediately before the succes ### Session projections -When the composition provides `ctx.sessionProjections`, token-meter registers three projection units. `tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. A final assistant-message sample replaces streaming usage from the same attempt; `llm/retry-started` ends that replacement scope, so a retry in the same step contributes another billed attempt. `contextPressure` carries optional `pressureTokens` (the newest provider-reported prompt size), optional `projectedTokens` (what the next request's prompt would cost), and optional `contextWindow` from the newest `request/context` record. `contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition, not its provider-billed size. Unloading the plugin removes all three keys. +Token-meter registers three projection units through its required `ctx.sessionProjections` service. Host consumers call `ctx.tokenMeter.contextPressure(session)` to read the current pressure fields synchronously; the registration handle stays private. `tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. A final assistant-message sample replaces streaming usage from the same attempt; `llm/retry-started` ends that replacement scope, so a retry in the same step contributes another billed attempt. `contextPressure` carries optional `pressureTokens` (the newest provider-reported prompt size), optional `projectedTokens` (what the next request's prompt would cost), and optional `contextWindow` from the newest `request/context` record. `contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition, not its provider-billed size. Unloading the plugin removes all three keys. Image offload reprices existing node identities while preserving prior usage anchors. The fixed reference heuristic excludes `offloaded` metadata, so an offload decision does not change `contextBreakdown` or the scalar heuristic total; route-aware measurement replaces the selected visual prices with placeholder text. diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md index 0dc929f492..8f8d4f8a7f 100644 --- a/packages/llm/token-meter/README.zh.md +++ b/packages/llm/token-meter/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用 `ctx.tokenMeter` 估算会话当前的请求与上下文压力,或为单条消息计价。测量会回放持久会话日志,结果确定且不进行模型调用,因此压缩、占用显示与遥测可以共享同一结果。会话投影可用时,消费方可以读取 `tokenUsage`、`contextPressure` 与 `contextBreakdown`;文本和没有图片定价的路由采用近似的固定启发式规则,存在声明时应用视觉 token 定价,文件则按模型可见的句柄文本计价。只有请求 envelope 完全相同时才复用提供方报告的用量;本包不添加模型可见内容,也不在 loop 中做决策。 +使用 `ctx.tokenMeter` 估算会话当前的请求与上下文压力,或为单条消息计价。测量会回放持久会话日志,结果确定且不进行模型调用,因此压缩、占用显示与遥测可以共享同一结果。该服务依赖会话投影;消费方可以读取 `tokenUsage`、`contextPressure` 与 `contextBreakdown`;文本和没有图片定价的路由采用近似的固定启发式规则,存在声明时应用视觉 token 定价,文件则按模型可见的句柄文本计价。只有请求 envelope 完全相同时才复用提供方报告的用量;本包不添加模型可见内容,也不在 loop 中做决策。 ## 目录 @@ -46,7 +46,7 @@ const price = ctx.tokenMeter.estimateMessage(message) ### 会话投影 -当组合提供 `ctx.sessionProjections` 时,token-meter 注册三个投影单元。`tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 与 `cacheWriteTokens`。最终 assistant 消息样本会替换同一次尝试的流式用量;`llm/retry-started` 会结束该替换范围,因此同一步骤中的重试会贡献另一次计费用量。`contextPressure` 携带可选 `pressureTokens`(提供方报告的最新提示词规模)、可选 `projectedTokens`(下一个请求的提示词将花费多少)与来自最新一条 `request/context` 记录的可选 `contextWindow`。`contextBreakdown` 携带启发式 `systemTokens`、`toolsTokens` 与 `messageTokens`——上下文的构成,而非提供方计费规模。卸载插件会移除全部三个键。 +token-meter 通过必需的 `ctx.sessionProjections` 服务注册三个投影单元。Host 消费方调用 `ctx.tokenMeter.contextPressure(session)` 同步读取当前压力字段;注册句柄保留在内部。`tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 与 `cacheWriteTokens`。最终 assistant 消息样本会替换同一次尝试的流式用量;`llm/retry-started` 会结束该替换范围,因此同一步骤中的重试会贡献另一次计费用量。`contextPressure` 携带可选 `pressureTokens`(提供方报告的最新提示词规模)、可选 `projectedTokens`(下一个请求的提示词将花费多少)与来自最新一条 `request/context` 记录的可选 `contextWindow`。`contextBreakdown` 携带启发式 `systemTokens`、`toolsTokens` 与 `messageTokens`——上下文的构成,而非提供方计费规模。卸载插件会移除全部三个键。 图片省略重新计算现有节点的价格,同时保留此前的用量锚点。固定引用启发式规则不计入 `offloaded` 元数据,因此一次省略决定不改变 `contextBreakdown` 或标量启发式总量,按路由的测量则把所选图片的视觉价格换成占位文本价格。 diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 7c1ac24432..367a0cab76 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -23,9 +23,10 @@ import { SessionLogOffset, SessionSeq, } from '@deepseek-ai/dsh-session' -// Type-only: activates the `ctx.sessionProjections` Context declaration. -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' +import type { SessionProjectionStateMap } from '@deepseek-ai/dsh-session-projection/types' import type { + ContextPressureProjection, TokenMeasurement, TokenMeasurementBaseline, TokenMeterConfig, @@ -38,7 +39,6 @@ import type { MeterSurfaceNode } from './surface-fold.ts' import { priceSurface } from './route-pricing.ts' export type * from './types.ts' -export { contextPressureProjectionDefinition } from './usage-projection.ts' // Module-edge re-export: forces the emitted index.d.ts to import the // projection-unit modules, so their SessionProjectionStateMap augmentations load // in aggregate programs that only import the package root. @@ -107,6 +107,7 @@ export class TokenMeter extends Service { static inject = ['sessionProjections'] private readonly states = new WeakMap() + private contextPressureProjection!: ProjectionHandle constructor(ctx: Context, config: TokenMeterConfig = {}) { super(ctx, 'tokenMeter') @@ -116,7 +117,7 @@ export class TokenMeter extends Service { protected async [Service.init](): Promise { const ctx = this.ctx await ctx.sessionProjections.register(tokenUsageProjectionDefinition) - await ctx.sessionProjections.register(contextPressureProjectionDefinition) + this.contextPressureProjection = await ctx.sessionProjections.register(contextPressureProjectionDefinition) await ctx.sessionProjections.register(contextBreakdownProjectionDefinition) // Readers catch up independently, while eager observation bounds ordinary @@ -126,6 +127,15 @@ export class TokenMeter extends Service { }) } + /** + * Read the latest logged context occupancy and its projected surface adjustment. + * @param session - Session whose committed usage and request capacity are read. + * @returns context pressure; fields remain absent until their source events are recorded. + */ + contextPressure(session: Session): ContextPressureProjection { + return contextPressureProjectionDefinition.wire.view(this.contextPressureProjection.stateOf(session)) + } + /** * Measure current request pressure and surface through the durable tail. * diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index b34e6e31ff..fee8d43ba5 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -361,14 +361,14 @@ function appendAssistant( describe('contextPressure session projection', () => { it('serves no pressure or capacity for an empty log', async () => { const { ctx, session } = await harness() - expect(pressure(ctx, session)).toEqual({}) + expect(ctx.tokenMeter.contextPressure(session)).toEqual({}) }) it('does not synthesize zero pressure before a provider usage sample', async () => { const { ctx, session } = await harness() startStep(session, 1, 1) recordContext(session, 'small', 64_000) - expect(pressure(ctx, session)).toEqual({ contextWindow: 64_000 }) + expect(ctx.tokenMeter.contextPressure(session)).toEqual({ contextWindow: 64_000 }) }) it('sums prompt-side buckets and excludes response output', async () => { @@ -382,7 +382,7 @@ describe('contextPressure session projection', () => { }, 1, 1) // Output is deliberately absent: occupancy describes the prompt that was // sent, so it holds still while the response streams. - expect(pressure(ctx, session).pressureTokens).toBe(125) + expect(ctx.tokenMeter.contextPressure(session)).toEqual({ pressureTokens: 125, projectedTokens: 125 }) }) it('replaces pressure with the newest request rather than accumulating', async () => { diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 6f05c60d1d..603810713a 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -25,7 +25,6 @@ import { Context, Service } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' import { z as zod } from 'zod' import type { ZodType } from 'zod' -import { turnBoundaryProjectionDefinition, type TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' @@ -174,12 +173,11 @@ export const planProjectionDefinition = { * Client carriers expose the projection's cropped `{ active, pending }` view. */ export class PlanModeController extends Service { - static inject = ['tools', 'systemPrompt', 'sessionProjections'] + static inject = ['tools', 'systemPrompt', 'sessionProjections', 'agents'] /** Validated deployment-owned guidance. */ private readonly section: string private projection!: ProjectionHandle - private turnBoundary!: ProjectionHandle /** * Latest selection per session awaiting the next accepted in-turn pre-step. @@ -196,7 +194,6 @@ export class PlanModeController extends Service { protected async [Service.init](): Promise { const ctx = this.ctx this.projection = await ctx.sessionProjections.register(planProjectionDefinition) - this.turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) let disposed = false // Pre-step is outside Session.append publication, so it can append the // log-only mode event inside an open turn without re-entering the session. @@ -232,7 +229,6 @@ export class PlanModeController extends Service { }, }) - // The command child activates only when a command registry is composed. ctx.inject(['commands'], (commandCtx) => { commandCtx.commands.register({ @@ -385,18 +381,15 @@ export class PlanModeController extends Service { } private hasOpenTurn(session: Session): boolean { - const state = this.turnBoundary.stateOf(session) - return state.openTurnStartSeq !== null + return this.ctx.agents.turnBoundaryOf(session).openTurnStartSeq !== null } private loggedActiveAtLastHeader(session: Session): boolean | undefined { return this.planState(session).activeAtLastHeader ?? undefined } - /** Read the required plan projection state or fail at the first service access. */ private planState(session: Session): PlanUnitState { - const state = this.projection.stateOf(session) - return state + return this.projection.stateOf(session) } /** diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 2ba26b9a09..9bc5917ea8 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -13,7 +13,6 @@ import UserQuestionService, { import CommandRuntime from '@deepseek-ai/dsh-commands' import { PtcRuntime, type PtcRunRequest, type PtcRunResult } from '@deepseek-ai/dsh-ptc-runtime' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import PlanModeController, { EXIT_PLAN_MODE, planProjectionDefinition, resolveConfig } from '../src/index.ts' import type { PlanModeConfig } from '../src/index.ts' import type { PlanUnitState } from '../src/types.ts' @@ -89,7 +88,7 @@ function foldPlanMode(events: readonly SessionEvent[], end = events.length): boo async function mountProjectionSeam(ctx: Context): Promise { await ctx.plugin(SessionProjectionRegistry) - await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.plugin(AgentRegistry) } async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise { @@ -770,7 +769,6 @@ describe('/plan', () => { describe('exit_plan_mode', () => { async function setupWithReview(answer?: { selected: string[]; custom?: string }) { const ctx = await setup() - await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const asked: AskUserQuestionRequest[] = [] if (answer !== undefined) { @@ -850,7 +848,6 @@ describe('exit_plan_mode', () => { it('rejects review from a runtime-owned agent with consumer-neutral guidance', async () => { const ctx = await setup() - await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const ask = vi.fn(async () => ({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })) registerQuestionAnswerer(ctx, { ask }) @@ -907,7 +904,6 @@ describe('exit_plan_mode', () => { provideWorkingDirectoryFixture(ctx) await mountProjectionSeam(ctx) await ctx.plugin(PlanModeController, PLAN_CONFIG) - await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const asked: AskUserQuestionRequest[] = [] registerQuestionAnswerer(ctx, { @@ -1086,7 +1082,6 @@ describe('exit_plan_mode', () => { await ctx.plugin(ToolRuntime) await mountProjectionSeam(ctx) const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG) - await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) let answer!: (value: { answers: { id: string; selected: string[] }[] }) => void registerQuestionAnswerer(ctx, { diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index 99c2b538ea..8809293a5d 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -25,8 +25,8 @@ async function harness(withPlanMode: boolean): Promise { await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(UserQuestionService) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) if (withPlanMode) await ctx.plugin(PlanModeController, { section: 'plan policy' }) const session = ctx.sessions.create() await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) diff --git a/packages/preset/agent-preset-registry/README.i18n.yaml b/packages/preset/agent-preset-registry/README.i18n.yaml index ae11417a45..09ae85b7e6 100644 --- a/packages/preset/agent-preset-registry/README.i18n.yaml +++ b/packages/preset/agent-preset-registry/README.i18n.yaml @@ -21,8 +21,8 @@ en: cd28d9b4f46c46a8 zh: 4921d853f98469fe /deepseek-ai-dsh-agent-preset-registry/understand-the-implementation: - en: 7df811c1347b9a94 - zh: 141a7feb5ca597fe + en: 32c8a5c63022e275 + zh: 1d53e28b0d7375da /deepseek-ai-dsh-agent-preset-registry/further-exploration: en: bbae8d79844bc481 zh: 4302107fd1f460e6 diff --git a/packages/preset/agent-preset-registry/README.md b/packages/preset/agent-preset-registry/README.md index 02530d05d3..bc4b722daf 100644 --- a/packages/preset/agent-preset-registry/README.md +++ b/packages/preset/agent-preset-registry/README.md @@ -57,7 +57,7 @@ Each declaration eagerly creates a registry-owned scope and an in-memory Loader A preset service provider and its consumers must share one `cordis:group` isolation realm. Entry-local isolation does not propagate through the Agent registration scope: ordinary Context lookup outside the group, including `agent.ctx`, still selects the Host realm. `agentPresets.serviceFor()` explicitly inspects the retained composition; it does not change ordinary service lookup. -Activation auditing checks imports, missing services and globally leaked services. Import failures, activation failures and leaks reject the mount. A row waiting for a Host service stays mounted, and every read and binding re-audits it after the Host Loader tree settles, so startup order does not decide the outcome. Failure prevents new bindings to that definition. Session logs retain the preset ID and blank-session selections; recovery after restart uses the current definition of that ID and rejects a missing definition. `select` leaves a blank Session that records the requested preset and already runs its current revision unchanged, without another selection record or `tools/change`. +Activation auditing checks imports, missing services and globally leaked services. Import failures, activation failures and leaks reject the mount. A row waiting for a Host service stays mounted, and every read and binding re-audits it after the Host Loader tree settles, so startup order does not decide the outcome. Failure prevents new bindings to that definition. `presetForSession(session)` synchronously reads the latest recorded preset ID, or null when none is recorded. Session logs retain the preset ID and blank-session selections; recovery after restart uses the current definition of that ID and rejects a missing definition. `select` leaves a blank Session that records the requested preset and already runs its current revision unchanged, without another selection record or `tools/change`. `inspectCompositions()` reads all revisions retained by this registry; passing an Agent context selects its exact revision, or returns an empty list when it has no binding here. Each `AgentPresetInspection` contains a preset ID, detached active-module references with their resolution bases, and leaked-service names. Inspectors query the runtime service rather than a module-local mount table; the results contain no Loader trees or fibers. [composition-inventory.ts](src/composition-inventory.ts) defines these records. diff --git a/packages/preset/agent-preset-registry/README.zh.md b/packages/preset/agent-preset-registry/README.zh.md index 334a72fd96..5d9a105c42 100644 --- a/packages/preset/agent-preset-registry/README.zh.md +++ b/packages/preset/agent-preset-registry/README.zh.md @@ -57,7 +57,7 @@ Web 内置定义来自 `dsh-web-app` bundle。定义使用普通插件行;注 preset 的服务提供方及其消费方必须共享同一个 `cordis:group` 隔离 realm。条目局部隔离不会沿 Agent 注册 scope 传播:组外的普通 Context 查找(包括 `agent.ctx`)仍选择 Host realm。`agentPresets.serviceFor()` 显式检查保留的组合,不改变普通服务查找。 -激活审计检查导入失败、缺失服务和向全局泄漏的服务。导入失败、激活失败和泄漏会拒绝挂载。等待 Host 服务的行保持挂载,每次读取和绑定都在 Host Loader 树结算后重新审计,因此启动顺序不决定结果。失败只禁用该定义的新绑定。会话日志保存 preset ID 和空白会话的切换记录;重启恢复使用该 ID 的当前定义,缺失时拒绝恢复。若空白会话已记录所请求的 preset 且正运行其当前版本,`select` 不做任何改动,也不追加切换记录或触发 `tools/change`。 +激活审计检查导入失败、缺失服务和向全局泄漏的服务。导入失败、激活失败和泄漏会拒绝挂载。等待 Host 服务的行保持挂载,每次读取和绑定都在 Host Loader 树结算后重新审计,因此启动顺序不决定结果。失败只禁用该定义的新绑定。`presetForSession(session)` 同步读取最新记录的预设 ID,未记录时返回 null。会话日志保存 preset ID 和空白会话的切换记录;重启恢复使用该 ID 的当前定义,缺失时拒绝恢复。若空白会话已记录所请求的 preset 且正运行其当前版本,`select` 不做任何改动,也不追加切换记录或触发 `tools/change`。 `inspectCompositions()` 读取本注册表仍保留的全部代际;传入 Agent 上下文时只选择它绑定的精确代际,在此没有绑定时返回空列表。每个 `AgentPresetInspection` 包含 preset ID、纯数据形式的活动模块引用及解析基址,以及泄漏的服务名。检查器查询运行中的服务,而不是模块级挂载表;结果不包含 Loader 树或 fiber。这些记录定义在 [composition-inventory.ts](src/composition-inventory.ts) 中。 diff --git a/packages/preset/agent-preset-registry/src/index.ts b/packages/preset/agent-preset-registry/src/index.ts index 6e79d2ded7..4b4bb467d0 100644 --- a/packages/preset/agent-preset-registry/src/index.ts +++ b/packages/preset/agent-preset-registry/src/index.ts @@ -1,5 +1,4 @@ /** Declarative Agent capability sets, activation and session binding. */ -import { turnBoundaryProjectionDefinition, type TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' @@ -8,6 +7,7 @@ import { bindScopeParent, createScope, scopeOf, scopeParentOf, type Scope, type import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { dump } from 'js-yaml' import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Session } from '@deepseek-ai/dsh-session' // Type-only: the optional `settings` service this registry keeps off the generated pages. import type {} from '@deepseek-ai/dsh-settings' import type {} from '@deepseek-ai/dsh-tools' @@ -52,12 +52,11 @@ interface Binding { /** Registry of YAML-declared presets and the revisions live Agents retain. */ export class AgentPresetRegistry extends TypertRemoteService { - static inject = ['loader', 'sessionProjections'] + static inject = ['loader', 'sessionProjections', 'agents'] static Config = z.object({ default: z.string().required(), selectedDefault: z.string().volatile(), }) - private turnBoundary!: ProjectionHandle private agentPreset!: ProjectionHandle private readonly owner: Context private readonly definitions = new Map() @@ -73,7 +72,6 @@ export class AgentPresetRegistry extends TypertRemoteService { protected async [Service.init](): Promise { const ctx = this.owner this.agentPreset = await ctx.sessionProjections.register(agentPresetProjectionDefinition) - this.turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) }) ctx.on('session/event', (session, event) => { if (event.type === 'agent-preset/selected') ctx.emit('agent-preset/selected', session.id, event.data.agentPreset) @@ -83,6 +81,15 @@ export class AgentPresetRegistry extends TypertRemoteService { /** Default preset for a subsequently created session. */ get defaultId(): string { return this.config.selectedDefault.get() ?? this.config.default } + /** + * Read the preset recorded for a Session, including selections no longer in the roster. + * @param session - Session whose committed preset selection is read. + * @returns the recorded preset, or null when none was selected. + */ + presetForSession(session: Session): string | null { + return this.agentPreset.stateOf(session) + } + /** Register and eagerly load a definition; activation failure remains visible in the roster. * @param definition Parsed configuration supplied by the declaring plugin. * @returns Definition disposer after activation or its diagnostic settles; the declaring plugin owns it. @@ -357,7 +364,7 @@ export class AgentPresetRegistry extends TypertRemoteService { @Remote('select') async select(agent: Agent, agentPreset: string): Promise { const turn = (this.switches.get(agent.id) ?? Promise.resolve()).then(async () => { - const boundary = this.turnBoundary.stateOf(agent.session) + const boundary = this.owner.agents.turnBoundaryOf(agent.session) if (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0) { throw new RemoteError('agent-preset/locked', 'This session has already started', { sessionId: agent.id, agentPreset }) } diff --git a/packages/preset/agent-preset-registry/tests/registry.spec.ts b/packages/preset/agent-preset-registry/tests/registry.spec.ts index 9616f78769..2c9c4486f3 100644 --- a/packages/preset/agent-preset-registry/tests/registry.spec.ts +++ b/packages/preset/agent-preset-registry/tests/registry.spec.ts @@ -1,12 +1,12 @@ import { afterEach, describe, expect, it } from 'vitest' import { createScope } from '@deepseek-ai/dsh-scope' -import { assembleContextFor } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent' import { entryListProblem } from '../src/index.ts' import { currentKey, harness, declare, contribution, agentOn, liveRegistries, plugin } from './harness.ts' import { omitsGeneratedPage } from '../../../settings/settings/tests/live-config.ts' import Loader from '@deepseek-ai/cordis-plugin-loader' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import AgentPresets, { agentPresetProjectionDefinition } from '../src/index.ts' +import AgentPresets from '../src/index.ts' import type { Context } from '@deepseek-ai/cordis' const contexts: Context[] = [] @@ -246,6 +246,7 @@ it('ignores a retired modeSelectionEnabled field in the user patch', async () => it('keeps its own instance off the generated Settings pages', () => omitsGeneratedPage(async (ctx) => { await ctx.plugin(Loader) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) return ctx.plugin(AgentPresets, { default: 'standard' }) })) @@ -317,12 +318,11 @@ it('logs the default preset a blank Session runs once it is selected without a r const ctx = await setup() await declare(ctx, contribution('standard')) const agent = await agentOn(ctx, 'unrecorded') - const preset = await ctx.sessionProjections.register(agentPresetProjectionDefinition) - expect(preset.stateOf(agent.session)).toBeNull() + expect(ctx.agentPresets.presetForSession(agent.session)).toBeNull() expect(await ctx.agentPresets.select(agent, 'standard')).toBe('standard') - expect(preset.stateOf(agent.session)).toBe('standard') + expect(ctx.agentPresets.presetForSession(agent.session)).toBe('standard') }) it('recomposes and logs a repeated selection once the preset definition was replaced', async () => { diff --git a/packages/schedule/schedule/tests/harness.ts b/packages/schedule/schedule/tests/harness.ts index bf152ad187..768125a479 100644 --- a/packages/schedule/schedule/tests/harness.ts +++ b/packages/schedule/schedule/tests/harness.ts @@ -1,6 +1,7 @@ /** Host Schedule tests use real domain storage and an explicit Session resolver. */ import { Context, Service } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import type { UserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -72,6 +73,7 @@ export async function harness(options: { const ctx = new Context() options.onContext?.(ctx) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(SystemPrompt, {}) await ctx.plugin(ToolRuntime) diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts index 2553bb43a2..f5d4e785cd 100644 --- a/packages/sdk/server/tests/server.spec.ts +++ b/packages/sdk/server/tests/server.spec.ts @@ -12,6 +12,7 @@ import { Context } from '@deepseek-ai/cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import { randomUUID } from 'node:crypto' import AgentRegistry, { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -456,6 +457,7 @@ describe('HarnessSdkJsonRpcServer', () => { it('forwards whole-agent status without attributing a turn outcome', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const transport = new FakeTransport() const server = new HarnessSdkJsonRpcServer(ctx, transport) diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 90321e2e39..96becd6d49 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -18,14 +18,14 @@ en: 3a15581f0b043e79 zh: 302fc8afe8fb45fa /deepseek-ai-dsh-session-projection/use-this-package/when-to-choose-it: - en: 7ea79ed69e0ea085 - zh: b7791be4c547495c + en: e16fd6f4fcace77c + zh: 4488f7f0b4751ea4 /deepseek-ai-dsh-session-projection/use-this-package/define-a-projection-unit: en: 09758d1475fad86e zh: 473637cb34aebe72 /deepseek-ai-dsh-session-projection/use-this-package/register-and-read: - en: 52120bc6e18a8da8 - zh: c45ca6a83a6fcd4e + en: 43af8fda90742877 + zh: 4d03fef88cb0eb54 /deepseek-ai-dsh-session-projection/use-this-package/persisted-checkpoints: en: 45f1c05fb7e08180 zh: f8d62c749899651a diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 9a1330ed60..4ddaed7c6b 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -29,7 +29,7 @@ Mount `dsh-session-projection` wherever client carriers need current values of l ### When to choose it -Choose it when a domain keeps state that clients should see without re-deriving it — a todo list, a goal snapshot, conversation statistics. The registry drives units eagerly over committed events, so any registered unit's value is current by construction. A unit without a `wire` block keeps its state host-only. Host readers declare `sessionProjections` in their plugin dependencies and await registration before exposing synchronous reads. +Choose it when a domain keeps state that clients should see without re-deriving it — a todo list, a goal snapshot, conversation statistics. The registry drives units eagerly over committed events, so any registered unit's value is current by construction. A unit without a `wire` block keeps its state host-only. Domain owners declare `sessionProjections` in their plugin dependencies and await registration before exposing synchronous service methods. Other plugins read those methods through the owning service. ### Define a projection unit @@ -63,7 +63,7 @@ const state = projection.stateOf(session) const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ``` -A domain that requires projected state declares `sessionProjections` as a Cordis service dependency; optional contributors may register under `ctx.inject(['sessionProjections'], …)`. Carriers use `ctx.get('sessionProjections')` and omit their block or frames when the registry is absent. +A domain that owns projected state declares `sessionProjections` as a Cordis service dependency and keeps its handle private. Consumers inject that domain service and use its methods, such as `ctx.agents.turnBoundaryOf(session)`, without importing definitions or retaining projection readers. Optional contributors may register under `ctx.inject(['sessionProjections'], …)`. Carriers use `ctx.get('sessionProjections')` and omit their block or frames when the registry is absent. ### Persisted checkpoints diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 865d9e8537..de05300b76 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 何时选择 -当领域保存客户端应看到、但不应自行重新派生的状态——todo 清单、goal 快照、对话统计——时选择本包。注册表在已提交事件上主动驱动单元,因此任何已注册单元的值按构造即为当前值。不带 `wire` 块的单元将状态保留在 host。host 读取方在插件依赖中声明 `sessionProjections`,并等待注册完成后才提供同步读取。 +当领域保存客户端应看到、但不应自行重新派生的状态——todo 清单、goal 快照、对话统计——时选择本包。注册表在已提交事件上主动驱动单元,因此任何已注册单元的值按构造即为当前值。不带 `wire` 块的单元将状态保留在 host。领域所有者在插件依赖中声明 `sessionProjections`,并等待注册完成后才提供同步服务方法。其他插件通过所属服务调用这些方法。 ### 定义投影单元 @@ -63,7 +63,7 @@ const state = projection.stateOf(session) const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ``` -必须使用投影状态的领域把 `sessionProjections` 声明为 Cordis 服务依赖;可选贡献方可以在 `ctx.inject(['sessionProjections'], …)` 下注册。载体使用 `ctx.get('sessionProjections')`,注册表缺席时省略自己的块或帧。 +持有投影状态的领域将 `sessionProjections` 声明为 Cordis 服务依赖,并在内部保留句柄。消费方注入该领域服务并使用其方法,例如 `ctx.agents.turnBoundaryOf(session)`,无需导入定义或保留投影读取句柄。可选贡献方可以在 `ctx.inject(['sessionProjections'], …)` 下注册。载体使用 `ctx.get('sessionProjections')`,注册表缺席时省略自己的块或帧。 ### 持久检查点 diff --git a/packages/session/session-stats/src/index.ts b/packages/session/session-stats/src/index.ts index 6439070f01..570a21daaa 100644 --- a/packages/session/session-stats/src/index.ts +++ b/packages/session/session-stats/src/index.ts @@ -14,8 +14,9 @@ import { sessionStatsProjectionDefinition } from './projection.ts' export type * from './types.ts' +/** Cordis plugin name. */ export const name = 'session-stats' - +/** The projection registry is the plugin's whole purpose; without it the fiber stays pending. */ export const inject = ['sessionProjections'] /** diff --git a/packages/session/session-title-first-prompt-llm/tests/loader-composition.spec.ts b/packages/session/session-title-first-prompt-llm/tests/loader-composition.spec.ts index 410e4c2ecf..5dce4f54d5 100644 --- a/packages/session/session-title-first-prompt-llm/tests/loader-composition.spec.ts +++ b/packages/session/session-title-first-prompt-llm/tests/loader-composition.spec.ts @@ -12,6 +12,7 @@ import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionSeq } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionTitleService from '@deepseek-ai/dsh-session-title' import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-prompt-llm' import { anthropicTextEvents, closeMockServers, mockServer } from '../../../llm/llm-pi-ai/tests/mock-server.ts' @@ -56,6 +57,7 @@ async function loadComposition( "- name: '@deepseek-ai/dsh-llm'", "- name: '@deepseek-ai/dsh-session'", "- name: '@deepseek-ai/dsh-session-projection'", + "- name: '@deepseek-ai/dsh-agent'", "- name: '@deepseek-ai/dsh-session-title'", ' config:', ' fallbackMaxWords: 5', @@ -78,6 +80,7 @@ async function loadComposition( ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], + ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-session-title', SessionTitleService], ['@deepseek-ai/dsh-session-title-first-prompt-llm', providerPlugin], ...entryModules, diff --git a/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts b/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts index 92565e1cc9..d4d8fa6615 100644 --- a/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts +++ b/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts @@ -1,10 +1,10 @@ +import AgentRegistry from '@deepseek-ai/dsh-agent' import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, onTestFinished } from 'vitest' import LlmRuntime, { createUserMessage, LlmAdapter, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SessionTitleService, { type SessionTitleProvider } from '@deepseek-ai/dsh-session-title' import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-prompt-llm' @@ -76,7 +76,7 @@ async function harness( await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, TITLE_CONFIG) const adapter = new RecordingAdapter(script, reasoningEfforts) ctx.llm.registerAdapter(['title-route'], adapter) @@ -249,7 +249,7 @@ describe('first-prompt LLM title provider', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, TITLE_CONFIG) // Delegate to the real registration while capturing it, to reach the guard // the service never produces. diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 3d5dc3d7e0..8b888b80d4 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -3,7 +3,6 @@ * @module @deepseek-ai/dsh-session-title */ -import { turnBoundaryProjectionDefinition, type TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' import { Context, FiberState, Service, type Fiber } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' @@ -19,6 +18,7 @@ import type { import { SessionSeq } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition, ProjectionHandle } from '@deepseek-ai/dsh-session-projection' +import type {} from '@deepseek-ai/dsh-agent' export type { SessionTitleEventData, SessionTitleModelIdentity, @@ -299,14 +299,13 @@ export function foldSessionTitle(events: readonly SessionEvent[]): SessionTitleS /** Log-backed title fold plus asynchronous fallback generation. */ export class SessionTitleService extends Service { - static inject = ['sessions', 'sessionProjections'] + static inject = ['sessions', 'sessionProjections', 'agents'] static Config: z = z.object({ fallbackMaxWords: z.number().step(1).min(1).required(), fallbackMaxBytes: z.number().step(1).min(1).required(), maxTitleBytes: z.number().step(1).min(1).required(), }) - private turnBoundary!: ProjectionHandle private titleInput!: ProjectionHandle private readonly config: ResolvedConfig private readonly ownerFiber: Fiber @@ -334,8 +333,6 @@ export class SessionTitleService extends Service { protected async [Service.init](): Promise { const ctx = this.ctx - this.turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) - ctx.effect(() => async () => { this.lifetime.abort(new Error('session-title service disposed')) if (this.registration !== undefined) this.registration.closing = true @@ -556,7 +553,7 @@ export class SessionTitleService extends Service { const state = session === undefined ? undefined : this.work.get(session) const pending = state?.pending if (session === undefined || state === undefined || pending === undefined) return - const boundary = this.turnBoundary.stateOf(session).lastStepBoundary + const boundary = this.ctx.agents.turnBoundaryOf(session).lastStepBoundary const route = session.requestHeader()?.config if (boundary?.kind !== 'start' || route?.provider !== options.provider diff --git a/packages/session/session-title/tests/persistence.spec.ts b/packages/session/session-title/tests/persistence.spec.ts index dd85dd1376..4edfdd5579 100644 --- a/packages/session/session-title/tests/persistence.spec.ts +++ b/packages/session/session-title/tests/persistence.spec.ts @@ -5,6 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionTitleService, { foldSessionTitle } from '@deepseek-ai/dsh-session-title' @@ -68,6 +69,7 @@ describe('session title persistence round trips', () => { const writer = new Context() await writer.plugin(SessionStore) await writer.plugin(SessionProjectionRegistry) + await writer.plugin(AgentRegistry) await writer.plugin(JsonlSessionPersistence, { root, compression: 'none' }) await writer.plugin(SessionTitleService, CONFIG) await appendPersistedTitle(writer, id) diff --git a/packages/session/session-title/tests/projection.spec.ts b/packages/session/session-title/tests/projection.spec.ts index 784df3c69d..aa39739120 100644 --- a/packages/session/session-title/tests/projection.spec.ts +++ b/packages/session/session-title/tests/projection.spec.ts @@ -3,6 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionSeq as SessionSeqType } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService from '@deepseek-ai/dsh-session-title' @@ -12,6 +13,7 @@ async function harness(withTitleService: boolean): Promise<{ ctx: Context; sessi const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) if (withTitleService) await ctx.plugin(SessionTitleService, CONFIG) return { ctx, session: ctx.sessions.create(SessionId('titled')) } } diff --git a/packages/session/session-title/tests/provider.spec.ts b/packages/session/session-title/tests/provider.spec.ts index f08002e145..f7e7830048 100644 --- a/packages/session/session-title/tests/provider.spec.ts +++ b/packages/session/session-title/tests/provider.spec.ts @@ -1,10 +1,10 @@ +import AgentRegistry from '@deepseek-ai/dsh-agent' import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, onTestFinished, vi } from 'vitest' import LlmRuntime, { createUserMessage, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' import { deepFreeze } from '@deepseek-ai/dsh-util-values' import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SessionTitleService, { SessionTitleProviderId, type SessionTitleProvider, @@ -56,6 +56,7 @@ describe('SessionTitleService Provider lifecycle', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const parent = ctx.sessions.create(SessionId('title-parent')) parent.append('turn/start', { @@ -121,6 +122,7 @@ describe('SessionTitleService Provider lifecycle', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const requests: SessionTitleProviderRequest[] = [] const provider: SessionTitleProvider = { @@ -185,6 +187,7 @@ describe('SessionTitleService Provider lifecycle', () => { onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 1, maxTitleBytes: 24 }) const currentTitles: (SessionTitleSnapshot | undefined)[] = [] ctx.sessionTitle.register({ @@ -214,6 +217,7 @@ describe('SessionTitleService Provider lifecycle', () => { onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const currentTitles: (SessionTitleSnapshot | undefined)[] = [] ctx.sessionTitle.register({ @@ -255,6 +259,7 @@ describe('SessionTitleService Provider lifecycle', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('chunked-provider-input')) session.append('turn/start', { turn: 1 }) @@ -284,6 +289,7 @@ describe('SessionTitleService Provider lifecycle', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const requests: SessionTitleProviderRequest[] = [] ctx.sessionTitle.register({ @@ -320,6 +326,7 @@ describe('SessionTitleService Provider lifecycle', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const pending = deferred() let observedSignal: AbortSignal | undefined @@ -372,6 +379,7 @@ describe('SessionTitleService Provider lifecycle', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const pending = deferred() const dispose = ctx.sessionTitle.register({ @@ -407,6 +415,7 @@ describe('SessionTitleService Provider lifecycle', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const firstResult = deferred() const requests: SessionTitleProviderRequest[] = [] @@ -452,7 +461,7 @@ describe('SessionTitleService Provider lifecycle', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const requests: SessionTitleProviderRequest[] = [] ctx.sessionTitle.register({ @@ -508,7 +517,7 @@ describe('SessionTitleService Provider lifecycle', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise => ({ title: 'Unexpected title', @@ -541,6 +550,7 @@ describe('SessionTitleService Provider lifecycle', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const provider: SessionTitleProvider = { diff --git a/packages/session/session-title/tests/rename.spec.ts b/packages/session/session-title/tests/rename.spec.ts index 9654c18edc..184c03df88 100644 --- a/packages/session/session-title/tests/rename.spec.ts +++ b/packages/session/session-title/tests/rename.spec.ts @@ -5,6 +5,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService, { SessionTitleProviderId, @@ -34,6 +35,7 @@ describe('SessionTitleService.rename', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('rename-accept')) session.append('turn/start', { turn: 1 }) @@ -60,6 +62,7 @@ describe('SessionTitleService.rename', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('rename-reject')) expect(() => ctx.sessionTitle.rename(session, '  ')).toThrow(/visible characters/) @@ -72,6 +75,7 @@ describe('SessionTitleService.rename', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const generate = vi.fn(async (request: SessionTitleProviderRequest) => ({ title: 'Provider title', @@ -110,6 +114,7 @@ describe('SessionTitleService.rename', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('rename-unpin-fallback')) session.append('turn/start', { turn: 1 }) @@ -132,6 +137,7 @@ describe('SessionTitleService.rename', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) // The provider parks on a test-held deferred so rename lands while its // generation is ACTIVE (not merely scheduled). @@ -172,6 +178,7 @@ describe('SessionTitleService.rename', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) // A 3-byte fallback cap cannot hold the 4-byte emoji prompt: the // re-derived fallback is empty, so the pinned title survives the refresh. await ctx.plugin(SessionTitleService, { ...CONFIG, fallbackMaxBytes: 3 }) diff --git a/packages/session/session-title/tests/service-contracts.spec.ts b/packages/session/session-title/tests/service-contracts.spec.ts index c6e0dc93c3..644b6074f9 100644 --- a/packages/session/session-title/tests/service-contracts.spec.ts +++ b/packages/session/session-title/tests/service-contracts.spec.ts @@ -2,6 +2,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { Context, type Fiber } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService, { SessionTitleProviderId, @@ -31,6 +32,7 @@ async function setup(config: Config = CONFIG): Promise { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, config) return ctx } @@ -244,6 +246,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) const lifecycle: { fiber?: Fiber; session?: Session; inactiveRefresh?: Promise } = {} ctx.on('internal/plugin', (subject) => { if (subject !== lifecycle.fiber || subject.uid !== null || lifecycle.session === undefined) return @@ -277,6 +280,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(SessionTitleService, CONFIG) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const session = startSession(ctx, 'service-unload-started-fallback') @@ -293,6 +297,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(SessionTitleService, CONFIG) const result = deferred() const requests: SessionTitleProviderRequest[] = [] diff --git a/packages/session/session-title/tests/session-title.spec.ts b/packages/session/session-title/tests/session-title.spec.ts index 1349c52d4b..43d439bb06 100644 --- a/packages/session/session-title/tests/session-title.spec.ts +++ b/packages/session/session-title/tests/session-title.spec.ts @@ -3,6 +3,7 @@ import type { ContextFormed } from '@deepseek-ai/dsh-llm' import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService, { SessionTitleProviderId, @@ -48,6 +49,7 @@ describe('SessionTitleService', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('fresh')) session.append('turn/start', { @@ -85,6 +87,7 @@ describe('SessionTitleService', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('prefixed-title')) session.append('user/message', createUserMessage({ @@ -113,6 +116,7 @@ describe('SessionTitleService', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('eligibility')) session.append('turn/start', { diff --git a/packages/session/session-title/tsconfig.json b/packages/session/session-title/tsconfig.json index e056eda2bd..5755ac55e2 100644 --- a/packages/session/session-title/tsconfig.json +++ b/packages/session/session-title/tsconfig.json @@ -31,6 +31,7 @@ }, { "path": "../../core/agent" + } ] } diff --git a/packages/session/session-turn-outline/src/index.ts b/packages/session/session-turn-outline/src/index.ts index ab2c5f667e..a5dd6f2198 100644 --- a/packages/session/session-turn-outline/src/index.ts +++ b/packages/session/session-turn-outline/src/index.ts @@ -14,8 +14,9 @@ import { turnOutlineProjectionDefinition } from './projection.ts' export type * from './types.ts' +/** Cordis plugin name. */ export const name = 'session-turn-outline' - +/** The projection registry is the plugin's whole purpose; without it the fiber stays pending. */ export const inject = ['sessionProjections'] /** diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index d3507373c7..a094cd0d13 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -4,6 +4,7 @@ import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import TerminalSessionService from '@deepseek-ai/dsh-terminal' import type { @@ -307,6 +308,7 @@ async function setup( contexts.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) const stub = stubBackend(initialMode) @@ -715,6 +717,7 @@ describe('tool-bash-persistent', () => { contexts.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) const spawnStarted = Promise.withResolvers() diff --git a/packages/shell/tool-bash/tests/background.spec.ts b/packages/shell/tool-bash/tests/background.spec.ts index a4c6cd41de..c2e5db5201 100644 --- a/packages/shell/tool-bash/tests/background.spec.ts +++ b/packages/shell/tool-bash/tests/background.spec.ts @@ -9,6 +9,7 @@ import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { provideWorkingDirectoryFixture, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' @@ -37,6 +38,7 @@ async function setup() { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry, { pumpPollMs: 25 }) await ctx.plugin(ToolTasks) @@ -424,6 +426,7 @@ describe('foreground commands as jobs', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry, { pumpPollMs: 25 }) await ctx.plugin(ToolTasks) diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 96fd9aa7b8..9a95c328f9 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -11,7 +11,6 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import { SessionId } from '@deepseek-ai/dsh-session' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as ToolJobs from '@deepseek-ai/dsh-tool-jobs' @@ -51,6 +50,7 @@ async function setup() { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalSubprocessRuntime) ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } @@ -66,6 +66,7 @@ async function setupWithJobs() { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) @@ -214,11 +215,10 @@ async function setupSandboxed(withApproval = false) { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) - await ctx.plugin(SessionProjectionRegistry) - await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(RecordingSandboxExecutor) if (withApproval) await ctx.plugin(ApprovalService) @@ -507,6 +507,7 @@ describe('bash tool', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(LocalSubprocessRuntime) @@ -526,6 +527,7 @@ describe('the background surface follows the job registry', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalSubprocessRuntime) ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } @@ -637,6 +639,7 @@ describe('background execution through the job runtime', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) @@ -667,6 +670,7 @@ describe('background execution through the job runtime', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(CountingStartExecutor) @@ -1337,6 +1341,7 @@ describe('the model-facing bash tool builds its request from named args only (no provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index b2b6d96137..a3c434abb6 100644 --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -4,6 +4,7 @@ import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import TerminalSessionService from '@deepseek-ai/dsh-terminal' import type { @@ -321,6 +322,7 @@ async function setup( contexts.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) const stub = stubBackend(initialMode) @@ -792,6 +794,7 @@ describe('tool-pwsh-persistent', () => { contexts.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) const spawnStarted = Promise.withResolvers() diff --git a/packages/shell/tool-pwsh/tests/background.spec.ts b/packages/shell/tool-pwsh/tests/background.spec.ts index 59b99b965c..bb28376873 100644 --- a/packages/shell/tool-pwsh/tests/background.spec.ts +++ b/packages/shell/tool-pwsh/tests/background.spec.ts @@ -6,6 +6,7 @@ import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { provideWorkingDirectoryFixture, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' @@ -86,6 +87,7 @@ async function setup() { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry, { pumpPollMs: 5 }) await ctx.plugin(ToolTasks) @@ -456,6 +458,7 @@ describe('foreground commands as jobs (pwsh)', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(BashEnvPlugin) @@ -494,6 +497,7 @@ describe('foreground commands as jobs (pwsh)', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolTasks) diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index 9b2d5eb040..abe3bf1bf3 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -29,7 +29,6 @@ import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import { ShellExecutor } from '@deepseek-ai/dsh-shell' import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' @@ -152,6 +151,7 @@ async function setup(toolConfig: Partial = {}, dshHome?: string provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome }) await ctx.plugin(FakeBash) @@ -166,6 +166,7 @@ async function setupWithJobs(toolConfig: Partial = {}, dshHome? provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) @@ -227,15 +228,14 @@ async function setupSandboxed(withApproval = false) { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) await ctx.plugin(BashEnvPlugin) - await ctx.plugin(SessionProjectionRegistry) // The loop's turnBoundary unit (the open-turn fold) is not mounted in this // bench — the loop itself is not composed. Register its open-turn fold so // the approval service's turn-enclosure gate reads the seeded log shape. - await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(ConfiningFakeBash) if (withApproval) await ctx.plugin(ApprovalService) @@ -618,6 +618,7 @@ describe('per-call sandbox policy resolution', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(BashEnvPlugin) await ctx.plugin(ConfiningFakeBash) @@ -914,6 +915,7 @@ describe('background execution through the job runtime', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(BashEnvPlugin) @@ -933,6 +935,7 @@ describe('the background surface follows the job registry', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(BashEnvPlugin) await ctx.plugin(FakeBash) diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index e3362cda0a..fc4cc6bd89 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -13,6 +13,7 @@ import { import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { agentEvents, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SkillRegistry from '@deepseek-ai/dsh-skill' import * as SkillFileSystem from '@deepseek-ai/dsh-skill-filesystem' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' @@ -56,6 +57,7 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const home = await tempDir('tool-schema') await ctx.plugin(SkillRegistry) @@ -794,6 +797,7 @@ describe('dsh-tool-skill', () => { provideWorkingDirectoryFixture(ctx, home) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(SkillRegistry) await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) diff --git a/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts index c9418f1da1..ffff21a7b9 100644 --- a/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts +++ b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts @@ -9,6 +9,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import z from '@deepseek-ai/schemastery' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import { ToolCallId } from '@deepseek-ai/dsh-llm' @@ -302,6 +303,7 @@ it('rejects a payload whose Node.js or pnpm version is malformed', async () => { async function tool(config: workspaceDependencies.Config) { const ctx = new Context() try { + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) diff --git a/packages/subagent/subagent/tests/external-activation-helpers.ts b/packages/subagent/subagent/tests/external-activation-helpers.ts index 389ce804d6..8ac5f26057 100644 --- a/packages/subagent/subagent/tests/external-activation-helpers.ts +++ b/packages/subagent/subagent/tests/external-activation-helpers.ts @@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto' import { onTestFinished } from 'vitest' import type { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -18,6 +19,7 @@ function parentStore(ctx: Context): Promise> { onTestFinished(() => ctx.fiber.dispose()) const ready = (async () => { if (ctx.get('sessions') === undefined) await ctx.plugin(SessionStore) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) if (ctx.get('agents') === undefined) await ctx.plugin(AgentRegistry) return new WeakMap() })() diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 123a99b98e..9ca378d63d 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -35,12 +35,11 @@ import { SubagentPresetSelection, type PresetSelectionConfig, type PresetSelecti import type {} from './model-selection-settings.ts' import { recordSubagentModelSelection, - subagentModelSelectionPolicy, subagentModelSelectionProjectionDefinition, + subagentModelSelectionPolicy, } from './model-selection-state.ts' export const name = 'tool-subagent' - export const inject = ['tools', 'subagents', 'systemPrompt', 'sessionProjections'] // Definition identity keeps shared guidance scoped to the actual visible tools, diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 9ed1aeeaeb..f3d991dbbb 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -631,9 +631,9 @@ describe('terminal-bash plugin shape', () => { it('validates config and registers the configured backend', async () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) await ctx.plugin(StubSubprocessRuntime) const fiber = await ctx.plugin(ptyLocal, config()) @@ -646,10 +646,10 @@ describe('terminal-bash plugin shape', () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) await ctx.plugin(EmptySandbox) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) await ctx.plugin(StubSubprocessRuntime) await ctx.plugin(ptyLocal, config()) @@ -665,10 +665,10 @@ describe('terminal-bash plugin shape', () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) await ctx.plugin(RecordingSandbox) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) await ctx.plugin(StubSubprocessRuntime) @@ -716,10 +716,10 @@ describe('terminal-bash plugin shape', () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) await ctx.plugin(RecordingSandbox) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) await ctx.plugin(StubSubprocessRuntime) diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index e5a53123d0..c12f6181ed 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -61,10 +61,10 @@ async function harness( const ctx = new Context() provideWorkingDirectoryFixture(ctx) contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) await ctx.plugin(PassthroughSandbox) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: root }) await ctx.plugin(LocalSubprocessRuntime) const fiber = await ctx.plugin(ptyLocal, { diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index 11da341945..bfd3d58ce3 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import TerminalSessionService, { TerminalBackendCleanupError, TerminalError, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import type { @@ -120,6 +121,7 @@ function backend(type = 'stub') { async function harness() { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(TerminalSessionService) ptyServiceDisposers.set(ctx, async () => { await fiber.dispose() }) diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index 5521005375..7fe073137c 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -20,7 +20,6 @@ import type {} from '@deepseek-ai/dsh-session-projection' export type * from './types.ts' export const name = 'tool-todo' - export const inject = ['tools', 'sessionProjections'] /** The valid {@link TodoItem} statuses, as a runtime set for input narrowing. */ diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index 20975df8c0..1c86347250 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -33,8 +33,8 @@ async function harness(withTodoTool: boolean): Promise { await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(UserQuestionService) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) const session = ctx.sessions.create() await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index 09406f8a68..f03fb6b28d 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { credentialRef } from '@deepseek-ai/dsh-credentials' import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local' @@ -704,6 +705,7 @@ describe('web-search-deepseek account route selection', () => { const ctx = new Context() try { await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(WebRuntime, { searchProvider: DEEPSEEK_PROVIDER_ID }) ctx.provide('deepseekAccount', { diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 7375e52194..229c1e6eaf 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -449,6 +449,7 @@ describe('dsh-tool-workflow', () => { onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolTasks) @@ -659,10 +660,10 @@ describe('dsh-tool-workflow', () => { onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolTasks) - await ctx.plugin(SessionProjectionRegistry) provideWorkingDirectoryFixture(ctx) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ diff --git a/packages/workflow/workflow-ptc/tests/host.spec.ts b/packages/workflow/workflow-ptc/tests/host.spec.ts index 4a866175a5..5254a25ceb 100644 --- a/packages/workflow/workflow-ptc/tests/host.spec.ts +++ b/packages/workflow/workflow-ptc/tests/host.spec.ts @@ -34,8 +34,8 @@ async function setup(execute?: (bindings: HostBindings, spec: PtcRunSpec) => Pro const ctx = new Context() onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjections) + await ctx.plugin(AgentRegistry) await ctx.plugin(SandboxPolicy, { mode: 'read-only' }) provideWorkingDirectoryFixture(ctx) await ctx.plugin(SubagentRuntime) diff --git a/packages/workflow/workflow-ptc/tests/setup.ts b/packages/workflow/workflow-ptc/tests/setup.ts index 0a63dad5f8..3feb65d171 100644 --- a/packages/workflow/workflow-ptc/tests/setup.ts +++ b/packages/workflow/workflow-ptc/tests/setup.ts @@ -37,8 +37,8 @@ export async function mountWorkflowRuntime( options: { cwd?: string; mode?: SandboxMode; runtimeConfig?: NodeRuntimeConfig } = {}, ): Promise { if (!ctx.get('sessions')) await ctx.plugin(SessionStore) - if (!ctx.get('agents')) await ctx.plugin(AgentRegistry) if (!ctx.get('sessionProjections')) await ctx.plugin(SessionProjections) + if (!ctx.get('agents')) await ctx.plugin(AgentRegistry) if (!ctx.get('fs')) await ctx.plugin(FileSystem) if (!ctx.get('subprocess')) await ctx.plugin(Subprocess) if (!ctx.get('sandbox')) await ctx.plugin(Sandbox) diff --git a/scripts/gen-config-catalog.ts b/scripts/gen-config-catalog.ts index df0ac078d4..60a31c9fdb 100644 --- a/scripts/gen-config-catalog.ts +++ b/scripts/gen-config-catalog.ts @@ -504,7 +504,7 @@ function unwrapExpr(expr: ts.Expression): ts.Expression { * packages whose schemas an intersect composes. A key path is the top-level * key or a nested path through object/array compositions (`agents[].id`). * Handles object/union calls, chained refinements, named const schema imports - * through package-relative or public workspace source paths, and `z.intersect([X.Config, …])`; errors on + * through public workspace source paths, and `z.intersect([X.Config, …])`; errors on * anything else, so a schema the walk cannot see fails the gate instead of * silently thinning it. Nested values that are neither `object` nor `array` * compositions (primitives, unions, dynamic-key dicts) contribute no paths. diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index b0c6123981..508e2994d4 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -329,6 +329,7 @@ export const LINK_MAP: Readonly> = { AllowedModelRoute: 'subagent.md', SubagentModelSelectionSettings: 'subagent.md', AgentOptions: 'core.md', + TurnBoundaryProjection: 'core.md', AgentStatus: 'core.md', AssistantStreamFrame: 'core.md', ContentBlock: 'llm-streaming.md', @@ -710,6 +711,7 @@ export const LINK_MAP: Readonly> = { ModDefinition: 'claude-code-mods.md', SurfaceSnapshot: 'claude-code-mods.md', TokenMeasurement: 'token-meter.md', + ContextPressureProjection: 'token-meter.md', PtcDispatchLog: 'tools.md', PostToolDecision: 'tools.md', PreToolDecision: 'tools.md', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 5dfcabf115..668ef3cf94 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -258,9 +258,10 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-plan-mode', dir: 'plan-mode', source: 'packages/plan/plan-mode/src/index.ts', - requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.userQuestions (execution time, opportunistic)'], + requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.agents', 'ctx.userQuestions (execution time, opportunistic)'], writes: ['tool/call', 'plan/mode inactive on an approved review', 'tool/result'], async mount(ctx) { + await ctx.plugin(AgentRegistry) await ctx.plugin(PlanModeController, { section: 'Tool catalog schema harvest.' }) }, note: @@ -288,9 +289,10 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-present', dir: 'tool-present', source: 'packages/deliverables/tool-present/src/index.ts', - requires: ['ctx.workingDirectory', 'ctx.tools', 'ctx.fs', 'ctx.sessionProjections'], + requires: ['ctx.workingDirectory', 'ctx.tools', 'ctx.fs', 'ctx.agents'], writes: ['tool/call', 'deliverables/presented after a successful final result', 'tool/result'], async mount(ctx) { + await ctx.plugin(AgentRegistry) await ctx.plugin(ToolPresent) }, note: 'Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards.', @@ -494,10 +496,11 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-session-query', dir: 'tool-session-query', source: 'packages/experimental/tool-session-query/src/index.ts', - requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.sessionQuery', 'a calling Agent for workspace authority'], + requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.sessionQuery', 'ctx.agents', 'a calling Agent for workspace authority'], writes: ['tool/call', 'tool/result'], async mount(ctx) { await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:' }) await ctx.plugin(ToolSessionQuery) }, diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 0a5a8431a4..6f4a10e801 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2481,6 +2481,16 @@ "doc": "docs/subsystems/boot.md", "symbol": "PendingModule", "source": "packages/boot/hmr/src/index.ts" + }, + { + "doc": "docs/subsystems/core.md", + "symbol": "TurnBoundaryProjection", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/subsystems/token-meter.md", + "symbol": "ContextPressureProjection", + "source": "packages/llm/token-meter/src/projection.ts" } ] } From bad994ee010e7299efba826a603d630e1dbc480c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 15 Oct 2026 18:29:20 +0800 Subject: [PATCH 15/37] docs(upgrade): consolidate projection migration in the current release guide --- .../guide.i18n.yaml | 16 ---------------- .../claude-code-mods-projections/guide.md | 17 ----------------- .../claude-code-mods-projections/guide.zh.md | 17 ----------------- .../subagent-activations/guide.i18n.yaml | 8 ++++---- .../subagent-activations/guide.md | 6 ++---- .../subagent-activations/guide.zh.md | 6 ++---- .../session-projection-handles/guide.i18n.yaml | 8 ++++---- .../session-projection-handles/guide.md | 4 ++-- .../session-projection-handles/guide.zh.md | 4 ++-- 9 files changed, 16 insertions(+), 70 deletions(-) delete mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.i18n.yaml delete mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.md delete mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.zh.md diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.i18n.yaml deleted file mode 100644 index 6d6f45fd15..0000000000 --- a/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.i18n.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.md -/: - en: 2ca1b8bc4d168c3f - zh: 2257728847687363 -/compose-session-projections-for-claude-code-mods: - en: 58c229d213b33d7b - zh: d6d539f921e59af2 -/compose-session-projections-for-claude-code-mods/change: - en: 3a76b4c79776f847 - zh: e9c7d258a2b76611 -/compose-session-projections-for-claude-code-mods/migration: - en: 6b9ecb1a635ccb44 - zh: 978dad0bdcd69d57 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.md deleted file mode 100644 index 3c97f66652..0000000000 --- a/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -kind: upgrade-guide -description: "The Claude Code mods bridge requires the session projection registry at initialization." ---- - -# Compose session projections for Claude Code mods - -English | [中文](guide.zh.md) - -## Change - -`@deepseek-ai/dsh-experimental-claude-code-mods` requires `sessionProjections` to initialize. Custom compositions that omit this service no longer start the bridge. The bridge registers the turn and context-pressure projections before serving mods. - -## Migration - -1. Add `@deepseek-ai/dsh-session-projection` to the `cordis.yml` or profile overlay that composes the bridge, unless it is already composed. -2. Start the profile and confirm the bridge and its mod plugins load successfully. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.zh.md deleted file mode 100644 index 753d5ca266..0000000000 --- a/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.zh.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -kind: upgrade-guide -description: "Claude Code 模组桥接在初始化时要求会话投影注册表。" ---- - -# 为 Claude Code 模组组合会话投影 - -[English](guide.md) | 中文 - -## 变更 - -`@deepseek-ai/dsh-experimental-claude-code-mods` 需要 `sessionProjections` 才能初始化。未提供此服务的自定义组合不再启动桥接。桥接在服务模组前注册轮次和上下文压力投影。 - -## 迁移 - -1. 在组合桥接的 `cordis.yml` 或配置覆盖文件中添加 `@deepseek-ai/dsh-session-projection`;已组合此服务时无需重复添加。 -2. 启动配置,确认桥接及其模组插件成功加载。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.i18n.yaml index 9407c83aff..c1938c0f53 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.i18n.yaml @@ -9,8 +9,8 @@ en: fcf5f06084f989d9 zh: 5e3e6a05786390dd /migrate-delegation-to-managed-subagent-activations/change: - en: e56eb4dee1cf246a - zh: f5c5c62c815c140d + en: 17add29d9f80674e + zh: 4d4050e99f019cca /migrate-delegation-to-managed-subagent-activations/migration: - en: e8455cb8d6ecf66b - zh: 1b48e06d6aac7d9f + en: d4269b2c58484a3d + zh: 0ae39476ecd330e6 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.md index 1ef7d07119..5a88ff2ee3 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.md +++ b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.md @@ -11,9 +11,7 @@ English | [中文](guide.zh.md) `@deepseek-ai/dsh-tool-subagent` removes `backgroundMode`, `enableRunInBackground`, and the model argument `run_in_background`. Every delegation now returns `{ kind: 'activation', subagentId }` after accepting the child, and the runtime delivers its final answer in a parent completion notice. Foreground results and `{ kind: 'background', jobId }` results are removed; subagents are no longer collected or stopped through `job_output` or `job_kill`. -`@deepseek-ai/dsh-subagent-in-process-driver` is removed. Local providers contribute creation inputs through `prepareContinuable()`; the subagent service owns execution. Spawn and Fork children can receive later messages, including after settlement when persistence and Session query are available. External providers execute once and do not accept follow-up messages. - -Subagent initialization requires `@deepseek-ai/dsh-session-projection`. +The published `@deepseek-ai/dsh-subagent-in-process-driver` package is removed. Local providers contribute creation inputs through `prepareContinuable()`; the subagent service owns execution. Spawn and Fork children can receive later messages, including after settlement when persistence and Session query are available. External providers execute once and do not accept follow-up messages. ## Migration @@ -26,7 +24,7 @@ Subagent initialization requires `@deepseek-ai/dsh-session-projection`. toolName: subagent ``` - Mount `@deepseek-ai/dsh-session-projection`. Local tool delegation requires Session persistence. Compose it with the [subagent service and provider](../../../../packages/subagent/subagent/README.md#use-this-package). + Local tool delegation requires Session persistence. Compose it with the [subagent service and provider](../../../../packages/subagent/subagent/README.md#use-this-package). 2. Remove `run_in_background` from tool calls and update result consumers to read `subagentId`. Collect the completion notice instead of foreground output or a Job result. Use `interrupt_agent` to stop the current execution; use `send_message` only for local children when those control tools are composed. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.zh.md index 1f2e80d7cf..aa83610496 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.zh.md @@ -11,9 +11,7 @@ description: "受管理的子 agent activation 替代前台与 Job 后台委派 `@deepseek-ai/dsh-tool-subagent` 移除了 `backgroundMode`、`enableRunInBackground` 和模型参数 `run_in_background`。每次委派现在都会在接纳子 agent(智能体)后返回 `{ kind: 'activation', subagentId }`,运行时通过父级完成通知交付最终答案。前台结果和 `{ kind: 'background', jobId }` 结果已移除;子 agent 不再通过 `job_output` 或 `job_kill` 收集结果或停止。 -`@deepseek-ai/dsh-subagent-in-process-driver` 被移除。本地提供方通过 `prepareContinuable()` 提供创建输入,由子 agent 服务拥有执行过程。Spawn 和 Fork 子级可以接收后续消息;组合了持久化和 Session 查询服务时,结算后也能接收。外部提供方只执行一次,不接受后续消息。 - -子 agent 初始化需要 `@deepseek-ai/dsh-session-projection`。 +已发布的 `@deepseek-ai/dsh-subagent-in-process-driver` 包被移除。本地提供方通过 `prepareContinuable()` 提供创建输入,由子 agent 服务拥有执行过程。Spawn 和 Fork 子级可以接收后续消息;组合了持久化和 Session 查询服务时,结算后也能接收。外部提供方只执行一次,不接受后续消息。 ## 迁移 @@ -26,7 +24,7 @@ description: "受管理的子 agent activation 替代前台与 Job 后台委派 toolName: subagent ``` - 挂载 `@deepseek-ai/dsh-session-projection`。本地工具委派需要 Session 持久化。将其与[子 agent 服务及提供方](../../../../packages/subagent/subagent/README.zh.md#use-this-package)一起组合。 + 本地工具委派需要 Session 持久化。将其与[子 agent 服务及提供方](../../../../packages/subagent/subagent/README.zh.md#use-this-package)一起组合。 2. 从工具调用中删除 `run_in_background`,并将结果消费方改为读取 `subagentId`。收集完成通知,替代前台输出或 Job 结果。组合了相应控制工具时,使用 `interrupt_agent` 停止当前执行;`send_message` 仅用于本地子级。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.i18n.yaml index 8e72ee90bf..16a7f3f11c 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 5cf8cb0051860df5 zh: 2578c6c3e77bc1c7 /session-projection-registration-handles/change: - en: 154904370da3336a - zh: 8e508300cf097a27 + en: d488f71dcb5fcafb + zh: 0133ba0a568993ad /session-projection-registration-handles/migration: - en: 2d27fb8cc2f18a79 - zh: 6d8461cd25c3a608 + en: f268d8ba1d131b0f + zh: 29c3fe01d8b1cd0e diff --git a/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.md b/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.md index 4014d86b93..25f76616c2 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.md +++ b/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.md @@ -10,11 +10,11 @@ English | [中文](guide.zh.md) `@deepseek-ai/dsh-session-projection` changes `SessionProjectionRegistry.register(definition)` from a synchronous disposer to `Promise>`. Registry-level `stateOf(session, key)` is removed. Registration errors now reject the returned promise. After the last registration is disposed, the unit leaves snapshots, checkpoints, and live notifications, but retained handles can still fold Session events on demand. -`@deepseek-ai/dsh-agent` and `@deepseek-ai/dsh-subagent` require `sessionProjections`. AgentRegistry owns turn and step state; consumers use `ctx.agents.turnBoundaryOf(session)` instead of importing `turnBoundaryProjectionDefinition`. Preset and context-pressure reads use `ctx.agentPresets.presetForSession(session)` and `ctx.tokenMeter.contextPressure(session)`. The API controller and mods bridge no longer register those definitions independently; their optional-service behavior applies when the owning service is absent. `@deepseek-ai/dsh-api-session-controller` also removes `installModelSelectionProjection`. +`@deepseek-ai/dsh-agent` and `@deepseek-ai/dsh-subagent` require `sessionProjections`. AgentRegistry owns turn and step state; consumers use `ctx.agents.turnBoundaryOf(session)` instead of importing `turnBoundaryProjectionDefinition`. Turn-state consumers, including the Claude Code mods bridge, require `agents`. Preset and context-pressure reads use `ctx.agentPresets.presetForSession(session)` and `ctx.tokenMeter.contextPressure(session)`. The API controller and mods bridge no longer register those definitions independently; their optional-service behavior applies when the owning service is absent. `@deepseek-ai/dsh-api-session-controller` also removes `installModelSelectionProjection`. ## Migration -1. Mount `SessionProjectionRegistry` before `AgentRegistry`, `SubagentRuntime`, and other projection owners in custom compositions. Consumers of domain data inject the owning service. +1. Mount `SessionProjectionRegistry` before `AgentRegistry`, `SubagentRuntime`, and other projection owners in custom compositions. Include `@deepseek-ai/dsh-agent` wherever a plugin requires `agents`. Consumers of domain data inject the owning service. 2. For a plugin's own definitions, await `ctx.sessionProjections.register(definition)` in asynchronous `apply()` or `[Service.init]()` and retain the handle privately before publishing dependent operations. Keep existing function-plugin namespace exports. 3. Replace `registry.stateOf(session, key)` with a business service method for another domain, or `handle.stateOf(session)` inside the owner. Replace stored unregister callbacks with `handle.dispose`, which always returns `Promise`, and await explicit disposal; fiber disposal still releases registrations automatically. Await registration when handling initialization failures. 4. Replace `installModelSelectionProjection(ctx)` with `await ctx.sessionProjections.register(modelSelectionProjection)`, importing the definition from `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts`. diff --git a/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.zh.md index b48ff66064..7c917af0e4 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.zh.md @@ -10,11 +10,11 @@ description: "Session 投影注册改为异步,并返回保留读取能力的 `@deepseek-ai/dsh-session-projection` 的 `SessionProjectionRegistry.register(definition)` 从同步释放函数改为返回 `Promise>`。注册表级 `stateOf(session, key)` 已移除,注册错误现在使返回的 Promise 拒绝。最后一次注册释放后,该投影单元不再参与快照、检查点和实时通知,但保留的句柄仍可按需折叠 Session 事件。 -`@deepseek-ai/dsh-agent` 与 `@deepseek-ai/dsh-subagent` 依赖 `sessionProjections`。AgentRegistry 持有轮次和步骤状态;消费方使用 `ctx.agents.turnBoundaryOf(session)`,不再导入 `turnBoundaryProjectionDefinition`。预设与上下文压力分别通过 `ctx.agentPresets.presetForSession(session)` 和 `ctx.tokenMeter.contextPressure(session)` 读取。API 控制器与 mods 桥接不再独立注册这些定义;所属服务缺席时采用各自的可选服务行为。`@deepseek-ai/dsh-api-session-controller` 同时移除了 `installModelSelectionProjection`。 +`@deepseek-ai/dsh-agent` 与 `@deepseek-ai/dsh-subagent` 依赖 `sessionProjections`。AgentRegistry 持有轮次和步骤状态;消费方使用 `ctx.agents.turnBoundaryOf(session)`,不再导入 `turnBoundaryProjectionDefinition`。包括 Claude Code mods 桥接在内的轮次状态消费方依赖 `agents`。预设与上下文压力分别通过 `ctx.agentPresets.presetForSession(session)` 和 `ctx.tokenMeter.contextPressure(session)` 读取。API 控制器与 mods 桥接不再独立注册这些定义;所属服务缺席时采用各自的可选服务行为。`@deepseek-ai/dsh-api-session-controller` 同时移除了 `installModelSelectionProjection`。 ## 迁移 -1. 在自定义组合中,先挂载 `SessionProjectionRegistry`,再挂载 `AgentRegistry`、`SubagentRuntime` 及其他投影所有者。领域数据的消费方注入所属服务。 +1. 在自定义组合中,先挂载 `SessionProjectionRegistry`,再挂载 `AgentRegistry`、`SubagentRuntime` 及其他投影所有者。组合中的插件依赖 `agents` 时,加入 `@deepseek-ai/dsh-agent`。领域数据的消费方注入所属服务。 2. 对插件自己的定义,在异步 `apply()` 或 `[Service.init]()` 中等待 `ctx.sessionProjections.register(definition)`,并在提供依赖操作前于内部保存句柄。保留现有函数插件命名空间导出。 3. 将 `registry.stateOf(session, key)` 替换为其他领域的业务服务方法,或所有者内部的 `handle.stateOf(session)`。将保存的取消注册回调替换为始终返回 `Promise` 的 `handle.dispose`,并等待显式释放完成;fiber 释放仍会自动释放注册。处理初始化失败时须等待注册。 4. 将 `installModelSelectionProjection(ctx)` 替换为 `await ctx.sessionProjections.register(modelSelectionProjection)`,从 `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts` 导入该定义。 From aedf8e9ee8cbcf6036e30c8f5775856ebb658453 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 15 Oct 2026 19:02:35 +0800 Subject: [PATCH 16/37] refactor(tmux-context): name the projection registration handle --- packages/context/tmux-context/src/index.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts index fea575008d..8a5f9d7ae3 100644 --- a/packages/context/tmux-context/src/index.ts +++ b/packages/context/tmux-context/src/index.ts @@ -210,12 +210,6 @@ const tmuxContextStateSchema = zod.object({ type TmuxContextState = zod.infer -/** - * Register a prepended pre-step listener for the lifetime of `ctx`. - * @param ctx - plugin context; the listener is disposed with it. - * @param config - durable refresh scheduling configuration. - * @throws when the refresh interval is invalid. - */ declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { /** The stable state block of this plugin's latest durable injection, or null. */ @@ -223,15 +217,18 @@ declare module '@deepseek-ai/dsh-session-projection/types' { } } -/** Register the tmux-context plugin after its projections are ready. - * @param ctx - plugin context. - * @returns when the plugin is ready. +/** + * Register a prepended pre-step listener for the lifetime of `ctx`. + * @param ctx - plugin context; the listener is disposed with it. + * @param config - durable refresh scheduling configuration. + * @returns when the listener is registered. + * @throws when the refresh interval is invalid. */ export async function apply(ctx: Context, config: Config): Promise { const refreshIntervalMs = config.refreshIntervalMs validateRefreshInterval(refreshIntervalMs) - const projection = await ctx.sessionProjections.register({ + const tmuxContextProjectionHandle = await ctx.sessionProjections.register({ key: 'tmuxContext', stateVersion: 1, stateSchema: tmuxContextStateSchema, @@ -255,7 +252,7 @@ export async function apply(ctx: Context, config: Config): Promise { if (decision.kind === 'reject' || signal.aborted || step !== 1) return decision const bash = ctx.get('shell') if (bash === undefined) return decision - const previous = projection.stateOf(agent.session) + const previous = tmuxContextProjectionHandle.stateOf(agent.session) if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== null) { const now = Date.now() if (now >= previous.time && now - previous.time < refreshIntervalMs) return decision From d62915e72a85e60a9471445373782e051e709346 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 15 Oct 2026 19:02:49 +0800 Subject: [PATCH 17/37] chore(terminal-bash): remove unused projection peer dependency --- packages/terminal/terminal-bash/package.json | 2 +- packages/terminal/terminal-bash/src/index.ts | 1 - pnpm-lock.yaml | 6 +++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json index bec256dbd5..7fa52c7a48 100644 --- a/packages/terminal/terminal-bash/package.json +++ b/packages/terminal/terminal-bash/package.json @@ -36,7 +36,6 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*" }, "dependencies": { @@ -52,6 +51,7 @@ "@deepseek-ai/dsh-sandbox": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts index 4b05eeb668..5359948eb6 100644 --- a/packages/terminal/terminal-bash/src/index.ts +++ b/packages/terminal/terminal-bash/src/index.ts @@ -13,7 +13,6 @@ import type { TerminalBackend, TerminalBackendSpawnSpec, TerminalSendOperation } import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import type {} from '@deepseek-ai/dsh-sandbox-policy' -import type {} from '@deepseek-ai/dsh-session-projection' import { ENCODING_PREAMBLE } from '@deepseek-ai/dsh-pwsh-local' import { type Config, type ResolvedConfig, resolveConfig, type ShellDialect, validateConfig } from './config.ts' import { LocalPtySession } from './session.ts' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ac35f64c1..e3b75b9858 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14453,9 +14453,6 @@ importers: '@deepseek-ai/dsh-pwsh-local': specifier: workspace:* version: link:../../shell/pwsh-local - '@deepseek-ai/dsh-session-projection': - specifier: workspace:* - version: link:../../session/session-projection '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -14481,6 +14478,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:* + version: link:../../session/session-projection '@deepseek-ai/dsh-subprocess': specifier: workspace:* version: link:../../subprocess/subprocess From 0570efc15731edd2fb3d6d0270ee542e6979b203 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 15 Oct 2026 19:34:21 +0800 Subject: [PATCH 18/37] test(session-title): mount agent registry in provider e2e --- .../session-title-first-prompt-llm/tests/provider.e2e.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts b/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts index b76e25a67a..ae57203690 100644 --- a/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts +++ b/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts @@ -1,3 +1,4 @@ +import AgentRegistry from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -22,6 +23,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('first-prompt title provider with await ctx.plugin(LlmDeepSeek, { thinking: 'disabled' }) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, From d7a1aa839a2540f5001543650df4de3d393e70e6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 15 Oct 2026 19:34:27 +0800 Subject: [PATCH 19/37] docs: refresh module graph after projection dependency cleanup --- docs/module-graph.md | 24 ++++++++---------------- docs/module-graph.zh.md | 24 ++++++++---------------- 2 files changed, 16 insertions(+), 32 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 00bc4da738..ba46785157 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -797,7 +797,6 @@ flowchart TD pkg_terminal_bash --> pkg_sandbox pkg_terminal_bash --> pkg_sandbox_policy pkg_terminal_bash --> pkg_session - pkg_terminal_bash --> pkg_session_projection pkg_terminal_bash --> pkg_subprocess pkg_terminal_bash --> pkg_terminal pkg_terminal_bash --> pkg_working_directory @@ -814,7 +813,6 @@ flowchart TD pkg_tool_goal --> pkg_goal pkg_tool_goal --> pkg_llm pkg_tool_goal --> pkg_session - pkg_tool_goal --> pkg_session_projection pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools pkg_tool_fs --> pkg_attachment @@ -874,7 +872,6 @@ flowchart TD pkg_agent_instructions --> pkg_home_paths pkg_agent_instructions --> pkg_llm pkg_agent_instructions --> pkg_session - pkg_agent_instructions --> pkg_session_projection pkg_agent_instructions --> pkg_tools pkg_agent_instructions --> pkg_working_directory pkg_file_reference_local --> pkg_agent @@ -886,7 +883,6 @@ flowchart TD pkg_tool_present --> pkg_fs pkg_tool_present --> pkg_llm pkg_tool_present --> pkg_session - pkg_tool_present --> pkg_session_projection pkg_tool_present --> pkg_tools pkg_tool_present --> pkg_working_directory pkg_workspace_changes --> pkg_agent @@ -931,7 +927,6 @@ flowchart TD pkg_hooks_codex --> pkg_hook_protocol pkg_hooks_codex --> pkg_llm pkg_hooks_codex --> pkg_session - pkg_hooks_codex --> pkg_session_projection pkg_hooks_codex --> pkg_tools pkg_hooks_codex --> pkg_working_directory pkg_session_title_all_prompts_llm --> pkg_llm @@ -1207,7 +1202,6 @@ flowchart TD pkg_experimental_claude_code_mods --> pkg_llm pkg_experimental_claude_code_mods --> pkg_scope pkg_experimental_claude_code_mods --> pkg_session - pkg_experimental_claude_code_mods --> pkg_session_projection pkg_experimental_claude_code_mods --> pkg_storage_domain pkg_experimental_claude_code_mods --> pkg_subprocess pkg_experimental_claude_code_mods --> pkg_token_meter @@ -1218,7 +1212,6 @@ flowchart TD pkg_tool_session_query --> pkg_agent pkg_tool_session_query --> pkg_llm pkg_tool_session_query --> pkg_session - pkg_tool_session_query --> pkg_session_projection pkg_tool_session_query --> pkg_session_query pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout @@ -1336,7 +1329,6 @@ flowchart TD pkg_hooks_claude_code --> pkg_hook_protocol pkg_hooks_claude_code --> pkg_llm pkg_hooks_claude_code --> pkg_session - pkg_hooks_claude_code --> pkg_session_projection pkg_hooks_claude_code --> pkg_subagent pkg_hooks_claude_code --> pkg_tools pkg_hooks_claude_code --> pkg_working_directory @@ -1663,10 +1655,10 @@ flowchart TD | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | -| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal), [`working-directory`](../packages/session/working-directory) | +| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal), [`working-directory`](../packages/session/working-directory) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`tools`](../packages/core/tools) | -| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`output-retention`](../packages/util/output-retention), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | @@ -1677,9 +1669,9 @@ flowchart TD | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction) | | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | +| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | -| [`tool-present`](../packages/deliverables/tool-present) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | +| [`tool-present`](../packages/deliverables/tool-present) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`cua`](../packages/experimental/cua) | `experimental` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`computer-use`](../packages/computer-use/computer-use), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | @@ -1688,7 +1680,7 @@ flowchart TD | [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | `experimental` | [`computer-use`](../packages/computer-use/computer-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-cua-macos`](../packages/experimental/cua-macos) | `experimental` | [`agent`](../packages/core/agent), [`computer-use`](../packages/computer-use/computer-use), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | `experimental` | [`experimental-worktree`](../packages/experimental/worktree), [`tools`](../packages/core/tools) | -| [`hooks-codex`](../packages/experimental/hooks-codex) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | +| [`hooks-codex`](../packages/experimental/hooks-codex) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`session-title-all-prompts-llm`](../packages/experimental/session-title-all-prompts-llm) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`tool-terminal`](../packages/experimental/tool-terminal) | `experimental` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | @@ -1734,8 +1726,8 @@ flowchart TD | [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`mcp-client`](../packages/mcp/mcp-client), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | -| [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | `experimental` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subprocess`](../packages/subprocess/subprocess), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | -| [`tool-session-query`](../packages/experimental/tool-session-query) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | `experimental` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`subprocess`](../packages/subprocess/subprocess), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | +| [`tool-session-query`](../packages/experimental/tool-session-query) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`webhook-github`](../packages/experimental/webhook-github) | `experimental` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/experimental/webhook) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1751,7 +1743,7 @@ flowchart TD | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent) | -| [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | +| [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-ralph`](../packages/experimental/tool-ralph) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow), [`working-directory`](../packages/session/working-directory) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index c452bebf03..4ba3fcd5f2 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -799,7 +799,6 @@ flowchart TD pkg_terminal_bash --> pkg_sandbox pkg_terminal_bash --> pkg_sandbox_policy pkg_terminal_bash --> pkg_session - pkg_terminal_bash --> pkg_session_projection pkg_terminal_bash --> pkg_subprocess pkg_terminal_bash --> pkg_terminal pkg_terminal_bash --> pkg_working_directory @@ -816,7 +815,6 @@ flowchart TD pkg_tool_goal --> pkg_goal pkg_tool_goal --> pkg_llm pkg_tool_goal --> pkg_session - pkg_tool_goal --> pkg_session_projection pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools pkg_tool_fs --> pkg_attachment @@ -876,7 +874,6 @@ flowchart TD pkg_agent_instructions --> pkg_home_paths pkg_agent_instructions --> pkg_llm pkg_agent_instructions --> pkg_session - pkg_agent_instructions --> pkg_session_projection pkg_agent_instructions --> pkg_tools pkg_agent_instructions --> pkg_working_directory pkg_file_reference_local --> pkg_agent @@ -888,7 +885,6 @@ flowchart TD pkg_tool_present --> pkg_fs pkg_tool_present --> pkg_llm pkg_tool_present --> pkg_session - pkg_tool_present --> pkg_session_projection pkg_tool_present --> pkg_tools pkg_tool_present --> pkg_working_directory pkg_workspace_changes --> pkg_agent @@ -933,7 +929,6 @@ flowchart TD pkg_hooks_codex --> pkg_hook_protocol pkg_hooks_codex --> pkg_llm pkg_hooks_codex --> pkg_session - pkg_hooks_codex --> pkg_session_projection pkg_hooks_codex --> pkg_tools pkg_hooks_codex --> pkg_working_directory pkg_session_title_all_prompts_llm --> pkg_llm @@ -1209,7 +1204,6 @@ flowchart TD pkg_experimental_claude_code_mods --> pkg_llm pkg_experimental_claude_code_mods --> pkg_scope pkg_experimental_claude_code_mods --> pkg_session - pkg_experimental_claude_code_mods --> pkg_session_projection pkg_experimental_claude_code_mods --> pkg_storage_domain pkg_experimental_claude_code_mods --> pkg_subprocess pkg_experimental_claude_code_mods --> pkg_token_meter @@ -1220,7 +1214,6 @@ flowchart TD pkg_tool_session_query --> pkg_agent pkg_tool_session_query --> pkg_llm pkg_tool_session_query --> pkg_session - pkg_tool_session_query --> pkg_session_projection pkg_tool_session_query --> pkg_session_query pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout @@ -1338,7 +1331,6 @@ flowchart TD pkg_hooks_claude_code --> pkg_hook_protocol pkg_hooks_claude_code --> pkg_llm pkg_hooks_claude_code --> pkg_session - pkg_hooks_claude_code --> pkg_session_projection pkg_hooks_claude_code --> pkg_subagent pkg_hooks_claude_code --> pkg_tools pkg_hooks_claude_code --> pkg_working_directory @@ -1665,10 +1657,10 @@ flowchart TD | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | -| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal), [`working-directory`](../packages/session/working-directory) | +| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal), [`working-directory`](../packages/session/working-directory) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`tools`](../packages/core/tools) | -| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`output-retention`](../packages/util/output-retention), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | @@ -1679,9 +1671,9 @@ flowchart TD | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction) | | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | +| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | -| [`tool-present`](../packages/deliverables/tool-present) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | +| [`tool-present`](../packages/deliverables/tool-present) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`cua`](../packages/experimental/cua) | `experimental` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`computer-use`](../packages/computer-use/computer-use), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | @@ -1690,7 +1682,7 @@ flowchart TD | [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | `experimental` | [`computer-use`](../packages/computer-use/computer-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-cua-macos`](../packages/experimental/cua-macos) | `experimental` | [`agent`](../packages/core/agent), [`computer-use`](../packages/computer-use/computer-use), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | `experimental` | [`experimental-worktree`](../packages/experimental/worktree), [`tools`](../packages/core/tools) | -| [`hooks-codex`](../packages/experimental/hooks-codex) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | +| [`hooks-codex`](../packages/experimental/hooks-codex) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`session-title-all-prompts-llm`](../packages/experimental/session-title-all-prompts-llm) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`tool-terminal`](../packages/experimental/tool-terminal) | `experimental` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | @@ -1736,8 +1728,8 @@ flowchart TD | [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`mcp-client`](../packages/mcp/mcp-client), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | -| [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | `experimental` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subprocess`](../packages/subprocess/subprocess), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | -| [`tool-session-query`](../packages/experimental/tool-session-query) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | `experimental` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`subprocess`](../packages/subprocess/subprocess), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | +| [`tool-session-query`](../packages/experimental/tool-session-query) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`webhook-github`](../packages/experimental/webhook-github) | `experimental` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/experimental/webhook) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1753,7 +1745,7 @@ flowchart TD | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent) | -| [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | +| [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-ralph`](../packages/experimental/tool-ralph) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow), [`working-directory`](../packages/session/working-directory) | From 491fa4a3948f0ee1f82dab7ac94898e3cde66660 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 15 Oct 2026 20:12:09 +0800 Subject: [PATCH 20/37] refactor(session-projection): clarify retained handle names --- packages/api/session-controller/src/agent.ts | 6 +++--- packages/context/harness-dev-context/src/index.ts | 4 ++-- packages/context/time-context/src/index.ts | 4 ++-- packages/core/agent-loop/src/index.ts | 8 ++++---- packages/core/agent/src/index.ts | 6 +++--- packages/experimental/agent-team/src/index.ts | 4 ++-- packages/experimental/agent-team/src/journal.ts | 6 +++--- packages/goal/goal/src/index.ts | 8 ++++---- packages/interaction/permission-presets/src/index.ts | 6 +++--- packages/interaction/user-questions/src/index.ts | 8 ++++---- packages/llm/llm-retry/src/index.ts | 4 ++-- packages/plan/plan-mode/src/index.ts | 6 +++--- packages/preset/agent-preset-registry/src/index.ts | 8 ++++---- packages/sandbox/sandbox-policy/src/index.ts | 6 +++--- packages/session/session-projection/README.md | 4 ++-- packages/session/session-projection/README.zh.md | 4 ++-- packages/session/session-title/src/index.ts | 6 +++--- packages/session/working-directory/src/index.ts | 8 ++++---- packages/subagent/tool-subagent/src/index.ts | 8 ++++---- .../tool-subagent/src/model-selection-state.ts | 12 ++++++------ 20 files changed, 63 insertions(+), 63 deletions(-) diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index 6c967c5c10..4dee6de84a 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -170,7 +170,7 @@ export async function inspectApiSession( /** Owns every operation that may create, resume, or configure a Web Agent. */ export class ApiSessionAgentController { - private modelSelection!: ProjectionHandle + private modelSelectionProjectionHandle!: ProjectionHandle private readonly resumes = new Map>() private readonly claims = new Map>() private readonly selections = new WeakMap() @@ -185,7 +185,7 @@ export class ApiSessionAgentController { */ async initialize(): Promise { const ctx = this.ctx - this.modelSelection = await ctx.sessionProjections.register(modelSelectionProjection) + this.modelSelectionProjectionHandle = await ctx.sessionProjections.register(modelSelectionProjection) ctx.typert.lookups.configure('agent', async (sessionId: SessionId) => { const found = await this.resolveAgent(sessionId) if ('error' in found) throw found.error @@ -300,7 +300,7 @@ export class ApiSessionAgentController { selectionFor(agent: Agent): InstalledSelection { const installed = this.selections.get(agent) if (installed !== undefined) return installed - const projectionState = this.modelSelection.stateOf(agent.session) + const projectionState = this.modelSelectionProjectionHandle.stateOf(agent.session) let picked = projectionState.pending === null ? undefined : agentModelSelection(projectionState.pending) diff --git a/packages/context/harness-dev-context/src/index.ts b/packages/context/harness-dev-context/src/index.ts index d823735a2a..588bb84b9b 100644 --- a/packages/context/harness-dev-context/src/index.ts +++ b/packages/context/harness-dev-context/src/index.ts @@ -127,7 +127,7 @@ const ADMITS_NOTHING: LegacyPrompt = { source: null, web: null } * @returns after the projection reader and prompt registrations are ready. */ export async function apply(ctx: Context): Promise { - const legacyPrompt = await ctx.sessionProjections.register({ + const legacyPromptProjectionHandle = await ctx.sessionProjections.register({ key: LEGACY_PROMPT, stateVersion: 1, stateSchema: legacyPromptSchema, @@ -136,7 +136,7 @@ export async function apply(ctx: Context): Promise { }) const admitted = (context: AssembleContext): LegacyPrompt => { const session = context.agent?.session - return session === undefined ? ADMITS_NOTHING : legacyPrompt.stateOf(session) + return session === undefined ? ADMITS_NOTHING : legacyPromptProjectionHandle.stateOf(session) } ctx.systemPrompt.section({ name: HARNESS_SOURCE, diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index e38ff615fa..25cc4b484d 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -155,7 +155,7 @@ export async function apply(ctx: Context, config: Config): Promise { return created } - const projection = await ctx.sessionProjections.register({ + const timeContextProjectionHandle = await ctx.sessionProjections.register({ key: 'timeContext', stateVersion: 2, stateSchema: timeContextStateSchema, @@ -190,7 +190,7 @@ export async function apply(ctx: Context, config: Config): Promise { const decision = await next() if (decision.kind === 'reject' || signal.aborted) return decision const now = Date.now() - const state = projection.stateOf(agent.session) + const state = timeContextProjectionHandle.stateOf(agent.session) if (refreshIntervalMs > 0) { const lastInjection = state.lastInjectionTime if (lastInjection != null diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 54476ac913..dc68fef4e0 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -308,7 +308,7 @@ export class AgentLoop extends Service implements AgentFactory { private readonly ownership: FactoryOwnership /** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */ private readonly runtime: { ctx: Context } - private inbox!: ProjectionHandle + private inboxProjectionHandle!: ProjectionHandle constructor(ctx: Context, config: Config) { super(ctx, 'agentLoop') @@ -327,7 +327,7 @@ export class AgentLoop extends Service implements AgentFactory { const ownership = this.ownership // The inbox remains readable until agent teardown finishes. ctx.effect(() => () => ownership.dispose(), 'agentLoop.transactions()') - this.inbox = await ctx.sessionProjections.register(inboxProjectionDefinition) + this.inboxProjectionHandle = await ctx.sessionProjections.register(inboxProjectionDefinition) ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()') ctx.systemPrompt.variable('provider', context => context.agent?.options.provider) ctx.systemPrompt.variable('model', context => context.agent?.options.model) @@ -543,10 +543,10 @@ export class AgentLoop extends Service implements AgentFactory { } } const untrack = this.ownership.track(dispose) - const inbox = this.inbox + const inboxProjectionHandle = this.inboxProjectionHandle try { unfollowOwner = ownerCtx.effect(function* () { - const created = machine = new ReactLoopAgent(loopCtx, id, options, session, () => inbox.stateOf(session), lastTurn) + const created = machine = new ReactLoopAgent(loopCtx, id, options, session, () => inboxProjectionHandle.stateOf(session), lastTurn) machineReady.resolve() // Adopt the scope on its original fiber so it cannot unload before teardown. // A loop already unloading during minting owns the provisional scope itself. diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 270aadbef9..d1515665e4 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -247,7 +247,7 @@ interface FactorySlot { export class AgentRegistry extends Service { static inject = ['sessionProjections'] - private turnBoundaryProjection!: ProjectionHandle + private turnBoundaryProjectionHandle!: ProjectionHandle private store = new Map() private factory: FactorySlot | undefined private readonly initiators = new AsyncLocalStorage() @@ -290,7 +290,7 @@ export class AgentRegistry extends Service { } protected async [Service.init](): Promise { - this.turnBoundaryProjection = await this.ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + this.turnBoundaryProjectionHandle = await this.ctx.sessionProjections.register(turnBoundaryProjectionDefinition) } /** @@ -299,7 +299,7 @@ export class AgentRegistry extends Service { * @returns the current turn and step positions; do not mutate the returned value. */ turnBoundaryOf(session: Session): TurnBoundaryProjection { - return this.turnBoundaryProjection.stateOf(session) + return this.turnBoundaryProjectionHandle.stateOf(session) } /** diff --git a/packages/experimental/agent-team/src/index.ts b/packages/experimental/agent-team/src/index.ts index a37e4e22b3..c0da4c9a24 100644 --- a/packages/experimental/agent-team/src/index.ts +++ b/packages/experimental/agent-team/src/index.ts @@ -90,8 +90,8 @@ export class TeamService extends Service { protected async [Service.init](): Promise { const ctx = this.ctx - const projection = await ctx.sessionProjections.register(teamProjectionDefinition) - this.journal = new TeamJournal(ctx, projection, (root) => { this.activity.notify(TeamId(root.id)) }) + const teamProjectionHandle = await ctx.sessionProjections.register(teamProjectionDefinition) + this.journal = new TeamJournal(ctx, teamProjectionHandle, (root) => { this.activity.notify(TeamId(root.id)) }) this.roster = new TeamRoster(ctx, this.journal, this.lifecycle, this.config.maxMembers) this.tasks = new TeamTaskBoard(this.journal, this.config.maxTasks) ctx.effect(() => () => this.disposeRuntime(), 'agentTeams.runtimeLifecycle()') diff --git a/packages/experimental/agent-team/src/journal.ts b/packages/experimental/agent-team/src/journal.ts index 6a6002bb72..d57fcca30b 100644 --- a/packages/experimental/agent-team/src/journal.ts +++ b/packages/experimental/agent-team/src/journal.ts @@ -15,12 +15,12 @@ export class TeamJournal { /** * @param ctx - Team service context with the injected Session service. - * @param projection - Team state registration owned by the Team service. + * @param teamProjectionHandle - Team state registration owned by the Team service. * @param onCommit - synchronous notification after the Team event flush succeeds. */ constructor( private readonly ctx: Context, - private readonly projection: ProjectionHandle, + private readonly teamProjectionHandle: ProjectionHandle, private readonly onCommit: (root: Agent) => void, ) {} @@ -31,7 +31,7 @@ export class TeamJournal { * @throws when the durable Team projection has failed. */ state(root: Agent): TeamState { - const projection = this.projection.stateOf(root.session) + const projection = this.teamProjectionHandle.stateOf(root.session) if (projection.failure !== undefined) throw new Error(projection.failure) return projection } diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index cbc0344b95..275b757eba 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -247,7 +247,7 @@ export class GoalService extends TypertRemoteService { private readonly resolved: ResolvedConfig private readonly runtimeStates = new WeakMap() - private projection!: ProjectionHandle + private goalProjectionHandle!: ProjectionHandle constructor(ctx: Context, config: Config = {}) { super(ctx, 'goals') @@ -258,7 +258,7 @@ export class GoalService extends TypertRemoteService { protected async [Service.init](): Promise { const ctx = this.ctx - this.projection = await ctx.sessionProjections.register(goalProjectionDefinition) + this.goalProjectionHandle = await ctx.sessionProjections.register(goalProjectionDefinition) ctx.on('agent/created', ({ agent }) => { this.setActivation(agent.session, 'disarmed') }) @@ -480,7 +480,7 @@ export class GoalService extends TypertRemoteService { /** Read the current durable projection maintained by the registry. */ private state(session: Session): GoalProjection | null { - const state = this.projection.stateOf(session) + const state = this.goalProjectionHandle.stateOf(session) if (state.failure !== null) throw new Error(state.failure) return state.current } @@ -502,7 +502,7 @@ export class GoalService extends TypertRemoteService { const runtime = this.runtimeState(session) if (runtime.activation === activation) return runtime.activation = activation - const state = this.projection.stateOf(session) + const state = this.goalProjectionHandle.stateOf(session) if (state.failure !== null) return const goal = this.view(state.current, runtime) this.ctx.emit('goal/activation-changed', { diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts index e9cb9a38c3..580443d7c3 100644 --- a/packages/interaction/permission-presets/src/index.ts +++ b/packages/interaction/permission-presets/src/index.ts @@ -204,7 +204,7 @@ export class PermissionPresetService extends TypertRemoteService { private autoAdmit: (() => void) | undefined private defaultSettings: () => PermissionSettings - private projection!: ProjectionHandle + private permissionProjectionHandle!: ProjectionHandle constructor(ctx: Context, config: Config) { super(ctx, 'permissionPresets') @@ -239,7 +239,7 @@ export class PermissionPresetService extends TypertRemoteService { const selectionSchema = zod.object({ currentValue: zod.string().min(1), }) as zod.ZodType - this.projection = await ctx.sessionProjections.register({ + this.permissionProjectionHandle = await ctx.sessionProjections.register({ key: 'permissions', stateVersion: 2, stateSchema: permissionStateSchema, @@ -331,7 +331,7 @@ export class PermissionPresetService extends TypertRemoteService { } private permissionState(session: Session): PermissionProjectionState { - return this.projection.stateOf(session) + return this.permissionProjectionHandle.stateOf(session) } /** diff --git a/packages/interaction/user-questions/src/index.ts b/packages/interaction/user-questions/src/index.ts index 1a93231646..846a8a65be 100644 --- a/packages/interaction/user-questions/src/index.ts +++ b/packages/interaction/user-questions/src/index.ts @@ -78,7 +78,7 @@ type QueuedReply = { messageId: MessageId; claimedTurn?: number } /** `ctx.userQuestions`: validation plus the scoped answerer waterfall. */ export class UserQuestionService extends TypertRemoteService { static Config = z.object({}) - private projection: ProjectionHandle | undefined + private userQuestionProjectionHandle: ProjectionHandle | undefined private readonly waits = new Map>() private readonly queuedReplies = new WeakMap>() @@ -128,8 +128,8 @@ export class UserQuestionService extends TypertRemoteService { protected async [Service.init](): Promise { const binding = this.ctx.inject(['sessionProjections'], async (ctx) => { - this.projection = await ctx.sessionProjections.register(userQuestionProjectionDefinition) - return () => { this.projection = undefined } + this.userQuestionProjectionHandle = await ctx.sessionProjections.register(userQuestionProjectionDefinition) + return () => { this.userQuestionProjectionHandle = undefined } }) if (this.ctx.get('sessionProjections') !== undefined) await binding } @@ -150,7 +150,7 @@ export class UserQuestionService extends TypertRemoteService { } private continued(agent: Agent): readonly PendingUserQuestion[] { - return this.projection?.stateOf(agent.session).questions.active.filter(question => question.state === 'continued') ?? [] + return this.userQuestionProjectionHandle?.stateOf(agent.session).questions.active.filter(question => question.state === 'continued') ?? [] } /** diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 016d19f400..6b7fb6e682 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -123,7 +123,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { */ export async function apply(ctx: Context, config: Config = {}, internals: RetryInternals = {}): Promise { validateConfig(config) - const projection = await ctx.sessionProjections.register({ + const llmRetryProjectionHandle = await ctx.sessionProjections.register({ key: 'llmRetry', stateVersion: 1, stateSchema: llmRetryStateSchema, @@ -218,7 +218,7 @@ export async function apply(ctx: Context, config: Config = {}, internals: RetryI } const policyKey = retryPolicyKey(policy) - const retryState = projection.stateOf(agent.session) + const retryState = llmRetryProjectionHandle.stateOf(agent.session) const previous = retryState[retryStateKey(provider, policyKey)] const previousRetry = previous?.retry ?? 0 if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next() diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 603810713a..e4034fa549 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -177,7 +177,7 @@ export class PlanModeController extends Service { /** Validated deployment-owned guidance. */ private readonly section: string - private projection!: ProjectionHandle + private planProjectionHandle!: ProjectionHandle /** * Latest selection per session awaiting the next accepted in-turn pre-step. @@ -193,7 +193,7 @@ export class PlanModeController extends Service { protected async [Service.init](): Promise { const ctx = this.ctx - this.projection = await ctx.sessionProjections.register(planProjectionDefinition) + this.planProjectionHandle = await ctx.sessionProjections.register(planProjectionDefinition) let disposed = false // Pre-step is outside Session.append publication, so it can append the // log-only mode event inside an open turn without re-entering the session. @@ -389,7 +389,7 @@ export class PlanModeController extends Service { } private planState(session: Session): PlanUnitState { - return this.projection.stateOf(session) + return this.planProjectionHandle.stateOf(session) } /** diff --git a/packages/preset/agent-preset-registry/src/index.ts b/packages/preset/agent-preset-registry/src/index.ts index 4b4bb467d0..5205a58228 100644 --- a/packages/preset/agent-preset-registry/src/index.ts +++ b/packages/preset/agent-preset-registry/src/index.ts @@ -57,7 +57,7 @@ export class AgentPresetRegistry extends TypertRemoteService { default: z.string().required(), selectedDefault: z.string().volatile(), }) - private agentPreset!: ProjectionHandle + private agentPresetProjectionHandle!: ProjectionHandle private readonly owner: Context private readonly definitions = new Map() private readonly generations = new Map() @@ -71,7 +71,7 @@ export class AgentPresetRegistry extends TypertRemoteService { protected async [Service.init](): Promise { const ctx = this.owner - this.agentPreset = await ctx.sessionProjections.register(agentPresetProjectionDefinition) + this.agentPresetProjectionHandle = await ctx.sessionProjections.register(agentPresetProjectionDefinition) ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) }) ctx.on('session/event', (session, event) => { if (event.type === 'agent-preset/selected') ctx.emit('agent-preset/selected', session.id, event.data.agentPreset) @@ -87,7 +87,7 @@ export class AgentPresetRegistry extends TypertRemoteService { * @returns the recorded preset, or null when none was selected. */ presetForSession(session: Session): string | null { - return this.agentPreset.stateOf(session) + return this.agentPresetProjectionHandle.stateOf(session) } /** Register and eagerly load a definition; activation failure remains visible in the roster. @@ -370,7 +370,7 @@ export class AgentPresetRegistry extends TypertRemoteService { } const current = this.definitions.get(agentPreset)?.generation if (current !== undefined && this.generationFor(agent.ctx) === current - && this.agentPreset.stateOf(agent.session) === agentPreset) return agentPreset + && this.agentPresetProjectionHandle.stateOf(agent.session) === agentPreset) return agentPreset const preset = await this.recompose(agent.ctx, agentPreset) agent.session.append('agent-preset/selected', { agentPreset: preset.id }) return preset.id diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index c966e6a4ea..942838ca36 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -122,7 +122,7 @@ export class SandboxPolicyService extends Service { readonly defaultMode: SandboxMode /** The absolute `workspace-write` fallback root for calls without a session cwd. */ readonly workspaceRoot: string - private projection!: ProjectionHandle + private sandboxModeProjectionHandle!: ProjectionHandle constructor(ctx: Context, config: Config) { super(ctx, 'sandboxPolicy') @@ -135,7 +135,7 @@ export class SandboxPolicyService extends Service { protected async [Service.init](): Promise { const ctx = this.ctx - this.projection = await ctx.sessionProjections.register({ + this.sandboxModeProjectionHandle = await ctx.sessionProjections.register({ key: 'sandboxMode', stateVersion: 1, stateSchema: sandboxModeStateSchema, @@ -181,7 +181,7 @@ export class SandboxPolicyService extends Service { * @returns the last logged mode, or `undefined` without one. */ overrideOf(session: Session): SandboxMode | undefined { - return this.projection.stateOf(session) ?? undefined + return this.sandboxModeProjectionHandle.stateOf(session) ?? undefined } } diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 4ddaed7c6b..930de0955d 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -58,8 +58,8 @@ const definition = { `await register(definition)` installs the unit and returns a handle with synchronous `stateOf(session)` reads and `dispose(): Promise`. Await `dispose()` to release the registration; repeated calls are safe. Registrants with the same key and `stateVersion` share cells; incompatible or invalid versions reject registration. Registration belongs to the calling fiber. The last unregister removes the unit from global event drives, snapshots, checkpoints, and change notifications. Existing handles remain readable: each retains its original unit and folds new Session events on demand, even if another version later registers the same key. Reading a handle does not initialize unrelated units or register it again. Carriers use `snapshot(session)` for a consistent client-view cut and `onChanged(listener)` for changes to registered units. ```text -const projection = await ctx.sessionProjections.register(definition) -const state = projection.stateOf(session) +const todoProjectionHandle = await ctx.sessionProjections.register(definition) +const state = todoProjectionHandle.stateOf(session) const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ``` diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index de05300b76..e0adbadde0 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -58,8 +58,8 @@ const definition = { `await register(definition)` 安装单元,返回提供同步 `stateOf(session)` 读取和 `dispose(): Promise` 的 handle。等待 `dispose()` 完成即可释放注册;重复调用是安全的。具有相同 key 和 `stateVersion` 的注册方共享 cell;版本不兼容或非法时注册会拒绝。注册归调用方 fiber 所有。最后一个注册方注销后,单元退出全局事件驱动、快照、检查点和变更通知。已有 handle 仍可读取:它保留原有单元,按需折叠新增的 Session 事件,即使同一 key 后来注册了其他版本也不受影响。读取 handle 不会初始化无关单元,也不会重新注册。载体用 `snapshot(session)` 读取一致的客户端视图切面,用 `onChanged(listener)` 订阅已注册单元的变更。 ```text -const projection = await ctx.sessionProjections.register(definition) -const state = projection.stateOf(session) +const todoProjectionHandle = await ctx.sessionProjections.register(definition) +const state = todoProjectionHandle.stateOf(session) const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ``` diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 8b888b80d4..b2d43669d1 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -306,7 +306,7 @@ export class SessionTitleService extends Service { maxTitleBytes: z.number().step(1).min(1).required(), }) - private titleInput!: ProjectionHandle + private titleInputProjectionHandle!: ProjectionHandle private readonly config: ResolvedConfig private readonly ownerFiber: Fiber private registration: ProviderRegistration | undefined @@ -347,7 +347,7 @@ export class SessionTitleService extends Service { await ctx.sessionProjections.register(titleProjectionDefinition) - this.titleInput = await ctx.sessionProjections.register<'titleInput', TitleInputState>({ + this.titleInputProjectionHandle = await ctx.sessionProjections.register<'titleInput', TitleInputState>({ key: 'titleInput', stateVersion: 3, stateSchema: titleInputStateSchema, @@ -734,7 +734,7 @@ export class SessionTitleService extends Service { } private titleInputOf(session: Session): TitleInputState { - return this.titleInput.stateOf(session) + return this.titleInputProjectionHandle.stateOf(session) } /** Queue detached service work and retain it through service disposal. */ diff --git a/packages/session/working-directory/src/index.ts b/packages/session/working-directory/src/index.ts index 60c8ad9c2a..61ca035d70 100644 --- a/packages/session/working-directory/src/index.ts +++ b/packages/session/working-directory/src/index.ts @@ -72,7 +72,7 @@ export class WorkingDirectoryService extends Service { private readonly operations = new Set>() private readonly lifecycle = new AbortController() - private projection!: ProjectionHandle + private workingDirectoryProjectionHandle!: ProjectionHandle constructor(ctx: Context, config: Config) { super(ctx, 'workingDirectory') @@ -84,7 +84,7 @@ export class WorkingDirectoryService extends Service { protected async [Service.init](): Promise { const ctx = this.ctx const directorySchema = zod.string().min(1).refine(isAbsolute).nullable() - this.projection = await ctx.sessionProjections.register({ + this.workingDirectoryProjectionHandle = await ctx.sessionProjections.register({ key: 'workingDirectory', stateVersion: 1, stateSchema: directorySchema, @@ -125,7 +125,7 @@ export class WorkingDirectoryService extends Service { * @returns its effective absolute directory. */ get(session: Session): string { - return this.projection.stateOf(session) ?? this.defaultDirectory + return this.workingDirectoryProjectionHandle.stateOf(session) ?? this.defaultDirectory } /** @@ -144,7 +144,7 @@ export class WorkingDirectoryService extends Service { control.throwIfAborted() agent.ctx.fiber.assertActive() if (info?.type === 'directory') { - if (this.projection.stateOf(agent.session) === null) { + if (this.workingDirectoryProjectionHandle.stateOf(agent.session) === null) { agent.session.append('working-directory/change', { cwd: current }) } return current diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 9ca378d63d..bd0b19da01 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -160,7 +160,7 @@ export async function apply(ctx: Context, config: Config, session?: Session): Pr const toolName = config.toolName ?? 'subagent' const modelSelectionCapable = config.modelSelectionSettings === true - const modelSelectionProjection = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + const subagentModelSelectionProjectionHandle = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) const assertSubagentProviderConfiguration = (subagentProvider: SubagentProvider): void => { presets.assertProvider(subagentProvider) @@ -416,7 +416,7 @@ export async function apply(ctx: Context, config: Config, session?: Session): Pr const freshSession = target.firstLiveSeq === 0 // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. && target.eventAt(SessionSeq(0))?.type !== 'session/end-seed' - let allowedModels = subagentModelSelectionPolicy(modelSelectionProjection, target) + let allowedModels = subagentModelSelectionPolicy(subagentModelSelectionProjectionHandle, target) if (allowedModels === undefined) { const parentId = target.header.origin === 'subagent' ? target.header.parentSession @@ -429,14 +429,14 @@ export async function apply(ctx: Context, config: Config, session?: Session): Pr const parent = sessions.get(parentId) allowedModels = parent === undefined ? undefined - : subagentModelSelectionPolicy(modelSelectionProjection, parent) + : subagentModelSelectionPolicy(subagentModelSelectionProjectionHandle, parent) } else if (freshSession) { const current = settings.current() allowedModels = current.enabled ? current.allowedModels : undefined } } if (allowedModels !== undefined) { - recordSubagentModelSelection(modelSelectionProjection, target, allowedModels) + recordSubagentModelSelection(subagentModelSelectionProjectionHandle, target, allowedModels) } return allowedModels === undefined ? undefined : { routes: allowedModels } } diff --git a/packages/subagent/tool-subagent/src/model-selection-state.ts b/packages/subagent/tool-subagent/src/model-selection-state.ts index 9b51f384f7..92cdac56bb 100644 --- a/packages/subagent/tool-subagent/src/model-selection-state.ts +++ b/packages/subagent/tool-subagent/src/model-selection-state.ts @@ -52,29 +52,29 @@ export const subagentModelSelectionProjectionDefinition = { /** * Read the exact route list captured for a model-selectable definition. - * @param projection - registered policy state reader. + * @param subagentModelSelectionProjectionHandle - registered policy state reader. * @param session - session whose durable decision is read. * @returns a detached route list, or undefined for the fixed-route definition. */ export function subagentModelSelectionPolicy( - projection: Pick, 'stateOf'>, + subagentModelSelectionProjectionHandle: Pick, 'stateOf'>, session: Session, ): AllowedModelRoute[] | undefined { - return projection.stateOf(session)?.map(route => ({ ...route })) + return subagentModelSelectionProjectionHandle.stateOf(session)?.map(route => ({ ...route })) } /** * Append the route policy once, before its definition can reach a model request. - * @param projection - registered policy state reader. + * @param subagentModelSelectionProjectionHandle - registered policy state reader. * @param session - session receiving the model-selectable definition. * @param allowedModels - exact routes the definition may select explicitly. */ export function recordSubagentModelSelection( - projection: Pick, 'stateOf'>, + subagentModelSelectionProjectionHandle: Pick, 'stateOf'>, session: Session, allowedModels: readonly AllowedModelRoute[], ): void { - if (subagentModelSelectionPolicy(projection, session) !== undefined) return + if (subagentModelSelectionPolicy(subagentModelSelectionProjectionHandle, session) !== undefined) return session.append('subagent/model-selection-policy', { allowedModels: allowedModels.map(route => ({ ...route })), }) From 5ee64b7005922171821ae2b5d1ee459fa7bb3f70 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 15 Oct 2026 20:12:24 +0800 Subject: [PATCH 21/37] refactor(agent): use a type-only SessionSeq import --- packages/core/agent/package.json | 1 + packages/core/agent/src/projection.ts | 11 +++++++---- packages/core/agent/tsconfig.json | 3 +++ pnpm-lock.yaml | 3 +++ 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index afb858d995..694bcf96f5 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -62,6 +62,7 @@ "@deepseek-ai/cordis": "workspace:~" }, "dependencies": { + "@deepseek-ai/dsh-brand": "workspace:*", "zod": "4.4.3" } } diff --git a/packages/core/agent/src/projection.ts b/packages/core/agent/src/projection.ts index 64333a692f..c4a56449f3 100644 --- a/packages/core/agent/src/projection.ts +++ b/packages/core/agent/src/projection.ts @@ -1,7 +1,8 @@ /** Shared pure fold of the Session's turn and step events. */ import type { TurnBoundaryProjection } from './types.ts' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' -import { SessionSeq } from '@deepseek-ai/dsh-session' +import type { SessionSeq } from '@deepseek-ai/dsh-session/types' +import { brandNumber } from '@deepseek-ai/dsh-brand' import { z as zod } from 'zod' declare module '@deepseek-ai/dsh-session-projection/types' { @@ -11,12 +12,14 @@ declare module '@deepseek-ai/dsh-session-projection/types' { } } +const sessionSeqSchema = zod.number().int().nonnegative().transform(brandNumber) + const turnBoundaryProjectionSchema: zod.ZodType = zod.object({ - openTurnStartSeq: zod.number().int().nonnegative().transform(SessionSeq).nullable(), - lastStepStartSeq: zod.number().int().nonnegative().transform(SessionSeq).nullable(), + openTurnStartSeq: sessionSeqSchema.nullable(), + lastStepStartSeq: sessionSeqSchema.nullable(), lastStepBoundary: zod.object({ kind: zod.union([zod.literal('start'), zod.literal('end')]), - seq: zod.number().int().nonnegative().transform(SessionSeq), + seq: sessionSeqSchema, }).nullable(), lastTurn: zod.number().int().nonnegative(), }) diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json index b597fc7068..a84b932abe 100644 --- a/packages/core/agent/tsconfig.json +++ b/packages/core/agent/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../util/brand" + }, { "path": "../../core/scope" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3b75b9858..9e2ccb8ae2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7055,6 +7055,9 @@ importers: packages/core/agent: dependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:* + version: link:../../util/brand zod: specifier: 4.4.3 version: 4.4.3 From e152f17bb2304039ac9058bf7ec4ce3e32270967 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 15 Oct 2026 20:12:36 +0800 Subject: [PATCH 22/37] refactor(token-meter): read pressure facts from retained state --- docs/subsystems/token-meter.i18n.yaml | 4 ++-- docs/subsystems/token-meter.md | 6 +++--- docs/subsystems/token-meter.zh.md | 6 +++--- packages/extensions/tool-cordis/src/api-catalog.ts | 4 ++-- packages/llm/token-meter/README.i18n.yaml | 4 ++-- packages/llm/token-meter/README.md | 2 +- packages/llm/token-meter/README.zh.md | 2 +- packages/llm/token-meter/src/index.ts | 14 +++++++++----- .../tests/token-usage-projection.spec.ts | 3 ++- 9 files changed, 25 insertions(+), 20 deletions(-) diff --git a/docs/subsystems/token-meter.i18n.yaml b/docs/subsystems/token-meter.i18n.yaml index eff44c4e64..f205395403 100644 --- a/docs/subsystems/token-meter.i18n.yaml +++ b/docs/subsystems/token-meter.i18n.yaml @@ -12,5 +12,5 @@ en: e445d12b10f651d0 zh: e93be5fb9da1da15 /token-meter/contextpressureprojection: - en: 78c8308e9f54fc38 - zh: f76658e7a2739807 + en: 37e477a82a9c3a83 + zh: ad7143becb29afe9 diff --git a/docs/subsystems/token-meter.md b/docs/subsystems/token-meter.md index 9476165e45..1aa56a8d24 100644 --- a/docs/subsystems/token-meter.md +++ b/docs/subsystems/token-meter.md @@ -55,7 +55,7 @@ Surface order is authoritative; replacement nodes can have higher durable seqs t ## `ContextPressureProjection` -`ctx.tokenMeter.contextPressure(session)` synchronously reads the latest recorded context pressure and capacity. These display values do not price a new request; use `measure()` for request pricing. +`ctx.tokenMeter.contextPressure(session)` synchronously reads the latest logged `pressureTokens` and `contextWindow`; the client projection also derives `projectedTokens` from surface changes. These display values do not price a new request; use `measure()` for request pricing. Source: [`packages/llm/token-meter/src/projection.ts`](../../packages/llm/token-meter/src/projection.ts) @@ -107,11 +107,11 @@ Replay owner for one service-wide estimator and isolated per-session folds. ```ts cordis-catalog /** - * Read the latest logged context occupancy and its projected surface adjustment. + * Read the latest logged provider prompt size and route capacity. * @param session - Session whose committed usage and request capacity are read. * @returns context pressure; fields remain absent until their source events are recorded. */ -contextPressure(session: Session): ContextPressureProjection +contextPressure(session: Session): Pick /** * Measure current request pressure and surface through the durable tail. diff --git a/docs/subsystems/token-meter.zh.md b/docs/subsystems/token-meter.zh.md index 07893eacce..970f90d9dd 100644 --- a/docs/subsystems/token-meter.zh.md +++ b/docs/subsystems/token-meter.zh.md @@ -55,7 +55,7 @@ interface TokenSurfaceNode { ## `ContextPressureProjection` -`ctx.tokenMeter.contextPressure(session)` 同步读取最新记录的上下文压力与容量。这些显示值不为新请求计价;请求计价使用 `measure()`。 +`ctx.tokenMeter.contextPressure(session)` 同步读取最新记录的 `pressureTokens` 和 `contextWindow`;客户端投影还根据表层变化派生 `projectedTokens`。这些显示值不为新请求计价;请求计价使用 `measure()`。 来源: [`packages/llm/token-meter/src/projection.ts`](../../packages/llm/token-meter/src/projection.ts) @@ -107,11 +107,11 @@ Replay owner for one service-wide estimator and isolated per-session folds. ```ts cordis-catalog /** - * Read the latest logged context occupancy and its projected surface adjustment. + * Read the latest logged provider prompt size and route capacity. * @param session - Session whose committed usage and request capacity are read. * @returns context pressure; fields remain absent until their source events are recorded. */ -contextPressure(session: Session): ContextPressureProjection +contextPressure(session: Session): Pick /** * Measure current request pressure and surface through the durable tail. diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 20c5ec129d..1c1190b1e0 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3373,8 +3373,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Replay owner for one service-wide estimator and isolated per-session folds.', methods: [ { - signature: 'contextPressure(session: Session): ContextPressureProjection', - description: 'Read the latest logged context occupancy and its projected surface adjustment.', + signature: 'contextPressure(session: Session): Pick', + description: 'Read the latest logged provider prompt size and route capacity.', parameters: [{ name: 'session', description: 'Session whose committed usage and request capacity are read.' }], returns: 'context pressure; fields remain absent until their source events are recorded.', }, diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml index ba794c93da..fe4b0d57b1 100644 --- a/packages/llm/token-meter/README.i18n.yaml +++ b/packages/llm/token-meter/README.i18n.yaml @@ -24,8 +24,8 @@ en: 58de3b364ab5af6b zh: c5440af43d321e56 /deepseek-ai-dsh-token-meter/use-this-package/session-projections: - en: afa4b026b55ec1ba - zh: 9805b2924b6a786b + en: acd064f47010ced9 + zh: ef5a456695050242 /deepseek-ai-dsh-token-meter/use-this-package/composition: en: 1a0b7f49e01cba08 zh: 666213a1024767c5 diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index 214970a2f1..8730acfd18 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -46,7 +46,7 @@ The measurement anchor includes the priced surface immediately before the succes ### Session projections -Token-meter registers three projection units through its required `ctx.sessionProjections` service. Host consumers call `ctx.tokenMeter.contextPressure(session)` to read the current pressure fields synchronously; the registration handle stays private. `tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. A final assistant-message sample replaces streaming usage from the same attempt; `llm/retry-started` ends that replacement scope, so a retry in the same step contributes another billed attempt. `contextPressure` carries optional `pressureTokens` (the newest provider-reported prompt size), optional `projectedTokens` (what the next request's prompt would cost), and optional `contextWindow` from the newest `request/context` record. `contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition, not its provider-billed size. Unloading the plugin removes all three keys. +Token-meter registers three projection units through its required `ctx.sessionProjections` service. Host consumers call `ctx.tokenMeter.contextPressure(session)` to read the latest provider-reported prompt size and route capacity synchronously; the registration handle stays private. `tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. A final assistant-message sample replaces streaming usage from the same attempt; `llm/retry-started` ends that replacement scope, so a retry in the same step contributes another billed attempt. `contextPressure` carries optional `pressureTokens` (the newest provider-reported prompt size), optional `projectedTokens` (what the next request's prompt would cost), and optional `contextWindow` from the newest `request/context` record. `contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition, not its provider-billed size. Unloading the plugin removes all three keys. Image offload reprices existing node identities while preserving prior usage anchors. The fixed reference heuristic excludes `offloaded` metadata, so an offload decision does not change `contextBreakdown` or the scalar heuristic total; route-aware measurement replaces the selected visual prices with placeholder text. diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md index 8f8d4f8a7f..e2a86c6528 100644 --- a/packages/llm/token-meter/README.zh.md +++ b/packages/llm/token-meter/README.zh.md @@ -46,7 +46,7 @@ const price = ctx.tokenMeter.estimateMessage(message) ### 会话投影 -token-meter 通过必需的 `ctx.sessionProjections` 服务注册三个投影单元。Host 消费方调用 `ctx.tokenMeter.contextPressure(session)` 同步读取当前压力字段;注册句柄保留在内部。`tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 与 `cacheWriteTokens`。最终 assistant 消息样本会替换同一次尝试的流式用量;`llm/retry-started` 会结束该替换范围,因此同一步骤中的重试会贡献另一次计费用量。`contextPressure` 携带可选 `pressureTokens`(提供方报告的最新提示词规模)、可选 `projectedTokens`(下一个请求的提示词将花费多少)与来自最新一条 `request/context` 记录的可选 `contextWindow`。`contextBreakdown` 携带启发式 `systemTokens`、`toolsTokens` 与 `messageTokens`——上下文的构成,而非提供方计费规模。卸载插件会移除全部三个键。 +token-meter 通过必需的 `ctx.sessionProjections` 服务注册三个投影单元。Host 消费方调用 `ctx.tokenMeter.contextPressure(session)` 同步读取提供方报告的最新提示词规模和路由容量;注册句柄保留在内部。`tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 与 `cacheWriteTokens`。最终 assistant 消息样本会替换同一次尝试的流式用量;`llm/retry-started` 会结束该替换范围,因此同一步骤中的重试会贡献另一次计费用量。`contextPressure` 携带可选 `pressureTokens`(提供方报告的最新提示词规模)、可选 `projectedTokens`(下一个请求的提示词将花费多少)与来自最新一条 `request/context` 记录的可选 `contextWindow`。`contextBreakdown` 携带启发式 `systemTokens`、`toolsTokens` 与 `messageTokens`——上下文的构成,而非提供方计费规模。卸载插件会移除全部三个键。 图片省略重新计算现有节点的价格,同时保留此前的用量锚点。固定引用启发式规则不计入 `offloaded` 元数据,因此一次省略决定不改变 `contextBreakdown` 或标量启发式总量,按路由的测量则把所选图片的视觉价格换成占位文本价格。 diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 367a0cab76..27768fa394 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -107,7 +107,7 @@ export class TokenMeter extends Service { static inject = ['sessionProjections'] private readonly states = new WeakMap() - private contextPressureProjection!: ProjectionHandle + private contextPressureProjectionHandle!: ProjectionHandle constructor(ctx: Context, config: TokenMeterConfig = {}) { super(ctx, 'tokenMeter') @@ -117,7 +117,7 @@ export class TokenMeter extends Service { protected async [Service.init](): Promise { const ctx = this.ctx await ctx.sessionProjections.register(tokenUsageProjectionDefinition) - this.contextPressureProjection = await ctx.sessionProjections.register(contextPressureProjectionDefinition) + this.contextPressureProjectionHandle = await ctx.sessionProjections.register(contextPressureProjectionDefinition) await ctx.sessionProjections.register(contextBreakdownProjectionDefinition) // Readers catch up independently, while eager observation bounds ordinary @@ -128,12 +128,16 @@ export class TokenMeter extends Service { } /** - * Read the latest logged context occupancy and its projected surface adjustment. + * Read the latest logged provider prompt size and route capacity. * @param session - Session whose committed usage and request capacity are read. * @returns context pressure; fields remain absent until their source events are recorded. */ - contextPressure(session: Session): ContextPressureProjection { - return contextPressureProjectionDefinition.wire.view(this.contextPressureProjection.stateOf(session)) + contextPressure(session: Session): Pick { + const { pressureTokens, contextWindow } = this.contextPressureProjectionHandle.stateOf(session) + return { + ...pressureTokens === undefined ? {} : { pressureTokens }, + ...contextWindow === undefined ? {} : { contextWindow }, + } } /** diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index fee8d43ba5..d14e3dd387 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -382,7 +382,8 @@ describe('contextPressure session projection', () => { }, 1, 1) // Output is deliberately absent: occupancy describes the prompt that was // sent, so it holds still while the response streams. - expect(ctx.tokenMeter.contextPressure(session)).toEqual({ pressureTokens: 125, projectedTokens: 125 }) + expect(ctx.tokenMeter.contextPressure(session)).toEqual({ pressureTokens: 125 }) + expect(pressure(ctx, session)).toEqual({ pressureTokens: 125, projectedTokens: 125 }) }) it('replaces pressure with the newest request rather than accumulating', async () => { From ff731c72dee9e7a23a1b9bb67cee6ce520ce5405 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 15 Oct 2026 21:01:00 +0800 Subject: [PATCH 23/37] refactor(session-projection): remove the host state map --- ...ssion-projection-and-command-log.i18n.yaml | 8 +-- ...7-27-session-projection-and-command-log.md | 11 ++- ...7-session-projection-and-command-log.zh.md | 11 ++- docs/config-catalog.md | 8 +-- docs/config-catalog.zh.md | 8 +-- .../conversation-and-session.md | 2 +- .../conversation-and-session.zh.md | 2 +- docs/event-producer-consumer.md | 14 ++-- docs/event-producer-consumer.zh.md | 14 ++-- docs/persistence-catalog.i18n.yaml | 32 ++++----- docs/persistence-catalog.md | 16 ++--- docs/persistence-catalog.zh.md | 16 ++--- docs/persistence-schema.json | 10 +-- docs/subsystems/session-projection.i18n.yaml | 4 +- docs/subsystems/session-projection.md | 13 ++-- docs/subsystems/session-projection.zh.md | 13 ++-- .../guide.i18n.yaml | 8 +-- .../session-projection-handles/guide.md | 4 +- .../session-projection-handles/guide.zh.md | 4 +- packages/api/session-controller/src/types.ts | 8 --- .../tests/session-projections.host.spec.ts | 5 -- .../context/harness-dev-context/src/index.ts | 7 -- packages/context/time-context/src/index.ts | 10 --- packages/context/tmux-context/src/index.ts | 9 --- packages/core/agent/src/projection.ts | 7 -- packages/core/agent/src/types.ts | 4 -- .../experimental/agent-team/src/projection.ts | 6 -- .../extensions/tool-cordis/src/api-catalog.ts | 10 +-- packages/goal/goal/README.i18n.yaml | 4 +- packages/goal/goal/README.md | 2 +- packages/goal/goal/README.zh.md | 2 +- packages/goal/goal/src/index.ts | 4 -- packages/goal/goal/src/types.ts | 3 - .../permission-presets/src/index.ts | 7 -- .../user-questions/src/projection.ts | 6 -- packages/llm/llm-retry/src/index.ts | 6 -- .../token-meter/src/breakdown-projection.ts | 8 +-- packages/llm/token-meter/src/index.ts | 9 +-- .../llm/token-meter/src/usage-projection.ts | 10 +-- packages/plan/plan-mode/src/types.ts | 4 -- .../preset/agent-preset-registry/src/types.ts | 3 - packages/sandbox/sandbox-policy/src/index.ts | 8 --- .../tests/cache.spec.ts | 9 --- .../tests/fixtures.spec.ts | 3 - .../session-projection/README.i18n.yaml | 8 +-- packages/session/session-projection/README.md | 4 +- .../session/session-projection/README.zh.md | 4 +- .../session/session-projection/src/index.ts | 29 +++----- .../session/session-projection/src/types.ts | 7 -- .../tests/registration-reader.spec.ts | 4 -- .../tests/registration.type-test.ts | 68 +++++++++++++++++++ .../session-projection/tests/registry.spec.ts | 7 -- .../session/session-stats/src/projection.ts | 6 -- packages/session/session-title/src/types.ts | 6 -- .../session/session-turn-outline/src/types.ts | 4 -- .../session/working-directory/src/index.ts | 5 -- packages/subagent/subagent/src/catalog.ts | 6 -- packages/subagent/subagent/src/projection.ts | 7 -- .../src/model-selection-state.ts | 7 -- packages/todo/tool-todo/src/types.ts | 3 - scripts/gen-cordis-catalog.ts | 1 - 61 files changed, 193 insertions(+), 335 deletions(-) create mode 100644 packages/session/session-projection/tests/registration.type-test.ts diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml index 59a262d59e..e0639785f1 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml @@ -15,8 +15,8 @@ en: e6c93d89a25f0d03 zh: 65e1f5842bc74008 /agent-note-session-projections-and-command-lifecycle-logging/proposal/host-projection-registry-dsh-session-projection-new-package: - en: a0f86a588c9dff31 - zh: 421803bc8b0dddb5 + en: bb52ae77e0302d2d + zh: 75e6a76ce7d02342 /agent-note-session-projections-and-command-lifecycle-logging/proposal/shipped-consumer-the-subagent-identity-unit: en: 10093535660c3cae zh: a7f4ff9e71ee6751 @@ -39,8 +39,8 @@ en: 64ad8d08a8ab44e0 zh: fb0a0f7369d0bd23 /agent-note-session-projections-and-command-lifecycle-logging/alternatives-considered: - en: e945e84e8360e0c4 - zh: becdeb100a4c5b6b + en: 4776bd01e7ec14f0 + zh: 9b1c2db96e1120f6 /agent-note-session-projections-and-command-lifecycle-logging/acceptance-criteria: en: 9dfcca30fffda225 zh: 0beddb81d32e0ca7 diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md index 4e2e02482e..f907f77959 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md @@ -24,15 +24,14 @@ A state-carrying log event MUST carry the complete post-change state, never a ba ### Host projection registry (`dsh-session-projection`, new package) -A light Service Definition package: merge-extensible host-state and client-view type maps, the registry service, and zod validation for persisted state and client values. Capability-seam roles: domain host plugins provide projection units, carriers consume them, and neither knows the other. +A light Service Definition package: local host-state definitions, a merge-extensible client-view type map, the registry service, and zod validation for persisted state and client values. Capability-seam roles: domain host plugins provide projection units, carriers consume them, and neither knows the other. What a domain registers is a **state-driven computation unit** — a pure fold plus declarations and an optional client view — never an opaque getter. The framework owns driving it (subscription, watermark, caching, and later checkpointing); the domain owns only the computation. Projections serve every business domain (session title, plan, goal, permission, todos); commands are merely one trigger path and hold no special position in this contract. ```ts ignore-check -export interface SessionProjectionStateMap {} // host fold states -export interface SessionProjectionMap {} // client-visible whole values +export interface SessionProjectionMap {} // client-visible whole values -export interface ProjectionDefinition { +export interface ProjectionDefinition { key: K stateSchema: ZodType persist?: boolean // host-only units opt in; client-visible units always persist @@ -54,7 +53,7 @@ declare module 'cordis' { } ``` -- `SessionProjectionStateMap` types host fold states; `SessionProjectionMap` remains the one client DTO table shared by the wire block and React hook via `import type`. A unit may remain host-only by omitting `wire`. How a client value is *rendered* is the slot system's business, never the projection layer's. The state/view split is specified by the [implemented state and client-view note](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md). +- `ProjectionDefinition` carries each unit's host state type; `SessionProjectionMap` remains the one client DTO table shared by the wire block and React hook via `import type`. A unit may remain host-only by omitting `wire`. How a client value is *rendered* is the slot system's business, never the projection layer's. The state/view split is specified by the [implemented state and client-view note](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md). - **The host is the only place a projection is computed.** The framework drives every registered unit forward eagerly: each committed session event passes through `apply`; a unit uninterested in an event returns the same state reference, and an unchanged reference (`Object.is`) produces no downstream work. Clients never fold domain events — they receive finished values (baseline block + push frame below). This removes the double-implementation trap (plan's two-event fold written once, on the host) and any client-side domain code. - **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, ver, seq, val)` (`ver` = the unit's `stateVersion`, `seq` = the watermark, `val` = the state JSON). A row is never wrong, only possibly stale — its `seq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value. - A domain's input event set is its own choice: todos folds `todo/write` alone; plan folds `plan/mode` plus its own `/plan` `command/run` records (see the plan section); goal folds `goal/change` metadata; session title folds its title events (retiring the bespoke `session/title` frame and the client's title-snapshot map — the fourth hand-rolled projection this seam absorbs). @@ -161,7 +160,7 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a **Hanging the registry off `ctx.apiProxy`** — rejected: session projections are not web-specific (TUI, ACP, headless are future consumers), and domain packages must not depend on the apiproxy package. The independent seam also deletes #587's type-only import edge from api-proxy into the plan package. -**A second client DTO table** — rejected: `SessionProjectionMap` remains the single client vocabulary shared by wire and UI. `SessionProjectionStateMap` is not another client view table; it types host fold state so internal state may differ from the value sent to clients. +**A second client DTO table** — rejected: `SessionProjectionMap` remains the single client vocabulary shared by wire and UI. Each unit types its host fold state locally, independently of the value sent to clients. **Event-broadcast collection instead of a registry walk** — rejected: async listeners cannot yield the single synchronous cut that makes `asOfSeq` one consistent snapshot across all keys; registries are this repo's shape for contributions (`ctx.tools`, prompt sections, slots). diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md index f226acfc83..e3f23ec975 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md @@ -24,15 +24,14 @@ Status: proposed ### host 侧投影注册表(`dsh-session-projection`,新包) -一个轻量的 Service Definition 包:merge-extensible 的 host 状态与客户端视图类型表、注册表服务,以及针对持久状态和客户端值的 zod 校验。能力 seam 的角色如下:领域 host 插件提供投影单元,载体消费这些单元,两侧互不相识。 +一个轻量的 Service Definition 包:局部 host 状态定义、merge-extensible 的客户端视图类型表、注册表服务,以及针对持久状态和客户端值的 zod 校验。能力 seam 的角色如下:领域 host 插件提供投影单元,载体消费这些单元,两侧互不相识。 领域注册的是一个**状态驱动计算单元(state-driven computation unit)**——纯折叠、若干声明及可选客户端视图——绝不是一个不透明的 getter。驱动它是框架的职责(订阅、水位线(watermark)、缓存,以及后续的检查点机制),领域只负责计算。投影服务于所有业务领域(会话标题、plan、goal、权限、todos);命令只是其中一条触发路径,在本约定中没有任何特殊地位。 ```ts ignore-check -export interface SessionProjectionStateMap {} // host fold states -export interface SessionProjectionMap {} // client-visible whole values +export interface SessionProjectionMap {} // client-visible whole values -export interface ProjectionDefinition { +export interface ProjectionDefinition { key: K stateSchema: ZodType persist?: boolean // host-only units opt in; client-visible units always persist @@ -54,7 +53,7 @@ declare module 'cordis' { } ``` -- `SessionProjectionStateMap` 描述 host 折叠状态;`SessionProjectionMap` 继续作为协议块和 React 钩子经 `import type` 共享的唯一客户端 DTO 表。单元省略 `wire` 即保持 host-only。客户端值如何*渲染*是 slot 体系的事,永远不归投影层管。状态/视图拆分见[已实现的状态与客户端视图记录](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md)。 +- `ProjectionDefinition` 携带各单元的宿主状态类型;`SessionProjectionMap` 继续作为协议块和 React 钩子经 `import type` 共享的唯一客户端 DTO 表。单元省略 `wire` 即保持 host-only。客户端值如何*渲染*是 slot 体系的事,永远不归投影层管。状态/视图拆分见[已实现的状态与客户端视图记录](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md)。 - **host 是投影唯一的计算地点。** 框架主动驱动(eager drive)每个已注册的单元:每个已提交的会话事件都经过 `apply`;对某事件不感兴趣的单元返回同一个状态引用,而引用未变(`Object.is`)就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值(基线块 + 下文的推送帧)。这消除了双重实现陷阱(plan 的双事件折叠只在 host 写一遍),也消除了一切客户端侧领域代码。 - **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, ver, seq, val)` 的行(`ver` = 单元的 `stateVersion`,`seq` = 水位线,`val` = 状态 JSON)。一行永远不会是错的,至多是陈旧的——其 `seq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。 - 领域的输入事件集由领域自己选择:todos 只折叠 `todo/write`;plan 折叠 `plan/mode` 外加它自己的 `/plan` `command/run` 记录(见 plan 一节);goal 折叠 `goal/change` 元数据;会话标题折叠其标题事件(顺带下线专设的 `session/title` 帧与客户端的标题快照表——这是该 seam 收编的第四个手工投影)。 @@ -161,7 +160,7 @@ host 侧命令执行器(`packages/interaction/commands`)在调用处理器 **把注册表挂到 `ctx.apiProxy` 名下**——不予采纳:会话投影并非 web 专属(TUI、ACP(Agent Client Protocol)、headless 都是未来消费方),且领域包不得依赖 apiproxy 包。独立 seam 还顺带删掉了 #587 从 api-proxy 指向 plan 包的 type-only 导入边。 -**第二张客户端 DTO 类型表**——不予采纳:`SessionProjectionMap` 仍是协议与 UI 共享的唯一客户端词汇。`SessionProjectionStateMap` 不是另一张客户端视图表;它描述 host 折叠状态,使内部状态可以不同于发往客户端的值。 +**第二张客户端 DTO 类型表**——不予采纳:`SessionProjectionMap` 仍是协议与 UI 共享的唯一客户端词汇。各单元在内部声明宿主折叠状态类型,与发往客户端的值相互独立。 **用事件广播收集、替代注册表遍历**——不予采纳:异步监听器给不出那个单一的同步切面,而正是它让 `asOfSeq` 成为横跨所有 key 的一致快照;注册表才是本仓库承接贡献的通行形状(`ctx.tools`、提示词片段、slot)。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b025f67f9c..326c9d2ac2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1418,7 +1418,7 @@ export type Config = LocalConfig ## `@deepseek-ai/dsh-goal` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/goal/goal/src/index.ts:172`](../packages/goal/goal/src/index.ts) +- `source`: [`packages/goal/goal/src/index.ts:168`](../packages/goal/goal/src/index.ts) ```ts config-catalog /** Deployment defaults for goal creation. */ @@ -2337,7 +2337,7 @@ export interface Config { - `inject`: `shell` · `approval` · `sessions` · `sessionProjections` - `refs`: [`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md) · `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/interaction/permission-presets/src/index.ts:159`](../packages/interaction/permission-presets/src/index.ts) +- `source`: [`packages/interaction/permission-presets/src/index.ts:152`](../packages/interaction/permission-presets/src/index.ts) ```ts config-catalog /** The {@link PermissionPresetService} config: preset table and composition default. */ @@ -3636,7 +3636,7 @@ export type ShellDialect = 'bash' | 'pwsh' ## `@deepseek-ai/dsh-time-context` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/context/time-context/src/index.ts:56`](../packages/context/time-context/src/index.ts) +- `source`: [`packages/context/time-context/src/index.ts:46`](../packages/context/time-context/src/index.ts) ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ @@ -4515,7 +4515,7 @@ export interface Config { ## `@deepseek-ai/dsh-working-directory` - `inject`: `fs` · `sessionProjections` · `systemPrompt` -- `source`: [`packages/session/working-directory/src/index.ts:56`](../packages/session/working-directory/src/index.ts) +- `source`: [`packages/session/working-directory/src/index.ts:51`](../packages/session/working-directory/src/index.ts) ```ts config-catalog /** Deployment default for Sessions without an original project directory. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index bb92fc0686..5f1378aed0 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1420,7 +1420,7 @@ export type Config = LocalConfig ## `@deepseek-ai/dsh-goal` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/goal/goal/src/index.ts:172`](../packages/goal/goal/src/index.ts) +- `source`: [`packages/goal/goal/src/index.ts:168`](../packages/goal/goal/src/index.ts) ```ts config-catalog /** Deployment defaults for goal creation. */ @@ -2339,7 +2339,7 @@ export interface Config { - `inject`: `shell` · `approval` · `sessions` · `sessionProjections` - `refs`: [`ApprovalPolicy`](subsystems/approval.zh.md) · [`SandboxMode`](subsystems/sandbox.zh.md) · `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/interaction/permission-presets/src/index.ts:159`](../packages/interaction/permission-presets/src/index.ts) +- `source`: [`packages/interaction/permission-presets/src/index.ts:152`](../packages/interaction/permission-presets/src/index.ts) ```ts config-catalog /** The {@link PermissionPresetService} config: preset table and composition default. */ @@ -3638,7 +3638,7 @@ export type ShellDialect = 'bash' | 'pwsh' ## `@deepseek-ai/dsh-time-context` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/context/time-context/src/index.ts:56`](../packages/context/time-context/src/index.ts) +- `source`: [`packages/context/time-context/src/index.ts:46`](../packages/context/time-context/src/index.ts) ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ @@ -4517,7 +4517,7 @@ export interface Config { ## `@deepseek-ai/dsh-working-directory` - `inject`: `fs` · `sessionProjections` · `systemPrompt` -- `source`: [`packages/session/working-directory/src/index.ts:56`](../packages/session/working-directory/src/index.ts) +- `source`: [`packages/session/working-directory/src/index.ts:51`](../packages/session/working-directory/src/index.ts) ```ts config-catalog /** Deployment default for Sessions without an original project directory. */ diff --git a/docs/defensive-patterns/conversation-and-session.md b/docs/defensive-patterns/conversation-and-session.md index 7b91d6a676..f2f610ebe4 100644 --- a/docs/defensive-patterns/conversation-and-session.md +++ b/docs/defensive-patterns/conversation-and-session.md @@ -24,7 +24,7 @@ function PlanChip({ useProjection }: PlanChipProps) { } // The computation is a registered unit; the registry drives it and publishes changes. -const planDefinition: ProjectionDefinition<'plan'> = { +const planDefinition: ProjectionDefinition<'plan', typeof emptyPlan> = { key: 'plan', stateVersion: 1, init: () => emptyPlan, diff --git a/docs/defensive-patterns/conversation-and-session.zh.md b/docs/defensive-patterns/conversation-and-session.zh.md index 855fdd876f..448ce061ec 100644 --- a/docs/defensive-patterns/conversation-and-session.zh.md +++ b/docs/defensive-patterns/conversation-and-session.zh.md @@ -24,7 +24,7 @@ function PlanChip({ useProjection }: PlanChipProps) { } // The computation is a registered unit; the registry drives it and publishes changes. -const planDefinition: ProjectionDefinition<'plan'> = { +const planDefinition: ProjectionDefinition<'plan', typeof emptyPlan> = { key: 'plan', stateVersion: 1, init: () => emptyPlan, diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 90c4d87f8d..d695116cb7 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,7 +9,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:196`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:69`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -22,11 +22,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:629`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:636`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:615`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:622`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:621`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:628`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:614`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | @@ -49,7 +49,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` | +| `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:147`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | | `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:40`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index e34342c21d..318b933db3 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -11,7 +11,7 @@ | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:196`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:69`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -24,11 +24,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:629`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:636`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:615`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:622`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:621`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:628`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:614`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | @@ -51,7 +51,7 @@ | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` | +| `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:147`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | | `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:40`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index b6a310d21e..b7eb3300cb 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -21,8 +21,8 @@ en: 9188862058c6986b zh: 9188862058c6986b /session-persistence-event-catalog/events/agent/agent-inbox-spliced-log-only: - en: 269a193ddd48917f - zh: 63ef1beb457dbdd9 + en: 8309b4babad90744 + zh: b344ac640d79947e /session-persistence-event-catalog/events/agent-preset: en: 49891ca9be5ac123 zh: 49891ca9be5ac123 @@ -141,14 +141,14 @@ en: 9edf4ef31e60e880 zh: 9edf4ef31e60e880 /session-persistence-event-catalog/events/model/model-selection-log-only: - en: 0d9e20c2357ae7ac - zh: 23401d59e97d6011 + en: 778af1e2e58810b8 + zh: df0fbae320d404a2 /session-persistence-event-catalog/events/permission: en: 19917bc5438e60af zh: 19917bc5438e60af /session-persistence-event-catalog/events/permission/permission-preset-log-only: - en: 41de5f6e1dad8620 - zh: 92cb5d5e87a23405 + en: 54423cb464a0a495 + zh: 8b3a9595255184f0 /session-persistence-event-catalog/events/plan: en: 4294f78d26ed985b zh: 4294f78d26ed985b @@ -1143,8 +1143,8 @@ en: 3b677f021d7dd4d3 zh: 2a875742b22879a2 /session-persistence-event-catalog/resolved-persistence-types/modelselection: - en: 6d66ffc1b393f2f2 - zh: ef6cb30b71f287e7 + en: e50c44f077599608 + zh: 7491723e289b1b3b /session-persistence-event-catalog/resolved-persistence-types/oneshotscheduledispatchchange: en: a3300babe57b6b67 zh: 4aaf06a149b399c3 @@ -1344,8 +1344,8 @@ en: 2786fef560851bb4 zh: 5d787475356b2606 /session-persistence-event-catalog/resolved-persistence-types/string: - en: 009c7ecb8c7d3bb5 - zh: 7f56d672f376b22f + en: 86bef22439bace14 + zh: 9350d592cb64e8f2 /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1521,8 +1521,8 @@ en: 8a9199b1784c70f5 zh: 76c98bc74686fda0 /session-persistence-event-catalog/resolved-persistence-types/inserted-outcome-removedcount-start: - en: fb9bed95931d510b - zh: dcaccd4254b4475c + en: 452ff7ab39ffb3a9 + zh: 33af56489f0a45bc /session-persistence-event-catalog/resolved-persistence-types/interrupted-message-step-stream: en: ec10cd8e9c202a81 zh: e08f859bcb13f0d4 @@ -1830,8 +1830,8 @@ en: 809a23c25ded8497 zh: e9cd23e281f8d31f /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: 9b6318ab4d7eba12 - zh: f475439ef40b2681 + en: 9a36cd81095ac333 + zh: fbcc74bbcac8eb5d /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 @@ -1884,8 +1884,8 @@ en: 00f1d435f82d2753 zh: c2ab108ad2748a6c /session-persistence-event-catalog/resolved-persistence-types/preset: - en: f60ecbd156c6f063 - zh: c9149679bbacff21 + en: 26cf71c850765c59 + zh: 7a436be430e04898 /session-persistence-event-catalog/resolved-persistence-types/reason-turn: en: a4ba4d0ee1396381 zh: b6f595f8a696d512 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index fb90826391..af9fedf418 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -185,7 +185,7 @@ Sources: [`packages/core/session/src/types.ts:432`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:88`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:84`](../packages/core/agent/src/types.ts) ### `agent-preset/*` @@ -700,7 +700,7 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src 'model/selection': ModelSelection ``` -Source: [`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) +Source: [`packages/api/session-controller/src/types.ts:33`](../packages/api/session-controller/src/types.ts) ### `permission/*` @@ -718,7 +718,7 @@ Source: [`packages/api/session-controller/src/types.ts:41`](../packages/api/sess 'permission/preset': { preset: string } ``` -Source: [`packages/interaction/permission-presets/src/index.ts:59`](../packages/interaction/permission-presets/src/index.ts) +Source: [`packages/interaction/permission-presets/src/index.ts:52`](../packages/interaction/permission-presets/src/index.ts) ### `plan/*` @@ -4329,7 +4329,7 @@ Sources: [`packages/llm/llm/src/message.ts:24`](../packages/llm/llm/src/message. SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -Sources: [`packages/api/session-controller/src/types.ts:101`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:93`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5575,7 +5575,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:409`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:401`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -6369,7 +6369,7 @@ Sources: [`packages/core/session/src/types.ts:428`](../packages/core/session/src SHA-256: `fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42` -Sources: [`packages/core/agent/src/types.ts:88`](../packages/core/agent/src/types.ts) +Sources: [`packages/core/agent/src/types.ts:84`](../packages/core/agent/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7546,7 +7546,7 @@ Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/inde SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -Sources: [`packages/api/session-controller/src/types.ts:418`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:410`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7781,7 +7781,7 @@ Sources: [`packages/interaction/user-approval/src/index.ts:40`](../packages/inte SHA-256: `8a9c990773f798eac10ca8878e6bcc531ee080bc589cac956730e462cc6a09ad` -Sources: [`packages/interaction/permission-presets/src/index.ts:59`](../packages/interaction/permission-presets/src/index.ts) +Sources: [`packages/interaction/permission-presets/src/index.ts:52`](../packages/interaction/permission-presets/src/index.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 75a0ade844..6c064bfbcc 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -187,7 +187,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/agent/src/types.ts:88`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:84`](../packages/core/agent/src/types.ts) ### `agent-preset/*` @@ -702,7 +702,7 @@ export type SessionEvent = { 'model/selection': ModelSelection ``` -来源:[`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:33`](../packages/api/session-controller/src/types.ts) ### `permission/*` @@ -720,7 +720,7 @@ export type SessionEvent = { 'permission/preset': { preset: string } ``` -来源:[`packages/interaction/permission-presets/src/index.ts:59`](../packages/interaction/permission-presets/src/index.ts) +来源:[`packages/interaction/permission-presets/src/index.ts:52`](../packages/interaction/permission-presets/src/index.ts) ### `plan/*` @@ -4331,7 +4331,7 @@ SHA-256: `719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91` SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -来源:[`packages/api/session-controller/src/types.ts:101`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:93`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5577,7 +5577,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:409`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:401`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -6371,7 +6371,7 @@ SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3` SHA-256: `fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42` -来源:[`packages/core/agent/src/types.ts:88`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:84`](../packages/core/agent/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7548,7 +7548,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -来源:[`packages/api/session-controller/src/types.ts:418`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:410`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7783,7 +7783,7 @@ SHA-256: `11f70b595aa76897fc4e25a352bfa718c659f44b799e5aa14e84e5a38fe72912` SHA-256: `8a9c990773f798eac10ca8878e6bcc531ee080bc589cac956730e462cc6a09ad` -来源:[`packages/interaction/permission-presets/src/index.ts:59`](../packages/interaction/permission-presets/src/index.ts) +来源:[`packages/interaction/permission-presets/src/index.ts:52`](../packages/interaction/permission-presets/src/index.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index ec0566a51e..9f52ce31cb 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -23546,7 +23546,7 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:409", + "packages/api/session-controller/src/types.ts:401", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", "packages/core/session/src/types.ts:21", @@ -24068,7 +24068,7 @@ "digest": "6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def", "names": [], "sources": [ - "packages/api/session-controller/src/types.ts:418" + "packages/api/session-controller/src/types.ts:410" ] }, { @@ -24490,7 +24490,7 @@ "digest": "8a9c990773f798eac10ca8878e6bcc531ee080bc589cac956730e462cc6a09ad", "names": [], "sources": [ - "packages/interaction/permission-presets/src/index.ts:59" + "packages/interaction/permission-presets/src/index.ts:52" ] }, { @@ -25884,7 +25884,7 @@ "packages/api/session-controller/src/types.ts#ModelSelection" ], "sources": [ - "packages/api/session-controller/src/types.ts:101" + "packages/api/session-controller/src/types.ts:93" ] }, { @@ -26047,7 +26047,7 @@ "digest": "fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42", "names": [], "sources": [ - "packages/core/agent/src/types.ts:88" + "packages/core/agent/src/types.ts:84" ] }, { diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 5f008d2f02..7f4e45e098 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -6,8 +6,8 @@ en: e9330151d3eb579a zh: 53e47acd1d810da9 /session-projections/the-unit: - en: fad897af8b8839ff - zh: 3c68c5256973bc39 + en: d48814d3ab0e997f + zh: d0337d8eba097a63 /session-projections/the-snapshot-and-the-change-feed: en: 630c1041f41f89bf zh: a3361bdead116cd7 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index b7d8fb2bad..950ae2d89f 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -28,7 +28,7 @@ interface ProjectionHandle { ## The unit -`SessionProjectionStateMap` is the merge-extensible table of host fold states, while `SessionProjectionMap` retains the client-visible whole values. A domain contributes one `ProjectionDefinition` per state key; a `wire` block makes that key client-visible, and rendering belongs to the slot system, never this layer: +`ProjectionDefinition` carries each unit's host state type locally; registration infers `S` from `stateSchema` and returns `ProjectionHandle`. Only client-visible keys merge into `SessionProjectionMap`, which types their whole values. A `wire` block makes a unit client-visible, and rendering belongs to the slot system, never this layer: ```ts type-equiv /** @@ -39,11 +39,8 @@ interface ProjectionHandle { * synchronous (an async unit would tear the carriers' consistency cut), and * `state` MUST be plain JSON (the persisted-cache precondition). */ -interface ProjectionDefinition< - K extends keyof SessionProjectionStateMap, - S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K], -> { - /** The projection key this unit owns (its `SessionProjectionStateMap` entry). */ +interface ProjectionDefinition { + /** The projection key this unit owns. */ key: K /** Validates persisted state before it seeds a fold. */ stateSchema: ZodType @@ -235,7 +232,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns a ready handle with synchronous state reads and the effect disposer. */ -register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, ): Promise> +register( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, ): Promise> /** * Register one host-only unit. Its state is omitted from client snapshots @@ -244,7 +241,7 @@ register< K extends keyof SessionProjectionMap, S extends SessionProjectionState * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns a ready handle with synchronous state reads and the effect disposer. */ -register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'>, ): Promise> +register( definition: ProjectionDefinition & { key: Exclude }, ): Promise> /** * Subscribe to the change feed. The registration is an effect on the diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 224a5dfdb6..52611c07c7 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -28,7 +28,7 @@ interface ProjectionHandle { ## 投影单元 -`SessionProjectionStateMap` 是 host 侧折叠状态的 merge-extensible 类型表,`SessionProjectionMap` 则继续表示客户端可见的全量值。领域为每个状态 key 贡献一个 `ProjectionDefinition`;`wire` 块使该 key 对客户端可见,渲染归 slot 体系管,永远不归本层: +`ProjectionDefinition` 在各单元内部携带宿主状态类型;注册从 `stateSchema` 推导 `S`,并返回 `ProjectionHandle`。只有客户端可见的 key 合并到 `SessionProjectionMap`,由它约束对应的全量值。`wire` 块使单元对客户端可见,渲染归 slot 体系管,永远不归本层: ```ts type-equiv /** @@ -39,11 +39,8 @@ interface ProjectionHandle { * synchronous (an async unit would tear the carriers' consistency cut), and * `state` MUST be plain JSON (the persisted-cache precondition). */ -interface ProjectionDefinition< - K extends keyof SessionProjectionStateMap, - S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K], -> { - /** The projection key this unit owns (its `SessionProjectionStateMap` entry). */ +interface ProjectionDefinition { + /** The projection key this unit owns. */ key: K /** Validates persisted state before it seeds a fold. */ stateSchema: ZodType @@ -235,7 +232,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns a ready handle with synchronous state reads and the effect disposer. */ -register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, ): Promise> +register( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, ): Promise> /** * Register one host-only unit. Its state is omitted from client snapshots @@ -244,7 +241,7 @@ register< K extends keyof SessionProjectionMap, S extends SessionProjectionState * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns a ready handle with synchronous state reads and the effect disposer. */ -register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'>, ): Promise> +register( definition: ProjectionDefinition & { key: Exclude }, ): Promise> /** * Subscribe to the change feed. The registration is an effect on the diff --git a/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.i18n.yaml index 16a7f3f11c..6918fdf4c1 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 5cf8cb0051860df5 zh: 2578c6c3e77bc1c7 /session-projection-registration-handles/change: - en: d488f71dcb5fcafb - zh: 0133ba0a568993ad + en: 64c2492c43c7a263 + zh: ba5a15b959df496c /session-projection-registration-handles/migration: - en: f268d8ba1d131b0f - zh: 29c3fe01d8b1cd0e + en: bd4310bb946b6f08 + zh: 2086e6e9c11bc4ed diff --git a/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.md b/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.md index 25f76616c2..73734634e3 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.md +++ b/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.md @@ -8,14 +8,14 @@ English | [中文](guide.zh.md) ## Change -`@deepseek-ai/dsh-session-projection` changes `SessionProjectionRegistry.register(definition)` from a synchronous disposer to `Promise>`. Registry-level `stateOf(session, key)` is removed. Registration errors now reject the returned promise. After the last registration is disposed, the unit leaves snapshots, checkpoints, and live notifications, but retained handles can still fold Session events on demand. +`@deepseek-ai/dsh-session-projection` changes `SessionProjectionRegistry.register(definition)` from a synchronous disposer to `Promise>`. Registry-level `stateOf(session, key)` and `SessionProjectionStateMap` are removed. Host state types stay local to `ProjectionDefinition` and are inferred from `stateSchema` during registration. Registration errors now reject the returned promise. After the last registration is disposed, the unit leaves snapshots, checkpoints, and live notifications, but retained handles can still fold Session events on demand. `@deepseek-ai/dsh-agent` and `@deepseek-ai/dsh-subagent` require `sessionProjections`. AgentRegistry owns turn and step state; consumers use `ctx.agents.turnBoundaryOf(session)` instead of importing `turnBoundaryProjectionDefinition`. Turn-state consumers, including the Claude Code mods bridge, require `agents`. Preset and context-pressure reads use `ctx.agentPresets.presetForSession(session)` and `ctx.tokenMeter.contextPressure(session)`. The API controller and mods bridge no longer register those definitions independently; their optional-service behavior applies when the owning service is absent. `@deepseek-ai/dsh-api-session-controller` also removes `installModelSelectionProjection`. ## Migration 1. Mount `SessionProjectionRegistry` before `AgentRegistry`, `SubagentRuntime`, and other projection owners in custom compositions. Include `@deepseek-ai/dsh-agent` wherever a plugin requires `agents`. Consumers of domain data inject the owning service. -2. For a plugin's own definitions, await `ctx.sessionProjections.register(definition)` in asynchronous `apply()` or `[Service.init]()` and retain the handle privately before publishing dependent operations. Keep existing function-plugin namespace exports. +2. Remove host-state map augmentations and imports; pass the local state type as the second `ProjectionDefinition` argument. Keep client-value declarations in `SessionProjectionMap`. For a plugin's own definitions, await `ctx.sessionProjections.register(definition)` in asynchronous `apply()` or `[Service.init]()` and retain the handle privately before publishing dependent operations. Keep existing function-plugin namespace exports. 3. Replace `registry.stateOf(session, key)` with a business service method for another domain, or `handle.stateOf(session)` inside the owner. Replace stored unregister callbacks with `handle.dispose`, which always returns `Promise`, and await explicit disposal; fiber disposal still releases registrations automatically. Await registration when handling initialization failures. 4. Replace `installModelSelectionProjection(ctx)` with `await ctx.sessionProjections.register(modelSelectionProjection)`, importing the definition from `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts`. 5. Typecheck custom plugins, then verify activation, a Session state read, and owner disposal. A released reader remains usable, while the released unit disappears from new snapshots once its final registration is gone. diff --git a/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.zh.md index 7c917af0e4..d79783d036 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.zh.md @@ -8,14 +8,14 @@ description: "Session 投影注册改为异步,并返回保留读取能力的 ## 变更 -`@deepseek-ai/dsh-session-projection` 的 `SessionProjectionRegistry.register(definition)` 从同步释放函数改为返回 `Promise>`。注册表级 `stateOf(session, key)` 已移除,注册错误现在使返回的 Promise 拒绝。最后一次注册释放后,该投影单元不再参与快照、检查点和实时通知,但保留的句柄仍可按需折叠 Session 事件。 +`@deepseek-ai/dsh-session-projection` 的 `SessionProjectionRegistry.register(definition)` 从同步释放函数改为返回 `Promise>`。注册表级 `stateOf(session, key)` 与 `SessionProjectionStateMap` 已移除。宿主状态类型保留在各自的 `ProjectionDefinition` 中,注册时从 `stateSchema` 推导。注册错误现在使返回的 Promise 拒绝。最后一次注册释放后,该投影单元不再参与快照、检查点和实时通知,但保留的句柄仍可按需折叠 Session 事件。 `@deepseek-ai/dsh-agent` 与 `@deepseek-ai/dsh-subagent` 依赖 `sessionProjections`。AgentRegistry 持有轮次和步骤状态;消费方使用 `ctx.agents.turnBoundaryOf(session)`,不再导入 `turnBoundaryProjectionDefinition`。包括 Claude Code mods 桥接在内的轮次状态消费方依赖 `agents`。预设与上下文压力分别通过 `ctx.agentPresets.presetForSession(session)` 和 `ctx.tokenMeter.contextPressure(session)` 读取。API 控制器与 mods 桥接不再独立注册这些定义;所属服务缺席时采用各自的可选服务行为。`@deepseek-ai/dsh-api-session-controller` 同时移除了 `installModelSelectionProjection`。 ## 迁移 1. 在自定义组合中,先挂载 `SessionProjectionRegistry`,再挂载 `AgentRegistry`、`SubagentRuntime` 及其他投影所有者。组合中的插件依赖 `agents` 时,加入 `@deepseek-ai/dsh-agent`。领域数据的消费方注入所属服务。 -2. 对插件自己的定义,在异步 `apply()` 或 `[Service.init]()` 中等待 `ctx.sessionProjections.register(definition)`,并在提供依赖操作前于内部保存句柄。保留现有函数插件命名空间导出。 +2. 删除宿主状态映射的声明合并与导入,将局部状态类型作为 `ProjectionDefinition` 的第二个类型参数。保留 `SessionProjectionMap` 中的客户端值声明。对插件自己的定义,在异步 `apply()` 或 `[Service.init]()` 中等待 `ctx.sessionProjections.register(definition)`,并在提供依赖操作前于内部保存句柄。保留现有函数插件命名空间导出。 3. 将 `registry.stateOf(session, key)` 替换为其他领域的业务服务方法,或所有者内部的 `handle.stateOf(session)`。将保存的取消注册回调替换为始终返回 `Promise` 的 `handle.dispose`,并等待显式释放完成;fiber 释放仍会自动释放注册。处理初始化失败时须等待注册。 4. 将 `installModelSelectionProjection(ctx)` 替换为 `await ctx.sessionProjections.register(modelSelectionProjection)`,从 `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts` 导入该定义。 5. 对自定义插件执行类型检查,再验证激活、Session 状态读取与 owner 释放。已释放的读取句柄仍可使用,而投影单元在最后一次注册解除后会从新快照中消失。 diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c0b2b9a573..0d94a302af 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -14,14 +14,6 @@ import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Host state persisted for cold Session list summaries. */ - sessionListMetadata: SessionListMetadata - /** Host state for the boot-constant image-limit view. */ - imageLimits: null - /** Durable model selection already used by a request and still pending for a later request. */ - modelSelection: ModelSelectionProjectionState - } interface SessionProjectionMap { /** Persisted facts used to summarize a Session without activating it. */ sessionListMetadata: SessionListMetadata diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index b8848c2084..14be3e07aa 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -41,11 +41,6 @@ afterEach(async () => { let nextHarnessSession = 1 declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - 'test/last-user': LastUserState - 'test/internal-count': number - 'test/private-prompt': string | null - } interface SessionProjectionMap { 'test/last-user': { text: string } | null } diff --git a/packages/context/harness-dev-context/src/index.ts b/packages/context/harness-dev-context/src/index.ts index 588bb84b9b..40ae8f38f1 100644 --- a/packages/context/harness-dev-context/src/index.ts +++ b/packages/context/harness-dev-context/src/index.ts @@ -85,13 +85,6 @@ const legacyPromptSchema = zod.object({ source: zod.string().nullable(), web: zo /** Value-bearing paragraphs of the latest nonempty system prompt a Session committed; `null` where it has none. */ type LegacyPrompt = zod.infer -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Value-bearing harness-source and Web-surface paragraphs of the latest nonempty admitted system prompt. */ - harnessDevLegacyPrompt: LegacyPrompt - } -} - /** The complete single-line paragraph between `prefix` and `suffix`, or `null` when the prompt has none. */ function legacyParagraph(prompt: string, { prefix, suffix }: { prefix: string; suffix: string }): string | null { const start = prompt.indexOf(prefix) diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 25cc4b484d..69eb467fc6 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -30,13 +30,6 @@ import { createTimestampFormatter, formatTimestamp } from './timestamp.ts' /** Cordis plugin name used by loader diagnostics. */ export const name = 'time-context' -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Latest time-context readings. */ - timeContext: TimeContextProjection - } -} - const timeContextStateSchema = zod.object({ /** Time of the latest model-visible event (user/assistant message, tool result), or null. */ lastMessageTime: zod.number().nullable(), @@ -46,9 +39,6 @@ const timeContextStateSchema = zod.object({ lastTurnInjectionTime: zod.number().nullable(), }) -/** Folded time-context readings. */ -type TimeContextProjection = zod.infer - /** The agent registry that owns pre-step processing. */ export const inject = ['agents', 'sessionProjections'] diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts index 8a5f9d7ae3..02c756f90f 100644 --- a/packages/context/tmux-context/src/index.ts +++ b/packages/context/tmux-context/src/index.ts @@ -208,15 +208,6 @@ const tmuxContextStateSchema = zod.object({ time: zod.number(), }).nullable() -type TmuxContextState = zod.infer - -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** The stable state block of this plugin's latest durable injection, or null. */ - tmuxContext: TmuxContextState - } -} - /** * Register a prepended pre-step listener for the lifetime of `ctx`. * @param ctx - plugin context; the listener is disposed with it. diff --git a/packages/core/agent/src/projection.ts b/packages/core/agent/src/projection.ts index c4a56449f3..a3414561c5 100644 --- a/packages/core/agent/src/projection.ts +++ b/packages/core/agent/src/projection.ts @@ -5,13 +5,6 @@ import type { SessionSeq } from '@deepseek-ai/dsh-session/types' import { brandNumber } from '@deepseek-ai/dsh-brand' import { z as zod } from 'zod' -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** The agent session's open/last turn and step boundary facts (whole value). */ - turnBoundary: TurnBoundaryProjection - } -} - const sessionSeqSchema = zod.number().int().nonnegative().transform(brandNumber) const turnBoundaryProjectionSchema: zod.ZodType = zod.object({ diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 2045e9ca11..dc54eb6737 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -56,10 +56,6 @@ export interface InboxWireState { } declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Pending agent input reconstructed from durable inbox splices. */ - inbox: InboxState - } interface SessionProjectionMap { /** Pending agent input reconstructed from durable inbox splices. */ inbox: InboxWireState diff --git a/packages/experimental/agent-team/src/projection.ts b/packages/experimental/agent-team/src/projection.ts index bc9239d3d8..dc02db2298 100644 --- a/packages/experimental/agent-team/src/projection.ts +++ b/packages/experimental/agent-team/src/projection.ts @@ -158,12 +158,6 @@ export interface TeamProjectionState extends TeamState { readonly failure?: string } -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - agentTeam: TeamProjectionState - } -} - const teamProjectionEntrySchema = z.object({ id: teamIdSchema, members: z.array(teamMemberSnapshotSchema), diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 1c1190b1e0..393b333827 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2320,13 +2320,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, checkpoints, and client snapshots; retained handles still fold new events on demand using the original unit.', methods: [ { - signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): Promise>', + signature: 'register( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): Promise>', description: 'Register one domain\'s unit on the calling context\'s fiber. Fiber or handle disposal releases one reference; the final release removes live participation. Retained handles remain readable after release. Callers must await registration before publishing consumers; registration may perform asynchronous work.', parameters: [{ name: 'definition', description: 'key, state schema, pure unit functions, and stateVersion.' }], returns: 'a ready handle with synchronous state reads and the effect disposer.', }, { - signature: 'register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'>, ): Promise>', + signature: 'register( definition: ProjectionDefinition & { key: Exclude }, ): Promise>', description: 'Register one host-only unit. Its state is omitted from client snapshots and always checkpointed like every other unit. Callers must await registration before publishing consumers; registration may perform asynchronous work.', parameters: [{ name: 'definition', description: 'key, state schema, pure unit functions, and stateVersion.' }], returns: 'a ready handle with synchronous state reads and the effect disposer.', @@ -6551,7 +6551,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ProjectionDefinition', - declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(header: SessionHeader, inheritedEventCount: SessionLogOffset): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType;\n view(state: NoInfer): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}', + declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(header: SessionHeader, inheritedEventCount: SessionLogOffset): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType;\n view(state: NoInfer): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}', }, { name: 'ProjectionHandle', @@ -7357,10 +7357,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionProjectionsRequest', declaration: 'export interface SessionProjectionsRequest {\n readonly sessionId: SessionId;\n}', }, - { - name: 'SessionProjectionStateMap', - declaration: 'export interface SessionProjectionStateMap {\n}', - }, { name: 'SessionProjectionsValue', declaration: 'export type SessionProjectionsValue = (SessionProjectionBaseline & {\n readonly kind: \'sequenced\';\n}) | {\n readonly kind: \'migration-required\';\n readonly values: SessionProjectionValues;\n} | null;', diff --git a/packages/goal/goal/README.i18n.yaml b/packages/goal/goal/README.i18n.yaml index d7e3878d2c..4aa0d5c88d 100644 --- a/packages/goal/goal/README.i18n.yaml +++ b/packages/goal/goal/README.i18n.yaml @@ -24,8 +24,8 @@ en: 81d959eecdec4bbd zh: ff47fb2cba8f1e73 /deepseek-ai-dsh-goal/use-this-package/session-projection: - en: 251f84f8959a2072 - zh: c57a4d252e12b128 + en: 67b924c4f6902dc1 + zh: 99ed177a06126895 /deepseek-ai-dsh-goal/use-this-package/drive-the-lifecycle: en: 622890ced87a44e9 zh: 6ba1f3f872400452 diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md index c39bc0cbcb..9e0a1ee60d 100644 --- a/packages/goal/goal/README.md +++ b/packages/goal/goal/README.md @@ -49,7 +49,7 @@ Load the package with a composition entry; the only deployment choice is the def ### Session projection -`GoalService` requires `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)) and registers the `goal` projection unit at startup; a composition that omits the projection registry cannot activate `ctx.goals`. The unit's version 6 host state retains the latest valid current goal, every previously used goal id, and the first strict replay failure. Its client view exposes the current goal or `null` before the first create and after a clear tombstone. The key merges into both `SessionProjectionStateMap` and `SessionProjectionMap`; carriers serve the client value on the history tail page and the `session/projection` push frame. +`GoalService` requires `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)) and registers the `goal` projection unit at startup; a composition that omits the projection registry cannot activate `ctx.goals`. The unit's version 6 host state retains the latest valid current goal, every previously used goal id, and the first strict replay failure. Its client view exposes the current goal or `null` before the first create and after a clear tombstone. The client key merges into `SessionProjectionMap`; carriers serve the client value on the history tail page and the `session/projection` push frame. ### Drive the lifecycle diff --git a/packages/goal/goal/README.zh.md b/packages/goal/goal/README.zh.md index a52fbcfdc2..ac8fd3837b 100644 --- a/packages/goal/goal/README.zh.md +++ b/packages/goal/goal/README.zh.md @@ -49,7 +49,7 @@ goal 适合一个需要跨自动 Goal Round 持续的长期完成目标——例 ### 会话投影 -`GoalService` 要求组合提供 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.zh.md)),并在启动时注册 `goal` 投影单元;未组合投影注册表的组合无法激活 `ctx.goals`。该单元版本为 6,其宿主状态保留最新的有效当前 goal、所有曾使用的 goal id,以及第一次严格回放失败。客户端视图提供当前 goal;首次 create 前与 clear tombstone 后为 `null`。该键同时合并到 `SessionProjectionStateMap` 与 `SessionProjectionMap`;载体通过历史尾页和 `session/projection` 推送帧提供客户端值。 +`GoalService` 要求组合提供 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.zh.md)),并在启动时注册 `goal` 投影单元;未组合投影注册表的组合无法激活 `ctx.goals`。该单元版本为 6,其宿主状态保留最新的有效当前 goal、所有曾使用的 goal id,以及第一次严格回放失败。客户端视图提供当前 goal;首次 create 前与 clear tombstone 后为 `null`。客户端键合并到 `SessionProjectionMap`;载体通过历史尾页和 `session/projection` 推送帧提供客户端值。 ### 驱动生命周期 diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 275b757eba..3d54ed75d6 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -47,10 +47,6 @@ import type { GoalSnapshotChangeMeta, } from './domain.ts' -// The pure payload outlet (./types.ts, ONE home of the `goal` projection-key -// declaration) re-exported onto the package root keeps the module edge in -// the emitted index.d.ts, so aggregate programs consuming the declarations -// still receive the SessionProjectionStateMap merge. export type * from './types.ts' export type * from './domain.ts' export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts' diff --git a/packages/goal/goal/src/types.ts b/packages/goal/goal/src/types.ts index eb7cd602ed..f7078de673 100644 --- a/packages/goal/goal/src/types.ts +++ b/packages/goal/goal/src/types.ts @@ -126,9 +126,6 @@ export interface GoalProjectionState { } declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - goal: GoalProjectionState - } interface SessionProjectionMap { /** * The session's current goal and admitted-round count, or diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts index 580443d7c3..935de3d5fa 100644 --- a/packages/interaction/permission-presets/src/index.ts +++ b/packages/interaction/permission-presets/src/index.ts @@ -41,13 +41,6 @@ declare module '@deepseek-ai/cordis' { } } -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Latest logged permission overrides and constructor-seed status. */ - permissions: PermissionProjectionState - } -} - declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** diff --git a/packages/interaction/user-questions/src/projection.ts b/packages/interaction/user-questions/src/projection.ts index 7453a265a5..0c64c93fa0 100644 --- a/packages/interaction/user-questions/src/projection.ts +++ b/packages/interaction/user-questions/src/projection.ts @@ -329,9 +329,3 @@ export const userQuestionProjectionDefinition = { wire: { viewSchema: projectionViewSchema, view: state => state.questions }, stateVersion: 2, } satisfies ProjectionDefinition<'userQuestions', UserQuestionProjectionState> - -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - userQuestions: UserQuestionProjectionState - } -} diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 6b7fb6e682..0c181260ce 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -107,12 +107,6 @@ const llmRetryStateSchema: zod.ZodType = zod.record(zod.string(), retry: zod.number().int().nonnegative(), retryId: zod.string(), })) as unknown as zod.ZodType -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Retry state for the current step by provider and policy. */ - llmRetry: LlmRetryState - } -} /** * Install provider-routed normal or unbounded request recovery. diff --git a/packages/llm/token-meter/src/breakdown-projection.ts b/packages/llm/token-meter/src/breakdown-projection.ts index 66ea6ae398..43ce1b04f7 100644 --- a/packages/llm/token-meter/src/breakdown-projection.ts +++ b/packages/llm/token-meter/src/breakdown-projection.ts @@ -13,15 +13,9 @@ import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' // Type-only: the `compaction/*` SessionEventMap merges (shadow-price events). import type {} from '@deepseek-ai/dsh-compaction' import { estimateMessage, estimateToolsTokens } from './estimate.ts' -// Import for the `contextBreakdown` SessionProjectionStateMap key merge. +// Type-only: the client projection key and value declaration. import type {} from './projection.ts' -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - contextBreakdown: ContextBreakdownState - } -} - const tokenCount = z.number().int().nonnegative() const sessionSeq = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionSeq) diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 27768fa394..552e5d9620 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -24,7 +24,6 @@ import { SessionSeq, } from '@deepseek-ai/dsh-session' import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' -import type { SessionProjectionStateMap } from '@deepseek-ai/dsh-session-projection/types' import type { ContextPressureProjection, TokenMeasurement, @@ -33,17 +32,13 @@ import type { } from './types.ts' import { contextBreakdownProjectionDefinition } from './breakdown-projection.ts' import { contextPressureProjectionDefinition, tokenUsageProjectionDefinition } from './usage-projection.ts' +import type { ContextPressureState } from './usage-projection.ts' import { estimateContent, estimateMessage, estimateToolsTokens, ROLE_OVERHEAD } from './estimate.ts' import { commitSurfaceTokens, planSurfaceTokens } from './surface-fold.ts' import type { MeterSurfaceNode } from './surface-fold.ts' import { priceSurface } from './route-pricing.ts' export type * from './types.ts' -// Module-edge re-export: forces the emitted index.d.ts to import the -// projection-unit modules, so their SessionProjectionStateMap augmentations load -// in aggregate programs that only import the package root. -export type * from './usage-projection.ts' -export type * from './breakdown-projection.ts' /** * Raw anchor facts captured at the latest successful call; the baseline is @@ -107,7 +102,7 @@ export class TokenMeter extends Service { static inject = ['sessionProjections'] private readonly states = new WeakMap() - private contextPressureProjectionHandle!: ProjectionHandle + private contextPressureProjectionHandle!: ProjectionHandle constructor(ctx: Context, config: TokenMeterConfig = {}) { super(ctx, 'tokenMeter') diff --git a/packages/llm/token-meter/src/usage-projection.ts b/packages/llm/token-meter/src/usage-projection.ts index 2238316fab..f07fa676a9 100644 --- a/packages/llm/token-meter/src/usage-projection.ts +++ b/packages/llm/token-meter/src/usage-projection.ts @@ -85,13 +85,6 @@ function usageOf(event: SessionEvent): TokenUsage | undefined { return lastAssistantStreamChunk(event.data.stream, 'usage')?.usage } -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - tokenUsage: TokenUsageState - contextPressure: ContextPressureState - } -} - /** The context-pressure state schema and source of its inferred type. */ const contextPressureStateSchema = z.object({ contextWindow: z.number().int().positive().optional(), @@ -105,7 +98,8 @@ const contextPressureStateSchema = z.object({ }).optional(), }).strict() -type ContextPressureState = z.infer +/** Host fold state for provider pressure and surface adjustments. */ +export type ContextPressureState = z.infer /** * Token-meter's session projection unit. diff --git a/packages/plan/plan-mode/src/types.ts b/packages/plan/plan-mode/src/types.ts index 3325c6b0c4..79fbaa3617 100644 --- a/packages/plan/plan-mode/src/types.ts +++ b/packages/plan/plan-mode/src/types.ts @@ -36,10 +36,6 @@ export interface PlanUnitState { } declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Host plan-mode fold state. */ - plan: PlanUnitState - } interface SessionProjectionMap { /** Plan collaboration state folded from the plan command lifecycle and `plan/mode` events. */ plan: PlanProjection diff --git a/packages/preset/agent-preset-registry/src/types.ts b/packages/preset/agent-preset-registry/src/types.ts index c30141d621..0875990d11 100644 --- a/packages/preset/agent-preset-registry/src/types.ts +++ b/packages/preset/agent-preset-registry/src/types.ts @@ -48,9 +48,6 @@ declare module '@deepseek-ai/dsh-typert-protocol' { } declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - agentPreset: string | null - } interface SessionProjectionMap { /** Preset the Session runs, or null when the deployment composes none. */ agentPreset: string | null diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index 942838ca36..e1fa052558 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -93,14 +93,6 @@ const sandboxModeStateSchema = zod.union([ zod.literal('danger-full-access'), ]).nullable() -type SandboxModeState = zod.infer -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Last logged sandbox-mode override, or null before one (deployment default applies at resolve time). */ - sandboxMode: SandboxModeState - } -} - /** * The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment * default mode, fallback workspace root, and current request-time policy diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index 77d2f81ab3..bacceed9e9 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -38,15 +38,6 @@ import { checkpointRecord, projectionCacheDomainSpec } from '../src/spec.ts' import type { CheckpointRecord } from '../src/spec.ts' declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - 'cache-test/marks': MarksState - 'cache-test/secondary-marks': MarksState - 'cache-test/marks2': Map - 'cache-test/count': number - 'cache-test/secret': string - 'cache-test/marks3': MarksState - title: string | null - } interface SessionProjectionMap { 'cache-test/marks': { marks: string[] } 'cache-test/secondary-marks': { marks: string[] } diff --git a/packages/session/session-projection-cache/tests/fixtures.spec.ts b/packages/session/session-projection-cache/tests/fixtures.spec.ts index 1fd5126eeb..d20b9a8c73 100644 --- a/packages/session/session-projection-cache/tests/fixtures.spec.ts +++ b/packages/session/session-projection-cache/tests/fixtures.spec.ts @@ -37,9 +37,6 @@ import { checkpointRow, projectionCacheDomainSpec } from '../src/spec.ts' // Declarations must match the shipped title unit's exactly (the repo-wide // compile face sees both). declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - title: string | null - } interface SessionProjectionMap { title: string | null } diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 96becd6d49..4cbe0781d9 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -24,8 +24,8 @@ en: 09758d1475fad86e zh: 473637cb34aebe72 /deepseek-ai-dsh-session-projection/use-this-package/register-and-read: - en: 43af8fda90742877 - zh: 4d03fef88cb0eb54 + en: b75f8f00df2933e1 + zh: c53e64518953110b /deepseek-ai-dsh-session-projection/use-this-package/persisted-checkpoints: en: 45f1c05fb7e08180 zh: f8d62c749899651a @@ -36,8 +36,8 @@ en: 8afa0fdb49898f6d zh: 6b2da34455963935 /deepseek-ai-dsh-session-projection/understand-the-implementation/source-map: - en: b0263d26c5a65d7a - zh: 2e58d3181dde901c + en: 48f170aebd4a630b + zh: 2b249e8fb9bde678 /deepseek-ai-dsh-session-projection/understand-the-implementation/drive-and-checkpoint-flow: en: 00136880bb586403 zh: bd35f645111c068c diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 930de0955d..35db3ed33c 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -55,7 +55,7 @@ const definition = { ### Register and read -`await register(definition)` installs the unit and returns a handle with synchronous `stateOf(session)` reads and `dispose(): Promise`. Await `dispose()` to release the registration; repeated calls are safe. Registrants with the same key and `stateVersion` share cells; incompatible or invalid versions reject registration. Registration belongs to the calling fiber. The last unregister removes the unit from global event drives, snapshots, checkpoints, and change notifications. Existing handles remain readable: each retains its original unit and folds new Session events on demand, even if another version later registers the same key. Reading a handle does not initialize unrelated units or register it again. Carriers use `snapshot(session)` for a consistent client-view cut and `onChanged(listener)` for changes to registered units. +`await register(definition)` infers the host state type from `stateSchema`, installs the unit, and returns a handle with synchronous `stateOf(session)` reads and `dispose(): Promise`. Await `dispose()` to release the registration; repeated calls are safe. Registrants with the same key and `stateVersion` share cells; incompatible or invalid versions reject registration. Registration belongs to the calling fiber. The last unregister removes the unit from global event drives, snapshots, checkpoints, and change notifications. Existing handles remain readable: each retains its original unit and folds new Session events on demand, even if another version later registers the same key. Reading a handle does not initialize unrelated units or register it again. Carriers use `snapshot(session)` for a consistent client-view cut and `onChanged(listener)` for changes to registered units. ```text const todoProjectionHandle = await ctx.sessionProjections.register(definition) @@ -88,7 +88,7 @@ The package is the Service Definition and drive role of a capability seam: the f | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `SessionProjectionRegistry` service, `ProjectionDefinition`, snapshot and checkpoint machinery | -| [`src/types.ts`](src/types.ts) | The merge-extensible `SessionProjectionMap` and `SessionProjectionStateMap` type tables | +| [`src/types.ts`](src/types.ts) | The merge-extensible client `SessionProjectionMap` type table | ### Drive and checkpoint flow diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index e0adbadde0..5554c29879 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -55,7 +55,7 @@ const definition = { ### 注册与读取 -`await register(definition)` 安装单元,返回提供同步 `stateOf(session)` 读取和 `dispose(): Promise` 的 handle。等待 `dispose()` 完成即可释放注册;重复调用是安全的。具有相同 key 和 `stateVersion` 的注册方共享 cell;版本不兼容或非法时注册会拒绝。注册归调用方 fiber 所有。最后一个注册方注销后,单元退出全局事件驱动、快照、检查点和变更通知。已有 handle 仍可读取:它保留原有单元,按需折叠新增的 Session 事件,即使同一 key 后来注册了其他版本也不受影响。读取 handle 不会初始化无关单元,也不会重新注册。载体用 `snapshot(session)` 读取一致的客户端视图切面,用 `onChanged(listener)` 订阅已注册单元的变更。 +`await register(definition)` 从 `stateSchema` 推导宿主状态类型,安装单元,并返回提供同步 `stateOf(session)` 读取和 `dispose(): Promise` 的 handle。等待 `dispose()` 完成即可释放注册;重复调用是安全的。具有相同 key 和 `stateVersion` 的注册方共享 cell;版本不兼容或非法时注册会拒绝。注册归调用方 fiber 所有。最后一个注册方注销后,单元退出全局事件驱动、快照、检查点和变更通知。已有 handle 仍可读取:它保留原有单元,按需折叠新增的 Session 事件,即使同一 key 后来注册了其他版本也不受影响。读取 handle 不会初始化无关单元,也不会重新注册。载体用 `snapshot(session)` 读取一致的客户端视图切面,用 `onChanged(listener)` 订阅已注册单元的变更。 ```text const todoProjectionHandle = await ctx.sessionProjections.register(definition) @@ -88,7 +88,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`SessionProjectionRegistry` 服务、`ProjectionDefinition`、快照与检查点机制 | -| [`src/types.ts`](src/types.ts) | 可合并扩展的 `SessionProjectionMap` 与 `SessionProjectionStateMap` 类型表 | +| [`src/types.ts`](src/types.ts) | 可合并扩展的客户端 `SessionProjectionMap` 类型表 | ### 驱动与检查点流程 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 1790882394..8f5c62d216 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -1,6 +1,6 @@ /** - * Service Definition and drive registry for the session-projection capability seam: the merge-extensible state and client-view type - * tables, the `ProjectionDefinition` state-driven computation unit contract, + * Service Definition and drive registry for the session-projection capability seam: the merge-extensible client-view type + * table, the `ProjectionDefinition` state-driven computation unit contract, * and the `ctx.sessionProjections` registry that DRIVES every registered unit * forward eagerly over committed session events. Domain host plugins * contribute pure folds and optional client views; the framework owns the @@ -33,9 +33,9 @@ declare module '@deepseek-ai/cordis' { } } -import type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts' +import type { SessionProjectionMap } from './types.ts' -export type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts' +export type { SessionProjectionMap } from './types.ts' /** An effect-owned registration with a reader retained independently of registration. */ export interface ProjectionHandle { @@ -61,11 +61,8 @@ export interface ProjectionHandle { * synchronous (an async unit would tear the carriers' consistency cut), and * `state` MUST be plain JSON (the persisted-cache precondition). */ -export interface ProjectionDefinition< - K extends keyof SessionProjectionStateMap, - S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K], -> { - /** The projection key this unit owns (its `SessionProjectionStateMap` entry). */ +export interface ProjectionDefinition { + /** The projection key this unit owns. */ key: K /** Validates persisted state before it seeds a fold. */ stateSchema: ZodType @@ -242,10 +239,7 @@ export class SessionProjectionRegistry extends Service { * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns a ready handle with synchronous state reads and the effect disposer. */ - register< - K extends keyof SessionProjectionMap, - S extends SessionProjectionStateMap[K], - >( + register( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, @@ -257,13 +251,10 @@ export class SessionProjectionRegistry extends Service { * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns a ready handle with synchronous state reads and the effect disposer. */ - register< - K extends Exclude, - S extends SessionProjectionStateMap[K], - >( - definition: Omit, 'wire'>, + register( + definition: ProjectionDefinition & { key: Exclude }, ): Promise> - async register( + async register( definition: ProjectionDefinition, ): Promise> { const wire = definition.wire as { diff --git a/packages/session/session-projection/src/types.ts b/packages/session/session-projection/src/types.ts index 8373e4677b..0b112ce0db 100644 --- a/packages/session/session-projection/src/types.ts +++ b/packages/session/session-projection/src/types.ts @@ -15,10 +15,3 @@ * system's business, never this layer's. */ export interface SessionProjectionMap {} - -/** - * The merge-extensible host fold-state table. Each client-visible key also - * appears in {@link SessionProjectionMap}; host-only keys appear only here. - * Values must be plain JSON so the projection cache can persist them. - */ -export interface SessionProjectionStateMap {} diff --git a/packages/session/session-projection/tests/registration-reader.spec.ts b/packages/session/session-projection/tests/registration-reader.spec.ts index ab287d6110..6850d1fd86 100644 --- a/packages/session/session-projection/tests/registration-reader.spec.ts +++ b/packages/session/session-projection/tests/registration-reader.spec.ts @@ -7,10 +7,6 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest' import { z } from 'zod' declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - 'test/reader': number - 'test/unrelated-reader': number - } interface SessionProjectionMap { 'test/reader': number } diff --git a/packages/session/session-projection/tests/registration.type-test.ts b/packages/session/session-projection/tests/registration.type-test.ts new file mode 100644 index 0000000000..dc9cadce3b --- /dev/null +++ b/packages/session/session-projection/tests/registration.type-test.ts @@ -0,0 +1,68 @@ +/** Compile-time registration checks for local state inference and client view declarations. */ +import { expectTypeOf } from 'vitest' +import { z } from 'zod' +import type { Session } from '@deepseek-ai/dsh-session' +import type { ProjectionDefinition, ProjectionHandle, SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection' + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + 'type-test/label': { label: string } + } +} + +const counter = { + key: 'type-test/counter', + stateSchema: z.number(), + stateVersion: 1, + init: () => 0, + apply: state => state + 1, +} satisfies ProjectionDefinition<'type-test/counter', number> + +/** + * Check inference and invalid registrations without executing them. + * @param registry - public projection registry. + * @param session - Session used to typecheck retained reads. + * @returns after the typechecked registration calls. + */ +export async function checkProjectionRegistrationTypes(registry: SessionProjectionRegistry, session: Session): Promise { + const host = await registry.register(counter) + expectTypeOf(host).toEqualTypeOf>() + expectTypeOf(host.stateOf(session)).toEqualTypeOf() + + const nullable = await registry.register({ + key: 'type-test/nullable', + stateSchema: z.string().nullable(), + stateVersion: 1, + init: () => null, + apply: (state) => { + expectTypeOf(state).toEqualTypeOf() + return state + }, + }) + expectTypeOf(nullable.stateOf(session)).toEqualTypeOf() + + const visible = { + ...counter, + key: 'type-test/label' as const, + wire: { + viewSchema: z.object({ label: z.string() }), + view: (state: number) => ({ label: String(state) }), + }, + } + const client = await registry.register(visible) + expectTypeOf(client.stateOf(session)).toEqualTypeOf() + + // @ts-expect-error -- initial state must match the state schema. + void registry.register({ ...counter, init: () => 'wrong' }) + // @ts-expect-error -- fold results must match the state schema. + void registry.register({ ...counter, apply: () => 'wrong' }) + // @ts-expect-error -- a declared client key requires its view. + void registry.register({ ...counter, key: 'type-test/label' }) + + const wrongView = { ...visible, wire: { ...visible.wire, view: (state: number) => ({ label: state }) } } + // @ts-expect-error -- the view must match the client key's declared value. + void registry.register(wrongView) + const unknownView = { ...visible, key: 'type-test/undeclared-view' as const } + // @ts-expect-error -- client-visible keys must be declared in the client map. + void registry.register(unknownView) +} diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index 189bcf96f2..589c44fc9f 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -22,13 +22,6 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - 'test/marks': MarksState - 'test/count': number - 'test/stable-view': StableViewState - 'test/cut': number - } - interface SessionProjectionMap { 'test/marks': { marks: string[] } 'test/stable-view': { marks: string[] } diff --git a/packages/session/session-stats/src/projection.ts b/packages/session/session-stats/src/projection.ts index e7d3f32377..c3560a8aca 100644 --- a/packages/session/session-stats/src/projection.ts +++ b/packages/session/session-stats/src/projection.ts @@ -63,12 +63,6 @@ interface SessionStatsState extends SessionStatsTotals { pendingCalls: Record } -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - sessionStats: SessionStatsState - } -} - const sessionStatsSchema = z.object({ turns: z.number().int().nonnegative(), steps: z.number().int().nonnegative(), diff --git a/packages/session/session-title/src/types.ts b/packages/session/session-title/src/types.ts index 20855445a5..be3c4aa7db 100644 --- a/packages/session/session-title/src/types.ts +++ b/packages/session/session-title/src/types.ts @@ -77,12 +77,6 @@ export interface TitleInputState { } declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Latest logged title text, or null. */ - title: string | null - /** Eligible human title input. */ - titleInput: TitleInputState - } interface SessionProjectionMap { /** * The session's current normalized title — the latest `session/title` diff --git a/packages/session/session-turn-outline/src/types.ts b/packages/session/session-turn-outline/src/types.ts index c4ccbcdab3..fac481681b 100644 --- a/packages/session/session-turn-outline/src/types.ts +++ b/packages/session/session-turn-outline/src/types.ts @@ -38,10 +38,6 @@ export interface TurnOutlineState { } declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Whole-log turn outline fold state (entries plus the open turn's response draft). */ - turnOutline: TurnOutlineState - } interface SessionProjectionMap { /** Every started turn with its `turn/start` seq and bounded previews, strictly increasing by turn; see {@link TurnOutlineEntry}. */ turnOutline: readonly TurnOutlineEntry[] diff --git a/packages/session/working-directory/src/index.ts b/packages/session/working-directory/src/index.ts index 61ca035d70..8664a8aca1 100644 --- a/packages/session/working-directory/src/index.ts +++ b/packages/session/working-directory/src/index.ts @@ -45,11 +45,6 @@ declare module '@deepseek-ai/dsh-session-projection/types' { /** Recorded current directory for Session observations, or null when none is recorded. */ workingDirectory: string | null } - - interface SessionProjectionStateMap { - /** Last committed working directory, or null before an explicit value exists. */ - workingDirectory: string | null - } } /** Deployment default for Sessions without an original project directory. */ diff --git a/packages/subagent/subagent/src/catalog.ts b/packages/subagent/subagent/src/catalog.ts index d640c8f6b8..416625c594 100644 --- a/packages/subagent/subagent/src/catalog.ts +++ b/packages/subagent/subagent/src/catalog.ts @@ -97,12 +97,6 @@ const stateSchema: z.ZodType = z.object({ head: chunkedListSchema(eventDataSchema).optional(), }).strict() -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - subagentCatalog: SubagentCatalogState - } -} - /** * Materialize complete and unknown-mode child identities from parent catalog events. * @param state - parent catalog fold state. diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts index 541721f2c1..bc283d911b 100644 --- a/packages/subagent/subagent/src/projection.ts +++ b/packages/subagent/subagent/src/projection.ts @@ -50,13 +50,6 @@ const timingStateSchema: z.ZodType = z.object({ lastTurnCompleted: z.boolean().optional(), }).strict() -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - subagentTiming: TimingState - subagent: IdentityState - } -} - /** * Fold turn boundaries around the child's own durable descriptor. * diff --git a/packages/subagent/tool-subagent/src/model-selection-state.ts b/packages/subagent/tool-subagent/src/model-selection-state.ts index 92cdac56bb..ff09b7248a 100644 --- a/packages/subagent/tool-subagent/src/model-selection-state.ts +++ b/packages/subagent/tool-subagent/src/model-selection-state.ts @@ -21,13 +21,6 @@ declare module '@deepseek-ai/dsh-session/types' { } } -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Exact routes authorized for child LLM selection, or null when disabled. */ - subagentModelSelectionPolicy: AllowedModelRoute[] | null - } -} - const modelSelectionPolicySchema: zod.ZodType = zod.array(zod.object({ provider: zod.string().min(1), model: zod.string().min(1), diff --git a/packages/todo/tool-todo/src/types.ts b/packages/todo/tool-todo/src/types.ts index 68d5649bb0..efa4d070d3 100644 --- a/packages/todo/tool-todo/src/types.ts +++ b/packages/todo/tool-todo/src/types.ts @@ -33,9 +33,6 @@ declare module '@deepseek-ai/dsh-session/types' { } declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - todos: TodoItem[] | null - } interface SessionProjectionMap { /** * The agent's current whole todo list (the latest `todo/write` snapshot), diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 4763950a68..8c0ac6f5d0 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -825,7 +825,6 @@ export const LINK_MAP: Readonly> = { ProjectionDefinition: 'session-projection.md', ProjectionHandle: 'session-projection.md', SessionProjectionMap: 'session-projection.md', - SessionProjectionStateMap: 'session-projection.md', ProjectionChangeListener: 'session-projection.md', ProjectionSnapshot: 'session-projection.md', ProjectionCheckpoint: 'session-projection.md', From 043071b8743078b19daea88fabf64af3a580bd50 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 11:34:36 +0800 Subject: [PATCH 24/37] test(cli): read subagent policy through a retained handle --- apps/cli/tests/web-agent-presets.e2e.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index a292bf7abf..9bc37e78b2 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -1,4 +1,5 @@ import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' +import { subagentModelSelectionProjectionDefinition } from '@deepseek-ai/dsh-tool-subagent/src/model-selection-state.ts' import { createRequire } from 'node:module' import { randomUUID } from 'node:crypto' import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises' @@ -328,13 +329,14 @@ describe('the shipped Web composition', () => { sessionId: SessionId('preset-model-selection-enabled'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), }) + const policy = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) try { expect(toolNames(ctx, disabled.agent)).toContain('list_subagent_models') expect(toolParameterNames(ctx, disabled.agent, 'subagent')).toEqual(expect.arrayContaining([ 'model', 'provider', 'reasoning_effort', ])) - expect(ctx.sessionProjections.stateOf(disabled.agent.session, 'subagentModelSelectionPolicy')).toBeNull() - expect(ctx.sessionProjections.stateOf(enabled.agent.session, 'subagentModelSelectionPolicy')).toEqual([ + expect(policy.stateOf(disabled.agent.session)).toBeNull() + expect(policy.stateOf(enabled.agent.session)).toEqual([ { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, ]) expect(toolNames(ctx, enabled.agent)).toContain('list_subagent_models') @@ -345,6 +347,7 @@ describe('the shipped Web composition', () => { expect(toolParameterNames(ctx, agent, 'subagent_fork')).toEqual(['cwd', 'description', 'prompt']) } } finally { + await policy.dispose() await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) await enabled.dispose() await disabled.dispose() From f62182e577b7a9de9eed9f023910131f4ec5fd61 Mon Sep 17 00:00:00 2001 From: luogesi Date: Fri, 16 Oct 2026 15:46:15 +0800 Subject: [PATCH 25/37] fix(sdk-minimal): checkpoint sessions before dispatch --- .../profiles/sdk/checkpoints.expected.e2e.ts | 71 +++++++++++++++++++ .../sdk/fixtures/checkpoint-failure.mjs | 13 ++++ .../sdk-minimal-checkpoints/guide.i18n.yaml | 16 +++++ .../sdk-minimal-checkpoints/guide.md | 18 +++++ .../sdk-minimal-checkpoints/guide.zh.md | 18 +++++ packages/bundle/sdk-minimal/README.i18n.yaml | 4 +- packages/bundle/sdk-minimal/README.md | 2 + packages/bundle/sdk-minimal/README.zh.md | 2 + packages/bundle/sdk-minimal/cordis.patch.yml | 3 + packages/bundle/sdk-minimal/package.json | 1 + .../sdk-minimal/tests/sdk-minimal.spec.ts | 1 + pnpm-lock.yaml | 3 + python/development.i18n.yaml | 4 +- python/development.md | 8 +++ python/development.zh.md | 8 +++ python/sdk/tests/manual_sdk_shutdown_smoke.py | 36 ++++++++++ .../checkpoint-observer.mjs | 26 +++++++ .../sdk/minimal-image-compaction/cordis.yml | 4 ++ snapshots/sdk/sdk.snapshot.ts | 2 + 19 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 apps/cli/tests/profiles/sdk/checkpoints.expected.e2e.ts create mode 100644 apps/cli/tests/profiles/sdk/fixtures/checkpoint-failure.mjs create mode 100644 docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.md create mode 100644 docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.zh.md create mode 100644 python/sdk/tests/manual_sdk_shutdown_smoke.py create mode 100644 snapshots/sdk/minimal-image-compaction/checkpoint-observer.mjs diff --git a/apps/cli/tests/profiles/sdk/checkpoints.expected.e2e.ts b/apps/cli/tests/profiles/sdk/checkpoints.expected.e2e.ts new file mode 100644 index 0000000000..69538856e9 --- /dev/null +++ b/apps/cli/tests/profiles/sdk/checkpoints.expected.e2e.ts @@ -0,0 +1,71 @@ +/** The shipped minimal profile must stop model dispatch when a durable checkpoint fails. */ +import { createServer } from 'node:http' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' +import { describe, expect, it, onTestFinished } from 'vitest' + +const dshBin = fileURLToPath(new URL('../../../lib/bin.js', import.meta.url)) +const fixture = fileURLToPath(new URL('./fixtures/checkpoint-failure.mjs', import.meta.url)) + +describe('sdk-minimal durability checkpoints', () => { + it.each([false, true])('checkpoint storage failure with policy disabled=%s', async (disabled) => { + const root = await mkdtemp(join(tmpdir(), 'dsh-sdk-checkpoint-')) + onTestFinished(async () => { await rm(root, { recursive: true, force: true }) }) + let modelRequests = 0 + const server = createServer((request, response) => { + request.resume() + request.on('end', () => { + modelRequests += 1 + const events = [ + { type: 'message_start', message: { id: 'checkpoint-response', model: 'deepseek-v4-flash', usage: { input_tokens: 3, output_tokens: 0 } } }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: 'OK' } }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } }, + { type: 'message_stop' }, + ] + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('')) + }) + }) + onTestFinished(async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error) + else resolve() + }) + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('model server has no TCP address') + const patch = join(root, 'checkpoint.patch.yml') + await writeFile(patch, JSON.stringify([ + { id: 'session-log-deepseek', config: { enabled: false } }, + { insert: [{ id: 'checkpoint-failure', name: fixture }] }, + ...disabled ? [{ id: 'session-checkpoint-policy', disabled: true }] : [], + ])) + const harness = new DeepSeekHarness({ + dshBin, profile: 'sdk-minimal', dshHome: join(root, 'home'), + cwd: root, processCwd: root, patches: [patch], + env: { ...process.env, DEEPSEEK_API_KEY: 'keyless-checkpoint-test', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}` }, + }) + onTestFinished(async () => { await harness.close() }) + const result = await harness.run('Reply OK without calling tools.') + expect(modelRequests).toBe(disabled ? 1 : 0) + const terminal = result.events.findLast(event => event.type === 'turn/end') + expect(terminal).toMatchObject({ data: { reason: { kind: disabled ? 'completed' : 'error' } } }) + if (disabled) { + expect(result.finalResponse).toBe('OK') + } else { + expect(terminal).toMatchObject({ + data: { reason: { kind: 'error', error: { message: 'sdk checkpoint persistence unavailable' } } }, + }) + } + }) +}) diff --git a/apps/cli/tests/profiles/sdk/fixtures/checkpoint-failure.mjs b/apps/cli/tests/profiles/sdk/fixtures/checkpoint-failure.mjs new file mode 100644 index 0000000000..7a37a58215 --- /dev/null +++ b/apps/cli/tests/profiles/sdk/fixtures/checkpoint-failure.mjs @@ -0,0 +1,13 @@ +/** Reject durable checkpoints without changing background JSONL writes. */ +export const name = 'sdk-checkpoint-failure' +export const inject = ['sessionPersistence'] + +/** + * Install a storage-failure signal at the public session flush event. + * @param ctx - Plugin context owning the flush listener. + */ +export function apply(ctx) { + ctx.on('session/flush', () => { + throw new Error('sdk checkpoint persistence unavailable') + }) +} diff --git a/docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.i18n.yaml new file mode 100644 index 0000000000..ce4efec8e4 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.md +/: + en: c81d35a7d1605742 + zh: 49a149c848fdbb35 +/sdk-minimal-requires-durable-checkpoints: + en: 353b6975265e5e07 + zh: b91ad5afa831b810 +/sdk-minimal-requires-durable-checkpoints/change: + en: 3ba980ee74955e34 + zh: 495fe6c0dd9001bf +/sdk-minimal-requires-durable-checkpoints/migration: + en: 43c3d55a0fa7a562 + zh: f2b087fb1387b517 diff --git a/docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.md b/docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.md new file mode 100644 index 0000000000..f842d62766 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "The sdk-minimal profile now stops model and tool dispatch when session persistence cannot complete a checkpoint." +--- + +# SDK-minimal requires durable checkpoints + +English | [中文](guide.zh.md) + +## Change + +The `sdk-minimal` profile enables `session-checkpoint-policy`. It waits for session history to flush before model requests, top-level tool execution, and agent steps. Previously these operations could proceed while JSONL writes were buffered; persistence errors could remain background warnings. A checkpoint failure now stops the dependent operation. First-use file-lock initialization is paid before the first model request, and checkpoint writes contribute to request latency. This does not change the Session format or guarantee that forced termination preserves the final unflushed response. + +## Migration + +1. Keep the selected `dsh_home` writable and allow request deadlines to cover storage initialization and checkpoint writes. A longer shutdown timeout alone does not affect a pre-request checkpoint. +2. Check profile and invocation patches for overrides of `session-checkpoint-policy`. To receive the durability policy, retain its enabled default alongside `sessions`. +3. Run a short SDK turn, close normally, and use `resume_session()` with the same home and session ID to verify restoration. Investigate checkpoint failures before retrying model or tool work; the operation does not run until the checkpoint succeeds. diff --git a/docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.zh.md new file mode 100644 index 0000000000..c55ade454a --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "sdk-minimal profile 现在会在会话持久化无法完成检查点时停止模型和工具调度。" +--- + +# SDK-minimal 要求持久化检查点 + +[English](guide.md) | 中文 + +## 变更 + +`sdk-minimal` profile 启用 `session-checkpoint-policy`,在模型请求、顶层工具执行和 agent step 之前等待会话历史刷盘。此前,JSONL 写入尚在缓冲时这些操作也可以继续,持久化错误可能只记录为后台警告。现在,检查点失败会停止依赖它的操作。首次文件锁初始化发生在第一次模型请求之前,检查点写入也计入请求延迟。此变更不修改 Session 格式,也不保证强制终止时保留最后尚未刷盘的回复。 + +## 迁移 + +1. 确保选定的 `dsh_home` 可写,并让请求超时覆盖存储初始化和检查点写入。仅延长关闭超时不会影响请求前的检查点。 +2. 检查 profile 和调用级 patch 是否覆盖了 `session-checkpoint-policy`。要使用此持久化策略,应在 `sessions` 旁保留其默认启用状态。 +3. 运行一轮 SDK 短对话,正常关闭,再使用相同 home 和会话 ID 调用 `resume_session()` 验证恢复。重试模型或工具操作前,先排查检查点失败;只有检查点成功后,操作才会执行。 diff --git a/packages/bundle/sdk-minimal/README.i18n.yaml b/packages/bundle/sdk-minimal/README.i18n.yaml index b93af7197d..62cee1bca3 100644 --- a/packages/bundle/sdk-minimal/README.i18n.yaml +++ b/packages/bundle/sdk-minimal/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-sdk-minimal/use-this-package: - en: 39483eef4a61f212 - zh: 62d82f46a04e07fe + en: 44072e053b253924 + zh: fecb1b7986ac05d4 /deepseek-ai-dsh-sdk-minimal/understand-the-implementation: en: 5db5d8f041867958 zh: bff5ef3a2e140712 diff --git a/packages/bundle/sdk-minimal/README.md b/packages/bundle/sdk-minimal/README.md index 9accd1fec2..db70633cd0 100644 --- a/packages/bundle/sdk-minimal/README.md +++ b/packages/bundle/sdk-minimal/README.md @@ -38,6 +38,8 @@ Use `dsh plugin --profile sdk-minimal` to manage persistent external dependencie The profile mounts exactly one persistent shell stack: Bash on Linux and macOS, or PowerShell on Windows. Both stacks use a 300-second timeout and one owner-scoped terminal; the other platform's rows remain disabled. +[Session checkpoints](../../session/session-checkpoint-policy/README.md) flush history before each model request, top-level tool execution, and agent step. Dispatch waits for the flush and stops if persistence fails, so the first model request also waits for native file-lock initialization. The final response still depends on background writes or successful shutdown; checkpoints do not make forced termination safe for unflushed events. + Like every shipped profile, it mounts [MCP resources](../../mcp/mcp-resources/README.md) once. Configure only [MCP client entries](../../mcp/mcp-client/README.md) to add servers. Clients mounted by another provider also count as configured. With no server configured in the caller's scope, MCP contributes no prompt text or tools and the default remains the shell, `read_image`, and `working_directory` tools. ----- diff --git a/packages/bundle/sdk-minimal/README.zh.md b/packages/bundle/sdk-minimal/README.zh.md index d35655df20..dbaef50cab 100644 --- a/packages/bundle/sdk-minimal/README.zh.md +++ b/packages/bundle/sdk-minimal/README.zh.md @@ -38,6 +38,8 @@ dsh --profile sdk-minimal 该 profile 只挂载一套持久 shell:Linux 和 macOS 使用 Bash,Windows 使用 PowerShell。两套配置都使用 300 秒超时与一个 agent 自有终端;另一平台的配置项保持禁用。 +[Session 检查点](../../session/session-checkpoint-policy/README.zh.md)会在每次模型请求、顶层工具执行和 agent step 之前刷盘。调度会等待刷盘完成,持久化失败时停止,因此第一次模型请求也会等待原生文件锁初始化。最后一段回复仍依赖后台写入或成功关闭来保存;检查点不能保证强制终止时尚未刷盘的事件不丢失。 + 与其他随附 profile 一样,它统一挂载 [MCP 资源](../../mcp/mcp-resources/README.zh.md)一次。只需配置 [MCP 客户端条目](../../mcp/mcp-client/README.zh.md)即可添加服务器。其他提供方挂载的客户端也属于已配置状态。调用方作用域中未配置服务器时,MCP 不贡献提示词文本或工具,默认仍为 shell、`read_image` 与 `working_directory` 三个工具。 ----- diff --git a/packages/bundle/sdk-minimal/cordis.patch.yml b/packages/bundle/sdk-minimal/cordis.patch.yml index 79922e7113..10b1fa0b5a 100644 --- a/packages/bundle/sdk-minimal/cordis.patch.yml +++ b/packages/bundle/sdk-minimal/cordis.patch.yml @@ -170,3 +170,6 @@ config: root: !!js dshHomePath('sessions') compression: none + + - id: session-checkpoint-policy + name: '@deepseek-ai/dsh-session-checkpoint-policy' diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index d53a316474..f5a82a3662 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -52,6 +52,7 @@ "@deepseek-ai/dsh-sdk-app": "workspace:*", "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", "@deepseek-ai/dsh-session-log-deepseek": "workspace:*", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", diff --git a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts index f4e24e5bc5..ce122661ae 100644 --- a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts +++ b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts @@ -61,6 +61,7 @@ describe('dsh-sdk-minimal bundle', () => { ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'], ['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'], ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'], + ['session-checkpoint-policy', '@deepseek-ai/dsh-session-checkpoint-policy'], ]) expect(rows.find(row => row.id === 'sdk-app-startup')?.config).toEqual({ profile: 'sdk-minimal' }) expect(rows.find(row => row.id === 'sdk-jsonrpc-server')).toMatchObject({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da43e12301..450fd64111 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2652,6 +2652,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session + '@deepseek-ai/dsh-session-checkpoint-policy': + specifier: workspace:* + version: link:../../session/session-checkpoint-policy '@deepseek-ai/dsh-session-log-deepseek': specifier: workspace:* version: link:../../session/session-log-deepseek diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index a48b0431d8..87593796ff 100644 --- a/python/development.i18n.yaml +++ b/python/development.i18n.yaml @@ -12,8 +12,8 @@ en: 37884fe76e167a0e zh: 4b6e50768c4bb945 /python-contributor-workflows/run-against-node-source: - en: 7430d9e6b3d02ddc - zh: 9044e6be91d0ad01 + en: 940c941d189a8248 + zh: 321f5183383d4a12 /python-contributor-workflows/build-distributions: en: 4aa941562dba5482 zh: 50543a92b3d0cf04 diff --git a/python/development.md b/python/development.md index b1e0f62b40..ed12f6994d 100644 --- a/python/development.md +++ b/python/development.md @@ -66,6 +66,14 @@ Repository contributors can select either development route; both execute the no `python/sdk/tests/manual_sdk_agent_smoke.py` uses the internal `_launch_args` test adapter to exercise the unbuilt TypeScript CLI under tsx. Arbitrary argv replacement is intentionally absent from the public SDK. +The manual [shutdown example](sdk/tests/manual_sdk_shutdown_smoke.py) uses ordinary SDK calls: run one short real-model turn, close immediately with the default 1-second budget, then open another runtime and restore the same session. Install the SDK/runtime and set `DEEPSEEK_API_KEY` before running: + +```sh +python python/sdk/tests/manual_sdk_shutdown_smoke.py +``` + +The example prints the close duration and history files, retains its temporary directory, and lets restoration failures raise normally. Recovery uses a separate 30-second shutdown budget. Reproduction depends on the host and first-turn timing; successful restoration does not establish a shutdown latency bound. This manual example is not collected by pytest. + ## Build distributions The root `package.json` version is authoritative for both Python distributions. The staging script injects that version into both wheels and pins the SDK to the same `deepseek-harness-runtime-bin` version. diff --git a/python/development.zh.md b/python/development.zh.md index dae84b9eb1..d37cba5cda 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -66,6 +66,14 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/test-dsh-home") as harness: `python/sdk/tests/manual_sdk_agent_smoke.py` 使用内部 `_launch_args` 测试适配器,通过 tsx 验证未构建的 TypeScript CLI。公开 SDK 刻意不提供任意 argv 替换。 +手动[关闭示例](sdk/tests/manual_sdk_shutdown_smoke.py)只使用普通 SDK 调用:运行一轮真实模型短对话,以默认 1 秒预算立即关闭,再启动另一个运行时恢复同一会话。安装 SDK/运行时并设置 `DEEPSEEK_API_KEY` 后运行: + +```sh +python python/sdk/tests/manual_sdk_shutdown_smoke.py +``` + +示例打印关闭耗时和历史文件列表,保留临时目录,并让恢复失败正常抛出异常。恢复进程使用独立的 30 秒关闭预算。能否复现取决于主机和首轮耗时,恢复成功不能确定关闭耗时上限。pytest 不收集此手动示例。 + ## 构建分发包 根目录 `package.json` 的版本是两个 Python 分发包的权威版本。暂存脚本会将该版本注入两个 wheel 包,并将 SDK 固定到同版本的 `deepseek-harness-runtime-bin`。 diff --git a/python/sdk/tests/manual_sdk_shutdown_smoke.py b/python/sdk/tests/manual_sdk_shutdown_smoke.py new file mode 100644 index 0000000000..2b3044532a --- /dev/null +++ b/python/sdk/tests/manual_sdk_shutdown_smoke.py @@ -0,0 +1,36 @@ +"""Run a short SDK session, close immediately, and try restoring its history. + +Requires an installed SDK/runtime and DEEPSEEK_API_KEY. Run this file directly. +The temporary Harness home is retained for inspection. +""" + +from pathlib import Path +from tempfile import mkdtemp +from time import monotonic + +from deepseek_harness import DeepSeekHarness, DeepSeekHarnessConfig + +root = Path(mkdtemp(prefix="dsh-shutdown-")) +config = DeepSeekHarnessConfig( + profile="sdk-minimal", + dsh_home=str(root / "home"), + cwd=str(root), + shutdown_timeout_seconds=1.0, +) +print("Test directory:", root) + +with DeepSeekHarness(config) as harness: + result = harness.run("只回复 OK,不要调用工具。") + started = monotonic() + +print(f"close() took {monotonic() - started:.3f}s") +print("Reply:", result.final_response) +if result.finish_reason != "completed": + raise RuntimeError(f"Model turn failed ({result.finish_reason}); check credentials and the session log.") +print("History files:", list((root / "home" / "sessions").rglob("*.jsonl"))) + +# Recovery gets a separate budget so its own shutdown does not repeat the test. +config.shutdown_timeout_seconds = 30.0 +with DeepSeekHarness(config) as harness: + harness.resume_session(result.session_id) + print("History restored successfully:", result.session_id) diff --git a/snapshots/sdk/minimal-image-compaction/checkpoint-observer.mjs b/snapshots/sdk/minimal-image-compaction/checkpoint-observer.mjs new file mode 100644 index 0000000000..4cba283cf2 --- /dev/null +++ b/snapshots/sdk/minimal-image-compaction/checkpoint-observer.mjs @@ -0,0 +1,26 @@ +/** Require a live-session checkpoint before the recorded minimal-profile model requests. */ +export const name = 'minimal-checkpoint-observer' +export const inject = ['llm', 'sessions', 'sessionPersistence'] + +/** + * Observe checkpoints without flushing or altering the recorded model transcript. + * @param ctx - Plugin context owning the checkpoint and model listeners. + */ +export function apply(ctx) { + const checkpointed = new WeakSet() + ctx.on('session/flush', session => { checkpointed.add(session) }) + ctx.on('llm/stream', async function* (options, next) { + const session = options.sessionId === undefined ? undefined : ctx.sessions.get(options.sessionId) + if (session !== undefined) { + if (!checkpointed.has(session)) throw new Error('sdk-minimal dispatched without a session checkpoint') + const handle = await ctx.sessionPersistence.open(session.id, 'read') + try { + const { events } = await handle.read() + if (events.length === 0) throw new Error('sdk-minimal dispatched before its history was persisted') + } finally { + await handle.close() + } + } + yield* next() + }) +} diff --git a/snapshots/sdk/minimal-image-compaction/cordis.yml b/snapshots/sdk/minimal-image-compaction/cordis.yml index 8e0759adae..388cc9c158 100644 --- a/snapshots/sdk/minimal-image-compaction/cordis.yml +++ b/snapshots/sdk/minimal-image-compaction/cordis.yml @@ -2,3 +2,7 @@ - id: session-log-deepseek config: enabled: false + +- insert: + - id: minimal-checkpoint-observer + name: './checkpoint-observer.mjs' diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index 233e4915e5..5b30ddf9d4 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -952,6 +952,8 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { const actualContext = contextOf(ordered, cwd) if (scenario.name === 'minimal-image-compaction') { const events = results.flatMap(result => result.events) + const failures = events.filter(event => event.type === 'turn/end' && event.data.reason.kind === 'error') + expect(failures, 'minimal profile completes its checkpointed turn').toEqual([]) const image = events.find(event => event.type === 'tool/result') expect(image).toMatchObject({ data: { message: { toolCallId: 'minimal-image-read', From 96f4db3f7d1a55b8c4a03d579534b9f9aefa43ed Mon Sep 17 00:00:00 2001 From: luogesi Date: Fri, 16 Oct 2026 16:39:22 +0800 Subject: [PATCH 26/37] test(sdk): align built profile and snapshot dependencies --- apps/cli/tests/built-bin.e2e.ts | 1 + package.json | 1 + pnpm-lock.yaml | 3 +++ 3 files changed, 5 insertions(+) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 5174026bc4..cd8b5aad9c 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1276,6 +1276,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'], ['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'], ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'], + ['session-checkpoint-policy', '@deepseek-ai/dsh-session-checkpoint-policy'], ]) expect(stdout).toContain('# == @deepseek-ai/dsh-sdk-minimal') expect(stdout).not.toContain('@deepseek-ai/dsh-base') diff --git a/package.json b/package.json index 39c7f94f95..3053d646eb 100644 --- a/package.json +++ b/package.json @@ -235,6 +235,7 @@ "@deepseek-ai/dsh-cua": "workspace:*", "@deepseek-ai/dsh-experimental-claude-code-mods": "workspace:*", "@deepseek-ai/dsh-llm-replay": "workspace:*", + "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", "@deepseek-ai/dsh-mcp-client": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*", "@deepseek-ai/dsh-web-fetch-http": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 450fd64111..60e6995b1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: '@deepseek-ai/dsh-skill-office': specifier: workspace:* version: link:packages/skill/skill-office + '@deepseek-ai/dsh-subagent-dsh-sdk': + specifier: workspace:* + version: link:packages/subagent/subagent-dsh-sdk '@deepseek-ai/dsh-tool-ralph': specifier: workspace:* version: link:packages/experimental/tool-ralph From 1ddba8b3b01145939445d2cc171b238d70e14c68 Mon Sep 17 00:00:00 2001 From: luogesi Date: Mon, 19 Oct 2026 11:36:03 +0800 Subject: [PATCH 27/37] fix(sdk-minimal): align review fixtures and upgrade guide --- apps/cli/tests/profiles/sdk/checkpoints.expected.e2e.ts | 3 ++- .../sdk-minimal-checkpoints/guide.i18n.yaml | 6 +++--- .../sdk-minimal-checkpoints/guide.md | 0 .../sdk-minimal-checkpoints/guide.zh.md | 0 4 files changed, 5 insertions(+), 4 deletions(-) rename docs/upgrade-guide/{v0.2.1-alpha.4 => v0.2.1-rc.2}/sdk-minimal-checkpoints/guide.i18n.yaml (86%) rename docs/upgrade-guide/{v0.2.1-alpha.4 => v0.2.1-rc.2}/sdk-minimal-checkpoints/guide.md (100%) rename docs/upgrade-guide/{v0.2.1-alpha.4 => v0.2.1-rc.2}/sdk-minimal-checkpoints/guide.zh.md (100%) diff --git a/apps/cli/tests/profiles/sdk/checkpoints.expected.e2e.ts b/apps/cli/tests/profiles/sdk/checkpoints.expected.e2e.ts index 69538856e9..8f486aa25d 100644 --- a/apps/cli/tests/profiles/sdk/checkpoints.expected.e2e.ts +++ b/apps/cli/tests/profiles/sdk/checkpoints.expected.e2e.ts @@ -1,5 +1,6 @@ /** The shipped minimal profile must stop model dispatch when a durable checkpoint fails. */ import { createServer } from 'node:http' +import { existsSync } from 'node:fs' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -10,7 +11,7 @@ import { describe, expect, it, onTestFinished } from 'vitest' const dshBin = fileURLToPath(new URL('../../../lib/bin.js', import.meta.url)) const fixture = fileURLToPath(new URL('./fixtures/checkpoint-failure.mjs', import.meta.url)) -describe('sdk-minimal durability checkpoints', () => { +describe.skipIf(!existsSync(dshBin))('sdk-minimal durability checkpoints', () => { it.each([false, true])('checkpoint storage failure with policy disabled=%s', async (disabled) => { const root = await mkdtemp(join(tmpdir(), 'dsh-sdk-checkpoint-')) onTestFinished(async () => { await rm(root, { recursive: true, force: true }) }) diff --git a/docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/sdk-minimal-checkpoints/guide.i18n.yaml similarity index 86% rename from docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.i18n.yaml rename to docs/upgrade-guide/v0.2.1-rc.2/sdk-minimal-checkpoints/guide.i18n.yaml index ce4efec8e4..3f4590b5d7 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-rc.2/sdk-minimal-checkpoints/guide.i18n.yaml @@ -1,13 +1,13 @@ # Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading # section, a hash of its English and Chinese blocks outside code blocks and generated regions. # After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.md +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/sdk-minimal-checkpoints/guide.md /: en: c81d35a7d1605742 zh: 49a149c848fdbb35 /sdk-minimal-requires-durable-checkpoints: - en: 353b6975265e5e07 - zh: b91ad5afa831b810 + en: a238e25e459a20cd + zh: 871ee52936d06b24 /sdk-minimal-requires-durable-checkpoints/change: en: 3ba980ee74955e34 zh: 495fe6c0dd9001bf diff --git a/docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/sdk-minimal-checkpoints/guide.md similarity index 100% rename from docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.md rename to docs/upgrade-guide/v0.2.1-rc.2/sdk-minimal-checkpoints/guide.md diff --git a/docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/sdk-minimal-checkpoints/guide.zh.md similarity index 100% rename from docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.zh.md rename to docs/upgrade-guide/v0.2.1-rc.2/sdk-minimal-checkpoints/guide.zh.md From 679ec80a14037ccebd36e95fa8f03161016bdde3 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 12:24:35 +0800 Subject: [PATCH 28/37] test(web): mount projections for search initiator fixtures --- packages/web/web-deepseek-api/package.json | 1 + packages/web/web-deepseek-api/tests/search.spec.ts | 3 +++ pnpm-lock.yaml | 3 +++ 3 files changed, 7 insertions(+) diff --git a/packages/web/web-deepseek-api/package.json b/packages/web/web-deepseek-api/package.json index 2e94db4578..5aa5410945 100644 --- a/packages/web/web-deepseek-api/package.json +++ b/packages/web/web-deepseek-api/package.json @@ -47,6 +47,7 @@ "@deepseek-ai/dsh-deepseek-account": "workspace:*", "@deepseek-ai/dsh-launch-environment": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-web": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-http-proxy": "workspace:*" diff --git a/packages/web/web-deepseek-api/tests/search.spec.ts b/packages/web/web-deepseek-api/tests/search.spec.ts index 942d252457..cb6c72e868 100644 --- a/packages/web/web-deepseek-api/tests/search.spec.ts +++ b/packages/web/web-deepseek-api/tests/search.spec.ts @@ -6,6 +6,7 @@ import type { Session } from '@deepseek-ai/dsh-session' import WebRuntime from '@deepseek-ai/dsh-web' import type { DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { DeepSeekSearchApiProvider, DEEPSEEK_WEB_API_BASE_URL, @@ -158,6 +159,7 @@ describe('Search API triggering question', () => { vi.stubGlobal('fetch', fetchMock) const ctx = new Context() try { + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(WebRuntime, { searchProvider: 'deepseek-search-api' }) await ctx.plugin(webApiPlugin, { apiKey: 'ds-key', ...config }) @@ -239,6 +241,7 @@ describe('Search API account route', () => { const ctx = new Context() try { await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(WebRuntime, { searchProvider: 'deepseek-search-api' }) ctx.provide('deepseekAccount', { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc27e06e20..b39473bb75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15343,6 +15343,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:* + version: link:../../session/session-projection '@deepseek-ai/dsh-web': specifier: workspace:* version: link:../web From b361989a459da1056525d3ce5547615e22cd5bd0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 12:46:23 +0800 Subject: [PATCH 29/37] test(benchmarks): balance user-text scaling sample volumes --- ...11-user-text-credential-matching.i18n.yaml | 8 ++--- ...026-10-11-user-text-credential-matching.md | 3 +- ...-10-11-user-text-credential-matching.zh.md | 3 +- .../user-text/user-text.worker.client.ts | 29 ++++++++++++------- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml index c73869d5ff..4d929aec54 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml @@ -21,11 +21,11 @@ en: f07bb6fbbdd01fe4 zh: 9313a0ef2a22af12 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate: - en: bfa4b063696492a5 - zh: 3cee9b6087dbee91 + en: fcc52be9892b2bfc + zh: ac259aba407efcbc /agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered: - en: 7600c1671b5dc73c - zh: b38e3ae76a5a32d1 + en: 168bda1a4e9e0325 + zh: af9cf7b804db84b7 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences: en: 8f9d25b72d3e7660 zh: e5c6ae0194905876 diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md index abf01cca55..e4373eb7dc 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md @@ -43,7 +43,7 @@ English | [中文](2026-10-11-user-text-credential-matching.zh.md) | Item | Content | |---|---| | Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time | -| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations; a 5,000-message sample evaluates the batch once. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo | +| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo | | Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval | | Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character | | Excluded | Cold code compilation, React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report | @@ -54,6 +54,7 @@ The reference constants are calibrated for minima of five single evaluations. Mi ## Alternatives considered +- **Equal evaluation counts at both scaling sizes**: the larger input allocates twice as much per sample, so its fastest sample can include a collection while the smaller input's fastest sample includes none. Equal total input per sample makes the per-evaluation means compare similar allocation volumes without excluding GC or changing the doubling limit. - **Unwarmed, separate baseline and matcher batches**: sub-millisecond baseline measurements can reach optimized code before the matcher’s newly exercised paths do, and separate batches amplify clock-speed drift. Per-workload warm-up, adjacent pairs, and fixed batches compare warmed code while limiting noise in short intervals and preserving fresh documents and matcher state. - **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure. - **One large quantified pattern for all formats**: one scan finds the leftmost match, but branch quantifiers still backtrack on failure. diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md index f5b57e883e..f081970b1c 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md @@ -43,7 +43,7 @@ Status: implemented | 项 | 内容 | |---|---| | 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 | -| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值,5,000 条消息的样本则整批求值一次。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 | +| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 | | 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC | | 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 | | 排除 | 冷代码编译、React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 | @@ -54,6 +54,7 @@ Status: implemented ## 考虑过的替代方案 +- **两种大小使用相同求值次数**:大输入每个样本的分配量翻倍,因而其最快样本可能包含一次垃圾回收,而小输入的最快样本没有。让每个样本的输入总量相等,使单次求值均值比较相近的分配量,无需排除垃圾回收或修改翻倍上限。 - **不预热、分批测量基线和匹配器**:亚毫秒基线测量可能先进入优化后的代码,而匹配器新触及的路径还未完成优化;分批测量也会放大时钟频率漂移。逐负载预热、相邻配对和固定批次比较预热后的代码,降低短区间测量的噪声,同时保持文档和匹配器状态全新。 - **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。 - **把全部格式合成一个带量词的大正则**:一次扫描能找到最左匹配,但各分支的量词失败时仍回溯。 diff --git a/benchmarks/user-text/user-text.worker.client.ts b/benchmarks/user-text/user-text.worker.client.ts index 354f9b12bb..52e10ac919 100644 --- a/benchmarks/user-text/user-text.worker.client.ts +++ b/benchmarks/user-text/user-text.worker.client.ts @@ -82,7 +82,7 @@ const FACTS: Readonly> = { sessionLabels: ['会话一'], const KIB = 1024 /** Exercise each workload's code paths before measuring sub-millisecond baseline evaluations. */ const WARMUP_PAIRS = 10 -/** Batch every single-document workload to limit noise in its shortest evaluation intervals. */ +/** Base batch size; half-sized scaling inputs use twice as many evaluations. */ const SINGLE_DOCUMENT_EVALUATIONS = 20 const PROSE = '帮我看看这个函数为什么慢,顺便把 README 也更新一下。The quick brown fox jumps over the lazy dog, 1234 times. ' const CODE = 'const value = await fetch(`https://api.example.com/v1/items?id=${id}`); if (!value.ok) throw new Error("bad: " + value.status);\n' @@ -160,22 +160,32 @@ interface Workload { readonly id: string readonly group: UserTextWorkloadGroup readonly texts: readonly string[] + /** Scaling pairs process the same total keys or characters per sample, including their GC cost. */ + readonly evaluationsPerSample: number } /** Named fixed workloads; adversarial inputs appear at two sizes so their scaling is observable. */ function workloads(): readonly Workload[] { return [ - { id: 'typical-messages', group: 'unmatched', texts: messages(5_000) }, + { id: 'typical-messages', group: 'unmatched', texts: messages(5_000), evaluationsPerSample: 1 }, ...Object.entries({ prose: PROSE, code: CODE, base64: BASE64, markdown: MARKDOWN, log: LOG, json: JSON_LINES, shell: SHELL }) - .map(([name, unit]): Workload => ({ id: `${name}-256k`, group: 'unmatched', texts: [fill(unit, 256 * KIB)] })), + .map(([name, unit]): Workload => ({ + id: `${name}-256k`, group: 'unmatched', texts: [fill(unit, 256 * KIB)], evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS, + })), ...Object.entries(CREDENTIALS).map(([name, credential]): Workload => ({ - id: `messages-with-${name}`, group: 'matched', texts: credentialMessages(5_000, credential), + id: `messages-with-${name}`, group: 'matched', texts: credentialMessages(5_000, credential), evaluationsPerSample: 1, })), - { id: 'env-keys-1000', group: 'matched', texts: [envFile(1_000)] }, - { id: 'env-keys-2000', group: 'matched', texts: [envFile(2_000)] }, + { id: 'env-keys-1000', group: 'matched', texts: [envFile(1_000)], evaluationsPerSample: 2 * SINGLE_DOCUMENT_EVALUATIONS }, + { id: 'env-keys-2000', group: 'matched', texts: [envFile(2_000)], evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS }, ...Object.entries(ADVERSARIAL_UNITS).flatMap(([name, unit]): Workload[] => [ - { id: `adversarial-${name}-64k`, group: 'adversarial', texts: [fill(unit, 64 * KIB)] }, - { id: `adversarial-${name}-128k`, group: 'adversarial', texts: [fill(unit, 128 * KIB)] }, + { + id: `adversarial-${name}-64k`, group: 'adversarial', texts: [fill(unit, 64 * KIB)], + evaluationsPerSample: 2 * SINGLE_DOCUMENT_EVALUATIONS, + }, + { + id: `adversarial-${name}-128k`, group: 'adversarial', texts: [fill(unit, 128 * KIB)], + evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS, + }, ]), ] } @@ -364,8 +374,7 @@ async function allocations(): Promise { async function timings(attempts: number): Promise { const results: UserTextWorkloadTiming[] = [] - for (const { id, group, texts } of workloads()) { - const evaluationsPerSample = texts.length === 1 ? SINGLE_DOCUMENT_EVALUATIONS : 1 + for (const { id, group, texts, evaluationsPerSample } of workloads()) { const { base, secret, samples } = await measure(texts, attempts, evaluationsPerSample) results.push({ id, From 13db84e62412ca64e62b2d24190aada49d13c2f0 Mon Sep 17 00:00:00 2001 From: luogesi Date: Mon, 19 Oct 2026 14:57:09 +0800 Subject: [PATCH 30/37] test(bench): calibrate sdk-minimal checkpoint budget --- ...backend-continuation-performance.i18n.yaml | 7 +++-- ...-09-06-backend-continuation-performance.md | 21 +++++++++++-- ...-06-backend-continuation-performance.zh.md | 21 +++++++++++-- .../agent-continuation/README.i18n.yaml | 4 +-- benchmarks/agent-continuation/README.md | 2 +- benchmarks/agent-continuation/README.zh.md | 2 +- .../agent-continuation.bench.ts | 30 ++++++++++++++++--- 7 files changed, 73 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml index afb6f6cd25..fe9f28b0f2 100644 --- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml @@ -12,8 +12,11 @@ en: a6dca710f2f43b03 zh: 49b507d106c5ec81 /agent-note-performance-baselines-for-tool-heavy-backend-continuation/calibration-evidence: - en: b1bdf65b8b017b68 - zh: 7e8ed6c18bc6aad1 + en: 16498006f02baebb + zh: 7b64cb0629ee1753 +/agent-note-performance-baselines-for-tool-heavy-backend-continuation/calibration-evidence/sdk-minimal-checkpoint-calibration: + en: f4b52851e35cec56 + zh: b419546dda8abf21 /agent-note-performance-baselines-for-tool-heavy-backend-continuation/alternatives-considered: en: 3a61dff7c926eb22 zh: 8f2633b16be78233 diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md index dd545253ff..e1de8eec76 100644 --- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md +++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md @@ -39,11 +39,11 @@ The implementation reference is the PR #3537 merge on Apple M4 Pro, macOS arm64, | Tool continuation | 358.953, 324.790, 318.861, 320.119, 322.896 | 324.280, 321.952, 340.409, 325.470, 324.312 | 322.896 / 324.312 | 340 | 850 | | Child catalog | 318.730, 309.006, 311.404, 308.565, 310.105 | 308.670, 310.030, 280.086, 303.084, 284.829 | 310.105 / 303.084 | 320 | 800 | -Continuation retains approximately 22.295 MiB; its source expectation is 23 MiB and its budget is 28.75 MiB. SDK time expectations use the existing [calibration helper](../../../../benchmarks/support/calibration.ts): 2× shared CI time scale and 1.25× variance headroom. Request history uses the direct hosted expectation in the [request-freeze calibration](../../../../benchmarks/agent-continuation/agent-continuation.bench.ts), without the 2× scale. Memory uses only 1.25× headroom. The scale is inherited from the existing lane's calibration, not a new Linux measurement of these cases; CI evidence remains necessary when runner characteristics change. Baseline budgets protect the measured implementation; tighter budgets belong with a measured behavior-preserving fix. +Continuation retains approximately 22.295 MiB; its source expectation is 23 MiB and its budget is 28.75 MiB. The historical SDK time expectation used the existing [calibration helper](../../../../benchmarks/support/calibration.ts): 2× shared CI time scale and 1.25× variance headroom. Request history uses the direct hosted expectation in the [request-freeze calibration](../../../../benchmarks/agent-continuation/agent-continuation.bench.ts), without the 2× scale. Memory uses only 1.25× headroom. The scale is inherited from the existing lane's calibration, not a new Linux measurement of these cases; CI evidence remains necessary when runner characteristics change. Baseline budgets protect the measured implementation; tighter budgets belong with a measured behavior-preserving fix. A separate plain-Node request-history CPU profile attributes 132.876 ms of sampled self time to deepFreeze called by buildRequest during a 211.300 ms operation. This identifies repeated traversal of already-frozen history as a focused investigation target, not a proven optimization result. Catalog first/repeat timings separately report the first and second parent-catalog observations. -The shipped SDK variant completes 100 turns, 200 requests, and 800 real file reads. Its five-sample smoke totals are 1,521.773, 1,463.465, 1,689.701, 1,365.485, and 1,417.106 ms (median 1,463.465 ms); a full-suite repeat reports 1,596.183, 1,784.536, 2,120.082, 1,405.365, and 1,355.894 ms (median 1,596.183 ms). Its 1,700 ms reference expectation yields a 4,250 ms CI budget. The repeat also slows the unchanged service cases, so it is validation under variable host load rather than evidence to relax their exclusive calibration. The SDK process receives an allowlisted environment and private home/workspace. A 40-second deadline starts SDK shutdown; every path awaits the same memoized close promise before the outer worker’s 60-second deadline. Profile timing includes boot, all turns, and shutdown, reported separately; no parent-process CPU or heap metric is presented as server memory. The adapter does not serialize requests for an external model provider. +The shipped SDK variant completes 100 turns, 200 requests, and 800 real file reads. Its five-sample smoke totals are 1,521.773, 1,463.465, 1,689.701, 1,365.485, and 1,417.106 ms (median 1,463.465 ms); a full-suite repeat reports 1,596.183, 1,784.536, 2,120.082, 1,405.365, and 1,355.894 ms (median 1,596.183 ms). Its historical 1,700 ms reference expectation yielded a 4,250 ms CI budget. The repeat also slows the unchanged service cases, so it is validation under variable host load rather than evidence to relax their exclusive calibration. The SDK process receives an allowlisted environment and private home/workspace. A 40-second deadline starts SDK shutdown; every path awaits the same memoized close promise before the outer worker’s 60-second deadline. Profile timing includes boot, all turns, and shutdown, reported separately; no parent-process CPU or heap metric is presented as server memory. The adapter does not serialize requests for an external model provider. The first Linux x64 CI measurement ran on `VM-7-113-ubuntu-ci-10` with Node 24.18.1 (run 34017868081, attempt 1, job 101444810498). The SDK median was 2,753.441 ms against its 4,250 ms budget, and tool-continuation retained-heap median was 22.274 MiB against 28.75 MiB. Request-history and tool-continuation time budgets failed: 785.498 ms against 550 ms and 1,077.285 ms against 850 ms, respectively. The unchanged Session-reopen open phase also failed at 31.6 ms against 30 ms. Attempt 2, job 101447076381 (run 34017868081) passed every benchmark on the same commit and unchanged budgets, but used `VM-7-113-ubuntu-ci-29` with Node 24.19.0. The gate runner suppressed successful child output, so that attempt supplies a passing verdict rather than raw medians. The changed runner and Node version prevent attributing the difference solely to contention or claiming stable repeated CI calibration; neither the budgets nor the shared scale are changed on this evidence. @@ -53,6 +53,23 @@ Tool continuation also uses a 900 ms expected CI duration with 1.25× headroom ( Baseline request history uses a 600 ms expected CI duration with 1.25× headroom (750 ms). With unchanged implementation, standard two-CPU hosted run 34035306987, job 101492163630 reports totals of 618.598, 618.606, 582.035, 582.304, and 581.832 ms: median 582.304 ms exceeds the historical 550 ms budget. The 220 ms M4 expectation remains historical evidence. The same measured-verdict assertion accepts the recorded unrounded median under 750 ms, rejects it under 550 ms, and rejects a synthetic 900 ms regression. This calibrates the unoptimized baseline only; workload, timing, product code, and memory budgets remain unchanged. +### SDK-minimal checkpoint calibration + +The SDK-minimal profile uses awaited checkpoints before steps, model requests, and top-level tools. This adds repeated durable writes to the timed path; the buffered-write reference above is historical. The accepted hosted expectation is 5,500 ms. Applying the existing 1.25 variance headroom gives 6,875 ms, rounded up to a whole second for a 7,000 ms budget. The 2× local-to-CI scale is not applied to a duration measured directly on CI. Other case budgets, shared factors, sample counts, and worker deadlines remain unchanged. + +The following four complete attempts of CI run 39198687926 in PR #6369 use the same checkpoint-enabled code, 100 turns, 200 requests, 800 real file reads, zstd persistence, and the original 4,250 ms assertion. Each sample starts a fresh process. All runners expose two CPUs on Linux x64 with Node 24.21.0. Times are milliseconds. + +| Attempt / job | CPU | Five raw total times | Median | +|---|---|---|---:| +| 1 / 117886905086 | AMD EPYC 7763 | 4009.703, 3974.780, 4073.098, 3998.816, 4065.456 | 4009.703 | +| 2 / 117898305589 | AMD EPYC 9V74 | 4182.691, 5946.327, 5581.127, 5432.567, 4362.942 | 5432.567 | +| 3 / 117906071823 | Intel Xeon 6973P-C | 2841.519, 2897.759, 2985.475, 2920.282, 2863.954 | 2897.759 | +| 4 / 117922218658 | AMD EPYC 7763 | 4388.420, 4224.595, 4312.915, 4288.871, 4336.641 | 4312.915 | + +The upper observed run median is 5,432.567 ms; the slowest single sample is 5,946.327 ms. The 5,500 ms expectation rounds above the upper median, and the final 7,000 ms ceiling leaves approximately 29% above it. Runner variation is retained in the evidence, not filtered into a fastest-run result. The later canceled attempt contributes no completed sample group. + +This calibration accepts the measured cost of the stronger durability policy; it is not a performance improvement. Local on/off diagnosis attributes most additional persistence time to frequent file synchronization. The browser input-overlap failures in attempts 1 and 4 are separate failures and receive no exemption from this budget. Deterministic controls use the measured-verdict assertion: the recorded upper-median sample group fails the old 4,250 ms ceiling and passes 7,000 ms, while a synthetic 7,800 ms median remains rejected. + ## Alternatives considered **Repeat existing migration and first-open variants.** Rejected: those twelve cases already distinguish read-only preparation from writable publication. These cases use the current generation and begin or continue actual model work, or enumerate a corpus rather than open one Session. diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md index 9817b72a44..cc96d63e95 100644 --- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md +++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md @@ -39,11 +39,11 @@ SDK fixture 通过 profile patch 显式插入 `fs-local` 和 `str_replace_editor | 工具续聊 | 358.953, 324.790, 318.861, 320.119, 322.896 | 324.280, 321.952, 340.409, 325.470, 324.312 | 322.896 / 324.312 | 340 | 850 | | 子会话目录 | 318.730, 309.006, 311.404, 308.565, 310.105 | 308.670, 310.030, 280.086, 303.084, 284.829 | 310.105 / 303.084 | 320 | 800 | -续聊保留约 22.295 MiB;源码期望值为 23 MiB,预算为 28.75 MiB。SDK 时间期望值使用现有[校准辅助函数](../../../../benchmarks/support/calibration.ts):2× 共享 CI 时间比例和 1.25× 波动余量。请求历史使用[请求历史基准](../../../../benchmarks/agent-continuation/agent-continuation.bench.ts)中的直接托管期望值,不乘以 2× 比例。内存只使用 1.25× 余量。比例继承现有通道的校准,并非这些用例的新 Linux 实测值;runner 特征变化时仍需 CI 证据。基线预算保护实测实现;更紧预算属于有测量依据且保持行为的修复。 +续聊保留约 22.295 MiB;源码期望值为 23 MiB,预算为 28.75 MiB。历史 SDK 时间期望值使用了现有[校准辅助函数](../../../../benchmarks/support/calibration.ts):2× 共享 CI 时间比例和 1.25× 波动余量。请求历史使用[请求历史基准](../../../../benchmarks/agent-continuation/agent-continuation.bench.ts)中的直接托管期望值,不乘以 2× 比例。内存只使用 1.25× 余量。比例继承现有通道的校准,并非这些用例的新 Linux 实测值;runner 特征变化时仍需 CI 证据。基线预算保护实测实现;更紧预算属于有测量依据且保持行为的修复。 独立的纯 Node 请求历史 CPU profile 在一次 211.300 ms 操作中,将 132.876 ms 采样自身时间归因于 buildRequest 调用的 deepFreeze。这把重复遍历已冻结历史定位为聚焦调查目标,不是已证实的优化结果。目录首次/重复时间分别报告第一次和第二次 parent 目录观察。 -已发布 SDK 变体完成 100 个轮次、200 次请求和 800 次真实文件读取。五样本 smoke 总时间为 1,521.773、1,463.465、1,689.701、1,365.485 和 1,417.106 ms(中位数 1,463.465 ms);完整套件重复运行报告 1,596.183、1,784.536、2,120.082、1,405.365 和 1,355.894 ms(中位数 1,596.183 ms)。1,700 ms 参考期望对应 4,250 ms CI 预算。重复运行中未改变的服务用例也变慢,因此这是可变主机负载下的验证,不是放宽其独占校准预算的依据。SDK 进程使用白名单环境和私有主目录/工作区。40 秒截止时间启动 SDK 关闭;所有路径等待同一个记忆化 close Promise,并早于外层 worker 的 60 秒截止时间。Profile 时间包含启动、全部轮次和关闭,分别报告;不把父进程 CPU 或堆指标当作服务端内存。适配器不为外部模型服务商序列化请求。 +已发布 SDK 变体完成 100 个轮次、200 次请求和 800 次真实文件读取。五样本 smoke 总时间为 1,521.773、1,463.465、1,689.701、1,365.485 和 1,417.106 ms(中位数 1,463.465 ms);完整套件重复运行报告 1,596.183、1,784.536、2,120.082、1,405.365 和 1,355.894 ms(中位数 1,596.183 ms)。历史 1,700 ms 参考期望对应当时的 4,250 ms CI 预算。重复运行中未改变的服务用例也变慢,因此这是可变主机负载下的验证,不是放宽其独占校准预算的依据。SDK 进程使用白名单环境和私有主目录/工作区。40 秒截止时间启动 SDK 关闭;所有路径等待同一个记忆化 close Promise,并早于外层 worker 的 60 秒截止时间。Profile 时间包含启动、全部轮次和关闭,分别报告;不把父进程 CPU 或堆指标当作服务端内存。适配器不为外部模型服务商序列化请求。 首次 Linux x64 CI 测量使用 `VM-7-113-ubuntu-ci-10` 和 Node 24.18.1(run 34017868081,attempt 1,job 101444810498)。SDK 中位数为 2,753.441 ms,预算为 4,250 ms;工具续聊保留堆中位数为 22.274 MiB,预算为 28.75 MiB。请求历史与工具续聊时间预算失败:分别为 785.498 ms 对 550 ms、1,077.285 ms 对 850 ms。未修改的 Session 重开 open 阶段也以 31.6 ms 对 30 ms 失败。Attempt 2,job 101447076381 (run 34017868081) 在同一提交和未修改预算下通过全部基准,但使用 `VM-7-113-ubuntu-ci-29` 和 Node 24.19.0。门禁运行器隐藏成功子进程的输出,因此该次运行只提供通过结论,不提供原始中位数。Runner 与 Node 版本同时变化,不能把差异仅归因于资源争用,也不能宣称已获得稳定的重复 CI 校准;这些证据不改变预算或共享比例。 @@ -53,6 +53,23 @@ SDK fixture 通过 profile patch 显式插入 `fs-local` 和 `str_replace_editor 基线请求历史使用 600 ms CI 期望时间与 1.25× 余量(750 ms)。未修改的实现在标准双 CPU 托管 run 34035306987,job 101492163630 中报告总时间为 618.598、618.606、582.035、582.304 和 581.832 ms:中位数 582.304 ms 超出历史 550 ms 预算。220 ms M4 期望保留为历史证据。与实测判定相同的断言在 750 ms 下接受未经舍入的录制中位数,在 550 ms 下拒绝它,并拒绝合成的 900 ms 回退。此校准仅针对未优化基线;负载、计时、产品代码和内存预算均不改变。 +### SDK-minimal 检查点校准 + +SDK-minimal profile 在 step、模型请求和顶层工具执行前等待检查点。这使重复持久化写入进入计时路径;上方基于缓冲写入的参考值仅作历史记录。已接受的托管期望值为 5,500 ms,乘现有 1.25 波动余量得到 6,875 ms,再向上取整到整秒,预算为 7,000 ms。直接在 CI 测得的时间不再乘本地到 CI 的 2× 比例。其他用例预算、共享系数、样本数量和 worker 截止时间均保持不变。 + +以下是 PR #6369 中 CI run 39198687926 的四次完整尝试,使用相同的 checkpoint 启用代码、100 轮、200 次请求、800 次真实文件读取、zstd 持久化和原 4,250 ms 断言。每个样本启动新进程。所有 runner 均为 Linux x64、两个可用 CPU、Node 24.21.0。时间单位为毫秒。 + +| 尝试 / job | CPU | 五个原始总耗时 | 中位数 | +|---|---|---|---:| +| 1 / 117886905086 | AMD EPYC 7763 | 4009.703, 3974.780, 4073.098, 3998.816, 4065.456 | 4009.703 | +| 2 / 117898305589 | AMD EPYC 9V74 | 4182.691, 5946.327, 5581.127, 5432.567, 4362.942 | 5432.567 | +| 3 / 117906071823 | Intel Xeon 6973P-C | 2841.519, 2897.759, 2985.475, 2920.282, 2863.954 | 2897.759 | +| 4 / 117922218658 | AMD EPYC 7763 | 4388.420, 4224.595, 4312.915, 4288.871, 4336.641 | 4312.915 | + +实测轮次中位数上界为 5,432.567 ms,最慢单次样本为 5,946.327 ms。5,500 ms 期望值向上取整覆盖该中位数,最终 7,000 ms 上限比它高约 29%。Runner 差异保留在证据中,不筛选为最快运行结果。后续取消的尝试不贡献完整样本组。 + +此校准接受更强持久化策略的实测成本,不表示性能已改善。本地开关对照将额外持久化时间的大部分归因于频繁的文件同步。第 1、4 次尝试中的浏览器输入重叠失败属于独立问题,不因本预算而豁免。确定性对照与实测判定使用同一断言:记录的最高中位数样本组被旧 4,250 ms 上限拒绝,在 7,000 ms 下通过;合成的 7,800 ms 中位数仍被拒绝。 + ## 考虑过的替代方案 **重复现有迁移和首次打开变体。** 拒绝:现有十二个用例已经区分只读准备与可写发布。这些用例使用当前代际并开始或继续实际模型工作,或者列举语料集合而不是打开单个 Session。 diff --git a/benchmarks/agent-continuation/README.i18n.yaml b/benchmarks/agent-continuation/README.i18n.yaml index 751cf9d842..d9b991241c 100644 --- a/benchmarks/agent-continuation/README.i18n.yaml +++ b/benchmarks/agent-continuation/README.i18n.yaml @@ -12,8 +12,8 @@ en: 66a769ce60d49ff3 zh: 7e0bfa7c598157fa /backend-continuation-benchmarks/run: - en: 900cdb7cdb11cf21 - zh: 0dafdd9a3be1b04e + en: a9a943044795029c + zh: 45c8257f51ec658a /backend-continuation-benchmarks/measurements: en: 84301730c56f0614 zh: 6e90a8d6574fa896 diff --git a/benchmarks/agent-continuation/README.md b/benchmarks/agent-continuation/README.md index 3074ba73e0..f7c1415a87 100644 --- a/benchmarks/agent-continuation/README.md +++ b/benchmarks/agent-continuation/README.md @@ -18,7 +18,7 @@ Measure long-history request processing, cold tool-heavy continuation, and repea From the repository root, build the libraries and workers with `pnpm run build:bench`, then run `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/agent-continuation/agent-continuation.bench.ts`. Do not overlap timing runs with builds or other benchmarks. -The test reports all five fresh-process samples, CPU models, available parallelism, platform/architecture, and Node/V8 versions, and enforces reviewed median budgets. Catalog and tool continuation each use a 900 ms standard hosted CI expectation with 1.25× headroom (1,125 ms); request history uses a separately reviewed 297 ms hosted limit ([archived evidence](../../.agents/notes/archived/simplification/2026-09-06-agent-request-freeze-evidence.md)), and SDK continuation uses reference-machine scaling. A failed worker reports its exit, signal, timeout, and stderr; temporary roots are removed even on failure. The required benchmark lane discovers this file automatically. +The test reports all five fresh-process samples, CPU models, available parallelism, platform/architecture, and Node/V8 versions, and enforces reviewed median budgets. Catalog and tool continuation each use a 900 ms standard hosted CI expectation with 1.25× headroom (1,125 ms); request history uses a separately reviewed 297 ms hosted limit ([archived evidence](../../.agents/notes/archived/simplification/2026-09-06-agent-request-freeze-evidence.md)), and checkpoint-enabled SDK-minimal continuation uses a 5,500 ms hosted expectation with 1.25× headroom rounded up to a whole second (7,000 ms). This hosted expectation does not use the 2× reference-machine scale. A failed worker reports its exit, signal, timeout, and stderr; temporary roots are removed even on failure. The required benchmark lane discovers this file automatically. diff --git a/benchmarks/agent-continuation/README.zh.md b/benchmarks/agent-continuation/README.zh.md index 6adc4143db..530e5089b2 100644 --- a/benchmarks/agent-continuation/README.zh.md +++ b/benchmarks/agent-continuation/README.zh.md @@ -18,7 +18,7 @@ 在仓库根目录使用 `pnpm run build:bench` 构建库和 worker,然后运行 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/agent-continuation/agent-continuation.bench.ts`。不要让计时运行与构建或其他基准重叠。 -测试报告全部五个新进程样本、CPU 型号、可用并行度、平台/架构和 Node/V8 版本,并约束经审查的中位数预算。目录和工具续聊用例均使用标准托管 CI 的 900 ms 期望值与 1.25× 余量(1,125 ms);请求历史使用单独审查的 297 ms 托管上限([已归档的证据](../../.agents/notes/archived/simplification/2026-09-06-agent-request-freeze-evidence.md)),SDK 续聊使用参考机器缩放。worker 失败时报告退出状态、信号、超时和 stderr;失败时也会删除临时根目录。必需基准通道自动发现此文件。 +测试报告全部五个新进程样本、CPU 型号、可用并行度、平台/架构和 Node/V8 版本,并约束经审查的中位数预算。目录和工具续聊用例均使用标准托管 CI 的 900 ms 期望值与 1.25× 余量(1,125 ms);请求历史使用单独审查的 297 ms 托管上限([已归档的证据](../../.agents/notes/archived/simplification/2026-09-06-agent-request-freeze-evidence.md)),启用 checkpoint 的 SDK-minimal 续聊使用 5,500 ms 托管期望值,乘 1.25× 余量后向上取整到整秒(7,000 ms)。该托管期望值不使用参考机器的 2× 比例。worker 失败时报告退出状态、信号、超时和 stderr;失败时也会删除临时根目录。必需基准通道自动发现此文件。 diff --git a/benchmarks/agent-continuation/agent-continuation.bench.ts b/benchmarks/agent-continuation/agent-continuation.bench.ts index 12958ac151..c2a95f28a6 100644 --- a/benchmarks/agent-continuation/agent-continuation.bench.ts +++ b/benchmarks/agent-continuation/agent-continuation.bench.ts @@ -14,8 +14,12 @@ import { WORKLOAD } from './workload.ts' const ATTEMPTS = 5 const WORKER_TIMEOUT_MS = 60_000 -/** M4 Pro / Node 24.19 baseline expectations, before shared CI scaling and variance headroom. */ -const EXPECTED_MS = { 'profile-continuation': 1_700 } as const +/** Checkpoint-enabled two-CPU hosted runs have an upper observed median of 5,432.567 ms. */ +const EXPECTED_PROFILE_CONTINUATION_CI_MS = 5_500 +/** Apply variance headroom and round up to a whole second; the baseline is already a CI measurement. */ +const PROFILE_CONTINUATION_BUDGET_MS = Math.ceil( + EXPECTED_PROFILE_CONTINUATION_CI_MS * PERFORMANCE_BUDGET_HEADROOM / 1_000, +) * 1_000 /** Standard two-CPU hosted CI baseline request-history median is 582.304 ms. */ const EXPECTED_BASELINE_REQUEST_CI_MS = 600 const BASELINE_REQUEST_BUDGET_MS = Math.ceil(EXPECTED_BASELINE_REQUEST_CI_MS * PERFORMANCE_BUDGET_HEADROOM) @@ -54,7 +58,7 @@ const CASES: Readonly> = { }, } -type Scenario = 'request-history' | 'catalog' | 'tool-continuation' | keyof typeof EXPECTED_MS +type Scenario = 'request-history' | 'catalog' | 'tool-continuation' | 'profile-continuation' type Report = ContinuationReport | CatalogReport | ProfileReport function workerName(scenario: Scenario): string { @@ -81,6 +85,24 @@ function expectTotalWithinBudget(value: number, budget: number): void { expect(value).toBeLessThanOrEqual(budget) } +describe('checkpoint-enabled SDK-minimal hosted calibration', () => { + it('accepts the recorded checkpoint-enabled workload above the buffered-write budget', () => { + const recordedMedian = median([4_182.691, 5_946.326875, 5_581.127338, 5_432.567004, 4_362.941759]) + + expect(recordedMedian).toBe(5_432.567004) + expect(() => expectTotalWithinBudget(recordedMedian, 4_250)).toThrow() + expectTotalWithinBudget(recordedMedian, PROFILE_CONTINUATION_BUDGET_MS) + expect(PROFILE_CONTINUATION_BUDGET_MS).toBe(7_000) + }) + + it('rejects a material SDK-minimal profile slowdown', () => { + const regressionMedian = median([7_600, 7_900, 8_000, 7_700, 7_800]) + + expect(regressionMedian).toBe(7_800) + expect(() => expectTotalWithinBudget(regressionMedian, PROFILE_CONTINUATION_BUDGET_MS)).toThrow() + }) +}) + describe('standard hosted catalog calibration', () => { it('accepts the recorded two-CPU samples that exceed the historical budget', () => { const recordedMedian = median([797.373945, 883.157358, 858.363927, 790.568538, 904.5785669999999]) @@ -187,7 +209,7 @@ describe('continuing tool-heavy Sessions with large histories', () => { const totalMs = samples.map(sample => sample.totalMs) const budgetMs = scenario === 'request-history' ? REQUEST_HISTORY_BUDGET_MS : scenario === 'catalog' ? CATALOG_BUDGET_MS - : scenario === 'tool-continuation' ? TOOL_CONTINUATION_BUDGET_MS : ciTimeBudget(EXPECTED_MS[scenario]) + : scenario === 'tool-continuation' ? TOOL_CONTINUATION_BUDGET_MS : PROFILE_CONTINUATION_BUDGET_MS const retainedHeapBudgetMb = EXPECTED_RETAINED_HEAP_MB * PERFORMANCE_BUDGET_HEADROOM console.log(JSON.stringify({ benchmark: 'agent-continuation/' + scenario, workload: WORKLOAD, From feff25e8f7dbc8123ad641dca89bd2b3abf2e5b2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:26:29 +0800 Subject: [PATCH 31/37] fix(subagent): initialize model selection before agent publication --- .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 2 +- packages/subagent/tool-subagent/README.zh.md | 2 +- .../src/model-selection-settings.ts | 7 +- .../model-selection-initialization.spec.ts | 69 +++++++++++++++++++ 5 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 packages/subagent/tool-subagent/tests/model-selection-initialization.spec.ts diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index c9fda66bd0..3b05b61f13 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/README.i18n.yaml @@ -27,8 +27,8 @@ en: 1950ef92bab0f46e zh: 593f5f64ed9b5631 /deepseek-ai-dsh-tool-subagent/use-this-package/selecting-a-child-llm: - en: 628ec29adbe175e6 - zh: 82df946ca7eef167 + en: 5cf892e4acab3f38 + zh: c282b79620bc3de5 /deepseek-ai-dsh-tool-subagent/understand-the-implementation: en: 6ce5f749256a7e6c zh: a4f2adb04a70d9cf diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 5d61e014e3..d9757eae9a 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -74,7 +74,7 @@ Compose persona and tools in the [preset plugin list](../../preset/agent-preset- ### Selecting a child LLM -Set `modelSelection: true` to expose optional `provider`, `model`, and `reasoning_effort` fields; instances without it reject selection fields. Set `modelDiscovery: true` on one instance per tool scope to register `list_subagent_models`; it defaults off independently of `modelSelection`. A second discovery owner in the same scope fails to register. Discovery follows its owning plugin instance, remains available when its delegation provider is absent, and is removed when that instance unloads. Web and Desktop presets enable discovery and route fields on spawn and leave both disabled on fork. The Host’s `/model-selection-settings` service samples the user preference during Agent creation, independently of tool installation. When enabled, its exact route list is recorded in each fresh top-level Session, inherited by child Sessions, and preserved on restore; later settings edits affect only new Sessions. Without a recorded Session policy, discovery and explicit selection permit only the parent Agent’s current effective provider/model and its supported efforts. Route fields require a backend that advertises `agentOptions`; in-process Spawn and Fork and DSH SDK qualify, while ACP, Codex, and Claude Code fail the mount. +Set `modelSelection: true` to expose optional `provider`, `model`, and `reasoning_effort` fields; instances without it reject selection fields. Set `modelDiscovery: true` on one instance per tool scope to register `list_subagent_models`; it defaults off independently of `modelSelection`. A second discovery owner in the same scope fails to register. Discovery follows its owning plugin instance, remains available when its delegation provider is absent, and is removed when that instance unloads. Web and Desktop presets enable discovery and route fields on spawn and leave both disabled on fork. The Host’s `/model-selection-settings` service samples the user preference during Agent creation, independently of tool installation. Its creation listener subscribes before projection registration completes and awaits the handle; registration failure or owner disposal rejects Agent publication. When enabled, its exact route list is recorded in each fresh top-level Session, inherited by child Sessions, and preserved on restore; later settings edits affect only new Sessions. Without a recorded Session policy, discovery and explicit selection permit only the parent Agent’s current effective provider/model and its supported efforts. Route fields require a backend that advertises `agentOptions`; in-process Spawn and Fork and DSH SDK qualify, while ACP, Codex, and Claude Code fail the mount. A call supplies `provider` and `model` together, or supplies only an effort when configured, parent, or provider-owned defaults provide the route. Static `provider.agentRouteDefaults`, when present, form the provider/model baseline; tool configuration and model fields overlay it before route-aware effort merging and exact-route preflight. Providers without these defaults use compatible values from the parent's latest logged request, then the parent's creation options before its first request, while retaining the configured `maxTokens`. Changing the route without an explicit effort clears the inherited route-owned effort, so the selected model resolves its default. The live LLM adapter validates the effective route before child creation. Catalog membership remains advisory, so a model can use an unlisted id when its adapter accepts it. diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index b42f3fe2d8..6e41531706 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -74,7 +74,7 @@ kind: "package-reference" ### 选择子级 LLM -设置 `modelSelection: true` 后,该实例公开可选的 `provider`、`model` 和 `reasoning_effort` 字段;未设置的实例拒绝选择字段。在每个工具作用域的一个实例上设置 `modelDiscovery: true`,以注册 `list_subagent_models`;该选项独立于 `modelSelection`,默认关闭。同一作用域的第二个发现工具所有者会注册失败。发现工具随其所属插件实例存续,在委派提供方缺席时仍然可用,并在该实例卸载时移除。Web 与 Desktop preset 为 spawn 启用发现和路由字段,为 fork 保持两者关闭。Host 的 `/model-selection-settings` 服务在 Agent 创建时读取用户偏好,与工具安装无关。启用时,精确路由列表记录在每个新顶层 Session 中,由子 Session 继承,并在恢复时保留;之后的设置编辑只影响新 Session。没有已记录 Session 策略时,发现和显式选择只允许父 Agent 当前生效的 provider/model 及其支持的强度。路由字段要求后端公布 `agentOptions`;进程内 Spawn、Fork 和 DSH SDK 满足条件,ACP、Codex 和 Claude Code 会使挂载失败。 +设置 `modelSelection: true` 后,该实例公开可选的 `provider`、`model` 和 `reasoning_effort` 字段;未设置的实例拒绝选择字段。在每个工具作用域的一个实例上设置 `modelDiscovery: true`,以注册 `list_subagent_models`;该选项独立于 `modelSelection`,默认关闭。同一作用域的第二个发现工具所有者会注册失败。发现工具随其所属插件实例存续,在委派提供方缺席时仍然可用,并在该实例卸载时移除。Web 与 Desktop preset 为 spawn 启用发现和路由字段,为 fork 保持两者关闭。Host 的 `/model-selection-settings` 服务在 Agent 创建时读取用户偏好,与工具安装无关。创建监听器在投影注册完成前订阅并等待句柄;注册失败或所属插件卸载会使 Agent 发布失败。启用时,精确路由列表记录在每个新顶层 Session 中,由子 Session 继承,并在恢复时保留;之后的设置编辑只影响新 Session。没有已记录 Session 策略时,发现和显式选择只允许父 Agent 当前生效的 provider/model 及其支持的强度。路由字段要求后端公布 `agentOptions`;进程内 Spawn、Fork 和 DSH SDK 满足条件,ACP、Codex 和 Claude Code 会使挂载失败。 一次调用需同时提供 `provider` 与 `model`;当配置值、父 agent 值或提供方持有的默认值能提供路由时,也可只提供推理等级。静态的 `provider.agentRouteDefaults` 在存在时构成提供方/模型基线;工具配置与模型字段会在路由相关强度合并和确切路由预检前覆盖它。没有这些默认值的提供方会使用父 agent 最新已记录请求中的兼容值,再使用父级首次请求前的创建选项,并保留配置的 `maxTokens`。更改路由但未显式提供推理等级时,会清除继承的路由自有等级,使所选模型解析自己的默认值。实时 LLM 适配器在创建子 agent 前校验有效路由。目录成员资格只提供建议,因此适配器接受时,模型可以使用未列出的 id。 diff --git a/packages/subagent/tool-subagent/src/model-selection-settings.ts b/packages/subagent/tool-subagent/src/model-selection-settings.ts index f7cd8758a7..99c806fa36 100644 --- a/packages/subagent/tool-subagent/src/model-selection-settings.ts +++ b/packages/subagent/tool-subagent/src/model-selection-settings.ts @@ -53,8 +53,10 @@ export class SubagentModelSelectionConfig extends Service { protected async [Service.init](): Promise { const ctx = this.ctx - const policy = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) - ctx.on('agent/created', ({ agent }) => { + const policyReady = ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + ctx.on('agent/created', async ({ agent }) => { + const policy = await policyReady + ctx.fiber.assertActive() const target = agent.session if (subagentModelSelectionPolicy(policy, target) !== undefined) return let allowedModels: AllowedModelRoute[] | undefined @@ -70,6 +72,7 @@ export class SubagentModelSelectionConfig extends Service { } if (allowedModels !== undefined) target.append('subagent/model-selection-policy', { allowedModels }) }) + await policyReady } /** diff --git a/packages/subagent/tool-subagent/tests/model-selection-initialization.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-initialization.spec.ts new file mode 100644 index 0000000000..7132a43d18 --- /dev/null +++ b/packages/subagent/tool-subagent/tests/model-selection-initialization.spec.ts @@ -0,0 +1,69 @@ +/** Agent publication waits for the policy owner while its projection activates. */ +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { SessionId } from '@deepseek-ai/dsh-session' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import SubagentModelSelectionConfig from '../src/model-selection-settings.ts' + +const ALLOWED_MODELS = [{ provider: 'alpha', model: 'fast-model' }] + +async function setup() { + const ctx = new Context() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(async () => { + release.resolve(undefined) + await ctx.fiber.dispose() + }) + await mountAgentLoopTestDependencies(ctx, { workingDirectory: true }) + await ctx.plugin(AgentLoop, { agents: [] }) + const register = ctx.sessionProjections.register.bind(ctx.sessionProjections) + const spy = vi.spyOn(ctx.sessionProjections, 'register').mockImplementationOnce(async (definition) => { + const handle = await register(definition) + entered.resolve(undefined) + await release.promise + return handle + }) + onTestFinished(() => { spy.mockRestore() }) + const announcing = Promise.withResolvers() + ctx.on('agent/created', () => { announcing.resolve(undefined) }, { prepend: true }) + const fiber = ctx.plugin(SubagentModelSelectionConfig, { enabled: true, allowedModels: ALLOWED_MODELS }) + await entered.promise + return { ctx, fiber, release, announcing: announcing.promise } +} + +describe('SubagentModelSelectionConfig activation', () => { + it('records the policy for an Agent announced while registration is pending', async () => { + const { ctx, fiber, release, announcing } = await setup() + const creation = ctx.agents.create({ sessionId: SessionId('during-policy-activation') }) + await announcing + release.resolve(undefined) + const { agent } = await creation + await fiber + + expect(agent.session.snapshotEvents().filter(event => event.type === 'subagent/model-selection-policy')) + .toMatchObject([{ data: { allowedModels: ALLOWED_MODELS } }]) + }) + + it.each(['dispose', 'reject'] as const)('rejects Agent publication when policy activation ends with %s', async (ending) => { + const { ctx, fiber, release, announcing } = await setup() + const id = SessionId('failed-policy-activation') + const creation = ctx.agents.create({ sessionId: id }) + const rejected = expect(creation).rejects.toThrow() + await announcing + if (ending === 'dispose') { + const disposal = fiber.dispose() + release.resolve(undefined) + await disposal + } else { + const failed = expect(Promise.resolve(fiber)).rejects.toThrow('registration failed') + release.reject(new Error('registration failed')) + await failed + } + await rejected + + expect(ctx.agents.get(id)).toBeUndefined() + expect(ctx.sessions.get(id)).toBeUndefined() + }) +}) From ab7bd24b60bec261f5e7e43df08f835095f2e1a3 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:26:29 +0800 Subject: [PATCH 32/37] fix(session-title): retain fallback requests during activation --- .../session/session-title/README.i18n.yaml | 4 +- packages/session/session-title/README.md | 2 + packages/session/session-title/README.zh.md | 2 + packages/session/session-title/src/index.ts | 51 ++--- .../tests/initialization.spec.ts | 179 ++++++++++++++++++ 5 files changed, 213 insertions(+), 25 deletions(-) create mode 100644 packages/session/session-title/tests/initialization.spec.ts diff --git a/packages/session/session-title/README.i18n.yaml b/packages/session/session-title/README.i18n.yaml index 762f1ba148..1785782cb1 100644 --- a/packages/session/session-title/README.i18n.yaml +++ b/packages/session/session-title/README.i18n.yaml @@ -42,8 +42,8 @@ en: 84f4a3ff08caf847 zh: 9ed3acae2bb2a8b0 /deepseek-ai-dsh-session-title/understand-the-implementation/lifecycle-and-concurrency: - en: 0167ad2951e5ae4d - zh: 0b1df4b7f20eb2f5 + en: 7a4aeb28bde70096 + zh: 6803a7c3054e80a1 /deepseek-ai-dsh-session-title/understand-the-implementation/normalization: en: 0fa22531683cc824 zh: c6379873eb809e76 diff --git a/packages/session/session-title/README.md b/packages/session/session-title/README.md index 83b0736619..fb91354ec7 100644 --- a/packages/session/session-title/README.md +++ b/packages/session/session-title/README.md @@ -88,6 +88,8 @@ Titles are durable, log-only state: every accepted revision is a `session/title` ### Lifecycle and concurrency +Messages received during activation wait for the service to become ready and request the built-in fallback. Providers register after service readiness; earlier messages and requests do not trigger them retroactively. Activation failure, service disposal, or Session removal prevents the deferred fallback from writing. + Per-session work state tracks a revision counter, an in-flight fallback, and pending and active provider work. A newer user message, provider disposal, session disposal, or explicit refresh aborts older work through an `AbortController`; a completion whose provider, revision, session, or signal is stale cannot append. Explicit refreshes reserve their revision before provider work; overlapping automatic and explicit fallback requests share one session-local in-flight append. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes. ### Normalization diff --git a/packages/session/session-title/README.zh.md b/packages/session/session-title/README.zh.md index 5e046468f5..f42174730b 100644 --- a/packages/session/session-title/README.zh.md +++ b/packages/session/session-title/README.zh.md @@ -88,6 +88,8 @@ kind: "package-reference" ### 生命周期与并发 +激活期间收到的消息会等待服务就绪,再请求内置回退标题。提供方在服务就绪后注册;此前的消息和请求不会追溯触发提供方。激活失败、服务卸载或 Session 移除都会阻止延后的回退标题写入。 + 每个会话的工作状态维护一个修订计数器、一个进行中的回退,以及待处理与活跃的提供方工作。较新的用户消息、提供方 dispose(资源释放)、会话 dispose 或显式刷新都会通过 `AbortController` 中止旧工作;提供方、修订、会话或信号已陈旧的完成结果无法追加。显式刷新会在提供方工作之前预留修订号;重叠的自动/显式回退请求共享一个会话本地正在进行的追加操作。服务拆卸会取消排队工作,并在卸载完成前等待不响应取消的调用结算。 ### 规范化 diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index b2d43669d1..86cfce0058 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -345,29 +345,11 @@ export class SessionTitleService extends Service { this.work.clear() }, 'sessionTitle lifecycle') - await ctx.sessionProjections.register(titleProjectionDefinition) - - this.titleInputProjectionHandle = await ctx.sessionProjections.register<'titleInput', TitleInputState>({ - key: 'titleInput', - stateVersion: 3, - stateSchema: titleInputStateSchema, - init: () => EMPTY_TITLE_INPUT, - apply: (state, event) => { - const message = sessionTitleUserMessageOf(event) - if (message === undefined) return state - return { - first: state.first ?? message, - count: state.count + 1, - lastSeq: message.seq, - } - }, - }) - + // oxlint-disable-next-line typescript/no-misused-promises -- session/event reports rejected observer promises without awaiting them. ctx.on('session/event', (session, event) => { switch (event.type) { case 'user/message': - this.onUserMessage(session, event) - break + return this.onUserMessage(session, event) case 'request/header': this.onRequestHeader(session, event) break @@ -385,6 +367,24 @@ export class SessionTitleService extends Service { state.active?.controller.abort(new Error('session disposed during title generation')) this.work.delete(session) }) + + await ctx.sessionProjections.register(titleProjectionDefinition) + + this.titleInputProjectionHandle = await ctx.sessionProjections.register<'titleInput', TitleInputState>({ + key: 'titleInput', + stateVersion: 3, + stateSchema: titleInputStateSchema, + init: () => EMPTY_TITLE_INPUT, + apply: (state, event) => { + const message = sessionTitleUserMessageOf(event) + if (message === undefined) return state + return { + first: state.first ?? message, + count: state.count + 1, + lastSeq: message.seq, + } + }, + }) } /** @@ -506,10 +506,15 @@ export class SessionTitleService extends Service { return dispose } - /** Schedule fallback creation and any provider cadence for one eligible event. */ - private onUserMessage(session: Session, event: Extract): void { - if (!this.serviceActive()) return + /** Schedule current-provider work; activation-time messages only request the fallback. */ + private async onUserMessage(session: Session, event: Extract): Promise { if (event.data.source.kind !== 'user' || sessionTitleUserMessageOf(event) === undefined) return + if (this.ownerFiber.state === FiberState.LOADING) { + await this.ownerFiber.await() + if (this.serviceActive() && this.ctx.sessions.get(session.id) === session) await this.ensureFallback(session) + return + } + if (!this.serviceActive()) return // A user rename pins the title: no automatic revision may override it. if (this.get(session)?.source.kind === 'user') return const registration = this.registration diff --git a/packages/session/session-title/tests/initialization.spec.ts b/packages/session/session-title/tests/initialization.spec.ts new file mode 100644 index 0000000000..4bb8181799 --- /dev/null +++ b/packages/session/session-title/tests/initialization.spec.ts @@ -0,0 +1,179 @@ +/** Live title input remains owned while projection registration is pending. */ +import { Context, FiberState } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import LlmRuntime, { createUserMessage, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' +import SessionStore, { type Session } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SessionTitleService, { SessionTitleProviderId, type SessionTitleProviderRequest } from '@deepseek-ai/dsh-session-title' +import { deepFreeze } from '@deepseek-ai/dsh-util-values' +import { describe, expect, it, onTestFinished, vi } from 'vitest' + +const CONFIG = { fallbackMaxWords: 8, fallbackMaxBytes: 64, maxTitleBytes: 256 } + +async function setup(key: 'title' | 'titleInput') { + const ctx = new Context() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(async () => { + release.resolve(undefined) + await ctx.fiber.dispose() + }) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LlmRuntime) + const register = ctx.sessionProjections.register.bind(ctx.sessionProjections) + const spy = vi.spyOn(ctx.sessionProjections, 'register').mockImplementation(async (definition) => { + const handle = await register(definition) + if (definition.key === key) { + entered.resolve(undefined) + await release.promise + } + return handle + }) + onTestFinished(() => { spy.mockRestore() }) + return { ctx, entered: entered.promise, release } +} + +function message(session: Session, text: string) { + return session.append('user/message', createUserMessage({ + content: [{ type: 'text', text }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) +} + +function titleWritten(ctx: Context, session: Session, kind: 'fallback' | 'provider') { + const written = Promise.withResolvers() + ctx.on('session/event', (target, event) => { + if (target === session && event.type === 'session/title' && event.data.source.kind === kind) written.resolve(undefined) + }) + return written.promise +} + +describe('SessionTitleService activation', () => { + it.each(['title', 'titleInput'] as const)('retains the first message while %s registration is pending', async (key) => { + const { ctx, entered, release } = await setup(key) + const fiber = ctx.plugin(SessionTitleService, CONFIG) + await entered + expect(ctx.get('sessionTitle')).toBeUndefined() + const session = ctx.sessions.create() + const written = titleWritten(ctx, session, 'fallback') + const first = message(session, 'First message during activation') + message(session, 'Second message during activation') + + release.resolve(undefined) + await fiber + await written + + expect(ctx.sessionTitle.get(session)).toMatchObject({ + title: 'First message during activation', messageSeqs: [first.seq], source: { kind: 'fallback' }, + }) + expect(session.snapshotEvents().filter(event => event.type === 'session/title')).toHaveLength(1) + }) + + it('uses the fallback for input received before a provider registers', async () => { + const { ctx, entered, release } = await setup('titleInput') + const requests: SessionTitleProviderRequest[] = [] + const provider = ctx.inject(['sessionTitle'], (owner) => { + owner.sessionTitle.register({ + id: SessionTitleProviderId('activation'), automatic: 'all-prompts', + async generate(request) { + requests.push(request) + return { title: 'Generated after activation', messageSeqs: request.messages.map(item => item.seq) } + }, + }) + }) + const fiber = ctx.plugin(SessionTitleService, CONFIG) + await entered + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1 }) + session.append('step/start', { turn: 1, step: 1 }) + const fallbackWritten = titleWritten(ctx, session, 'fallback') + const first = message(session, 'Prompt before provider registration') + const route = { provider: 'main-provider', model: 'main-model' } + session.append('request/header', { header: { config: route }, reason: 'initial' }) + void ctx.llm.stream(markAgentLoopRequest(deepFreeze({ ...route, sessionId: session.id, messages: session.deriveMessages() }))) + + release.resolve(undefined) + await fiber + await provider + await fallbackWritten + + expect(requests).toEqual([]) + expect(ctx.sessionTitle.get(session)).toMatchObject({ messageSeqs: [first.seq], source: { kind: 'fallback' } }) + + const providerWritten = titleWritten(ctx, session, 'provider') + const later = message(session, 'Prompt after provider registration') + void ctx.llm.stream(markAgentLoopRequest(deepFreeze({ ...route, sessionId: session.id, messages: session.deriveMessages() }))) + await providerWritten + + expect(requests).toHaveLength(1) + expect(requests[0]).toMatchObject({ route, messages: [ + { seq: first.seq, text: 'Prompt before provider registration' }, + { seq: later.seq, text: 'Prompt after provider registration' }, + ] }) + }) + + it('does not title a Session disposed before activation', async () => { + const { ctx, entered, release } = await setup('title') + const fiber = ctx.plugin(SessionTitleService, CONFIG) + await entered + const removed = ctx.sessions.prepare() + const detach = ctx.sessions.enter(removed) + ctx.sessions.announce(removed) + message(removed, 'Removed during activation') + detach() + const live = ctx.sessions.create() + const written = titleWritten(ctx, live, 'fallback') + message(live, 'Still live') + + release.resolve(undefined) + await fiber + await written + + expect(ctx.sessionTitle.get(removed)).toBeUndefined() + expect(ctx.sessionTitle.get(live)?.title).toBe('Still live') + }) + + it.each(['dispose', 'reject'] as const)('does not write a title after activation %s', async (ending) => { + const { ctx, entered, release } = await setup('titleInput') + const fiber = ctx.plugin(SessionTitleService, CONFIG) + await entered + const session = ctx.sessions.create() + message(session, 'Never commit this title') + if (ending === 'dispose') { + const disposal = fiber.dispose() + release.resolve(undefined) + await disposal + } else { + const failed = expect(Promise.resolve(fiber)).rejects.toThrow('registration failed') + release.reject(new Error('registration failed')) + await failed + } + + expect(session.snapshotEvents().filter(event => event.type === 'session/title')).toEqual([]) + expect(ctx.get('sessionTitle')).toBeUndefined() + }) + + it('reports one deferred fallback failure and still titles another Session', async () => { + const { ctx, entered, release } = await setup('titleInput') + const warn = vi.spyOn(ctx.logger, 'warn') + onTestFinished(() => { warn.mockRestore() }) + ctx.on('internal/status', (fiber) => { + if (fiber.runtime?.callback !== SessionTitleService || fiber.state !== FiberState.ACTIVE) return + const get = vi.spyOn(ctx.sessionTitle, 'get').mockImplementationOnce(() => { throw new Error('title read failed') }) + onTestFinished(() => { get.mockRestore() }) + }) + const fiber = ctx.plugin(SessionTitleService, CONFIG) + await entered + message(ctx.sessions.create(), 'Failed title read') + const live = ctx.sessions.create() + const written = titleWritten(ctx, live, 'fallback') + message(live, 'Unaffected title') + release.resolve(undefined) + await fiber + await written + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('session/event listener rejected: Error: title read failed')) + expect(ctx.sessionTitle.get(live)?.title).toBe('Unaffected title') + }) +}) From 05f1066f8c02b90e58ace6d2104338a8a3c19650 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:26:29 +0800 Subject: [PATCH 33/37] fix(permission-presets): reject publication before initialization --- .../permission-presets/README.i18n.yaml | 4 +- .../interaction/permission-presets/README.md | 2 +- .../permission-presets/README.zh.md | 2 +- .../permission-presets/src/index.ts | 14 ++- .../tests/initialization.spec.ts | 102 ++++++++++++++++++ 5 files changed, 115 insertions(+), 9 deletions(-) create mode 100644 packages/interaction/permission-presets/tests/initialization.spec.ts diff --git a/packages/interaction/permission-presets/README.i18n.yaml b/packages/interaction/permission-presets/README.i18n.yaml index 46968ede01..5ba3626c3f 100644 --- a/packages/interaction/permission-presets/README.i18n.yaml +++ b/packages/interaction/permission-presets/README.i18n.yaml @@ -42,8 +42,8 @@ en: 76deeb5fbcdf7525 zh: e96ad7e107947c23 /deepseek-ai-dsh-permission-presets/understand-the-implementation/session-pinning-and-blank-reuse: - en: 893cea88bce22903 - zh: 2431e19ced670511 + en: 048f156ac376668a + zh: 62399824edc1cda6 /deepseek-ai-dsh-permission-presets/understand-the-implementation/catalog-projection-and-optional-command: en: 341f80202edd66a3 zh: bd8ebb9d011b547f diff --git a/packages/interaction/permission-presets/README.md b/packages/interaction/permission-presets/README.md index d1179a9926..5b8e78077c 100644 --- a/packages/interaction/permission-presets/README.md +++ b/packages/interaction/permission-presets/README.md @@ -92,7 +92,7 @@ The observable behavior is covered in [Use this package](#use-this-package); thi ### Session pinning and blank reuse -Mounting pins every live and future session: a genuinely fresh session gains the configured default preset and both knob facts, while seeded or partially initialized sessions keep their effective knob values and gain only missing durable facts. The projection-owned seed marker makes this decision from the same incremental state as the knob values. A stored `auto` identity fails publication when the Auto integration is absent or rejects admission; the service neither rewrites it nor silently derives Full access. +Mounting pins every live and future session: a genuinely fresh session gains the configured default preset and both knob facts, while seeded or partially initialized sessions keep their effective knob values and gain only missing durable facts. The projection-owned seed marker makes this decision from the same incremental state as the knob values. The synchronous creation listener is installed before projection registration starts. While its reader is not ready, Session creation fails and Agent creation rolls back; callers must await plugin activation before creating or restoring sessions. A stored `auto` identity fails publication when the Auto integration is absent or rejects admission; the service neither rewrites it nor silently derives Full access. ### Catalog, projection, and optional command diff --git a/packages/interaction/permission-presets/README.zh.md b/packages/interaction/permission-presets/README.zh.md index 6afa0a0163..73e83dd68a 100644 --- a/packages/interaction/permission-presets/README.zh.md +++ b/packages/interaction/permission-presets/README.zh.md @@ -92,7 +92,7 @@ kind: "package-reference" ### 会话固定与空白复用 -挂载时会固定所有存活与未来的会话:真正全新的会话获得配置默认预设与两个旋钮事实,而 seed 会话或部分初始化的会话保留其有效旋钮值,只补充缺失的持久事实。投影自有的 seed 标记让该判断与旋钮值共用同一份增量状态。存储的 `auto` 身份在 Auto integration 缺失或拒绝准入时无法发布;服务既不会改写它,也不会静默推导为 Full access。 +挂载时会固定所有存活与未来的会话:真正全新的会话获得配置默认预设与两个旋钮事实,而 seed 会话或部分初始化的会话保留其有效旋钮值,只补充缺失的持久事实。投影自有的 seed 标记让该判断与旋钮值共用同一份增量状态。同步创建监听器在投影注册开始前安装。读取句柄尚未就绪时,Session 创建失败,Agent 创建回滚;调用方必须等待插件激活完成后再创建或恢复会话。存储的 `auto` 身份在 Auto integration 缺失或拒绝准入时无法发布;服务既不会改写它,也不会静默推导为 Full access。 ### 目录、投影与可选命令 diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts index 935de3d5fa..de98208579 100644 --- a/packages/interaction/permission-presets/src/index.ts +++ b/packages/interaction/permission-presets/src/index.ts @@ -197,7 +197,7 @@ export class PermissionPresetService extends TypertRemoteService { private autoAdmit: (() => void) | undefined private defaultSettings: () => PermissionSettings - private permissionProjectionHandle!: ProjectionHandle + private permissionProjectionHandle: ProjectionHandle | undefined constructor(ctx: Context, config: Config) { super(ctx, 'permissionPresets') @@ -229,6 +229,9 @@ export class PermissionPresetService extends TypertRemoteService { protected async [Service.init](): Promise { const ctx = this.ctx + ctx.on('session/created', (session) => { + this.pinInitialPermission(session) + }) const selectionSchema = zod.object({ currentValue: zod.string().min(1), }) as zod.ZodType @@ -240,9 +243,6 @@ export class PermissionPresetService extends TypertRemoteService { apply: applyPermissionEvent, wire: { viewSchema: selectionSchema, view: state => ({ currentValue: this.derive(state) }) }, }) - ctx.on('session/created', (session) => { - this.pinInitialPermission(session) - }) for (const session of ctx.sessions.list()) { this.pinInitialPermission(session) } @@ -324,6 +324,9 @@ export class PermissionPresetService extends TypertRemoteService { } private permissionState(session: Session): PermissionProjectionState { + if (this.permissionProjectionHandle === undefined) { + throw new Error('permission: presets are not ready; await plugin activation before creating or restoring a session') + } return this.permissionProjectionHandle.stateOf(session) } @@ -419,7 +422,8 @@ export class PermissionPresetService extends TypertRemoteService { * initialized sessions preserve their effective knob values and only gain * the missing durable facts. A stored Auto identity requires its live * integration and passes its admission check before - * publication. + * publication. Creation while the permission reader is still registering + * fails synchronously so the publishing caller can roll back. */ private pinInitialPermission(session: Session): void { const state = this.permissionState(session) diff --git a/packages/interaction/permission-presets/tests/initialization.spec.ts b/packages/interaction/permission-presets/tests/initialization.spec.ts new file mode 100644 index 0000000000..82a41f49ff --- /dev/null +++ b/packages/interaction/permission-presets/tests/initialization.spec.ts @@ -0,0 +1,102 @@ +/** Permission admission must finish before a Session or Agent is published. */ +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import PermissionPresetService, { AUTO_PRESET } from '@deepseek-ai/dsh-permission-presets' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import ApprovalService from '@deepseek-ai/dsh-user-approval' +import { describe, expect, it, onTestFinished, vi } from 'vitest' + +async function setup() { + const ctx = new Context() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(async () => { + release.resolve(undefined) + await ctx.fiber.dispose() + }) + await mountAgentLoopTestDependencies(ctx, { workingDirectory: true }) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(ApprovalService, { policy: 'ask' }) + ctx.provide('shell', { + sandboxMode: 'workspace-write', + resolve() { throw new Error('permission admission tests do not execute shell commands') }, + run() { throw new Error('permission admission tests do not execute shell commands') }, + start() { throw new Error('permission admission tests do not execute shell commands') }, + }) + const register = ctx.sessionProjections.register.bind(ctx.sessionProjections) + const spy = vi.spyOn(ctx.sessionProjections, 'register').mockImplementation(async (definition) => { + const handle = await register(definition) + if (definition.key === 'permissions') { + entered.resolve(undefined) + await release.promise + } + return handle + }) + onTestFinished(() => { spy.mockRestore() }) + const fiber = ctx.plugin(PermissionPresetService) + await entered.promise + return { ctx, fiber, release } +} + +function autoSeed() { + const source = Session.create(SessionId('auto-source')) + source.append('permission/preset', { preset: AUTO_PRESET }) + source.append('sandbox/mode', { mode: 'danger-full-access' }) + source.append('approval/policy', { policy: 'never' }) + return source.snapshotEvents() +} + +describe('permission admission during activation', () => { + it('rejects synchronous Session creation until the permission reader is ready', async () => { + const { ctx, fiber, release } = await setup() + const id = SessionId('pending-session') + expect(() => ctx.sessions.create(id)).toThrow(/permission:.*not ready/) + expect(ctx.sessions.get(id)).toBeUndefined() + + release.resolve(undefined) + await fiber + const session = ctx.sessions.create(id) + expect(ctx.permissionPresets.current(session)).toBe('workspace-write') + expect(ctx.approval.overrideOf(session)).toBe('ask') + expect(session.snapshotEvents().map(event => event.type)).toEqual([ + 'permission/preset', 'sandbox/mode', 'approval/policy', + ]) + }) + + it.each(['fresh', 'auto'] as const)('rolls back %s Agent creation before publication while registration is pending', async (kind) => { + const { ctx, fiber, release } = await setup() + const id = SessionId('pending-agent') + const options = { sessionId: id, ...kind === 'auto' ? { seed: autoSeed() } : {} } + const published = vi.fn() + ctx.on('agent/created', published) + + await expect(ctx.agents.create(options).then(() => 'published')).rejects.toThrow(/permission:.*not ready/) + expect(published).not.toHaveBeenCalled() + expect(ctx.agents.get(id)).toBeUndefined() + expect(ctx.sessions.get(id)).toBeUndefined() + + release.resolve(undefined) + await fiber + expect(ctx.agents.get(id)).toBeUndefined() + expect(ctx.sessions.get(id)).toBeUndefined() + + if (kind === 'auto') { + await expect(ctx.agents.create(options)).rejects.toThrow(/cannot restore preset "auto"/) + expect(published).not.toHaveBeenCalled() + expect(ctx.agents.get(id)).toBeUndefined() + expect(ctx.sessions.get(id)).toBeUndefined() + const admit = vi.fn() + ctx.permissionPresets.registerAuto(admit) + const { agent } = await ctx.agents.create(options) + expect(admit).toHaveBeenCalledTimes(1) + expect(ctx.permissionPresets.current(agent.session)).toBe(AUTO_PRESET) + expect(ctx.approval.overrideOf(agent.session)).toBe('never') + } else { + const { agent } = await ctx.agents.create(options) + expect(ctx.permissionPresets.current(agent.session)).toBe('workspace-write') + expect(ctx.approval.overrideOf(agent.session)).toBe('ask') + } + expect(published).toHaveBeenCalledTimes(1) + }) +}) From 2f30c684acc184d3a22acef9356bcf5289675cb7 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:26:30 +0800 Subject: [PATCH 34/37] fix(time-context): await projection readiness for initial requests --- .../context/time-context/README.i18n.yaml | 4 +- packages/context/time-context/README.md | 2 + packages/context/time-context/README.zh.md | 2 + packages/context/time-context/src/index.ts | 7 +- .../time-context/tests/initialization.spec.ts | 68 +++++++++++++++++++ 5 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 packages/context/time-context/tests/initialization.spec.ts diff --git a/packages/context/time-context/README.i18n.yaml b/packages/context/time-context/README.i18n.yaml index 0c6f23cd4b..00bb63502d 100644 --- a/packages/context/time-context/README.i18n.yaml +++ b/packages/context/time-context/README.i18n.yaml @@ -36,8 +36,8 @@ en: 269d7aa1f64c4278 zh: 086018a60cc3a351 /deepseek-ai-dsh-time-context/understand-the-implementation/main-flow: - en: ae9f13873dde5271 - zh: 873f32e67f8b202b + en: f09994721526e830 + zh: 6f1a463355111513 /deepseek-ai-dsh-time-context/further-exploration: en: e1cd55f40edbb7ed zh: 8a2abf22ef859841 diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index 4d03796d30..cc5e40fda8 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -76,6 +76,8 @@ The plugin prepends an `agent/pre-step` listener that delegates first and append ### Main flow +The pre-step listener is installed before awaiting projection registration. Steps arriving during activation wait for the reader; registration failure or owner disposal stops the request, and cancellation while waiting produces no reading. + When an injection is due, the plugin samples the wall clock, derives the browser-zone policy from the open turn's `user-rpc` messages, resolves the display zone (request-local or fallback), and renders the three-line reading. Positive-interval scheduling scans raw durable session events for the latest plugin-attributed message — including one shadowed by compaction — so the schedule survives resume without a process-local cache. A reading records an entered step, not a completed or transmitted request; a later preparation failure can leave it in history. diff --git a/packages/context/time-context/README.zh.md b/packages/context/time-context/README.zh.md index b55a778d5c..f392be5216 100644 --- a/packages/context/time-context/README.zh.md +++ b/packages/context/time-context/README.zh.md @@ -76,6 +76,8 @@ kind: "package-reference" ### 主要流程 +pre-step 监听器在等待投影注册前安装。激活期间到达的步骤会等待读取句柄;注册失败或所属插件释放会终止请求,等待期间取消不会产生读数。 + 需要注入时,插件采样挂钟时间,从开放轮次的 `user-rpc` 消息派生浏览器时区策略,解析显示时区(请求本地或回退),并渲染三行读数。正数间隔调度会扫描原始持久会话事件,查找最新一条归因于插件的消息——包括被压缩(compaction)遮蔽的读数——因此调度无需进程本地缓存也能在恢复后存续。读数记录的是已进入的步骤,不是已完成或已传输的请求;后续准备失败时,该读数可能留在历史中。 diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 69eb467fc6..03bc586d45 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -115,6 +115,8 @@ function validateRefreshInterval(refreshIntervalMs: number): void { /** * Register a prepended pre-step listener for the lifetime of `ctx`. + * Steps admitted during activation await the projection registration; a + * registration failure or owner disposal prevents the request from proceeding. * @param ctx - plugin context; the listener is disposed with it. * @param config - time zone and durable refresh scheduling configuration. * @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved. @@ -145,7 +147,7 @@ export async function apply(ctx: Context, config: Config): Promise { return created } - const timeContextProjectionHandle = await ctx.sessionProjections.register({ + const projectionReady = ctx.sessionProjections.register({ key: 'timeContext', stateVersion: 2, stateSchema: timeContextStateSchema, @@ -177,6 +179,8 @@ export async function apply(ctx: Context, config: Config): Promise { { agent, turn, step, signal }, next, ): Promise => { + const timeContextProjectionHandle = await projectionReady + ctx.fiber.assertActive() const decision = await next() if (decision.kind === 'reject' || signal.aborted) return decision const now = Date.now() @@ -214,4 +218,5 @@ export async function apply(ctx: Context, config: Config): Promise { ], } }, { prepend: true }) + await projectionReady } diff --git a/packages/context/time-context/tests/initialization.spec.ts b/packages/context/time-context/tests/initialization.spec.ts new file mode 100644 index 0000000000..ab3d404ce4 --- /dev/null +++ b/packages/context/time-context/tests/initialization.spec.ts @@ -0,0 +1,68 @@ +/** Real Agent requests overlapping time-context projection registration. */ +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import * as timeContext from '@deepseek-ai/dsh-time-context' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +describe('time-context activation', () => { + it.each(['ready', 'cancelled', 'disposed', 'failed'] as const)('handles a %s registration before the first request', async (outcome) => { + const ctx = new Context() + const registered = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(async () => { + release.resolve(undefined) + await ctx.fiber.dispose() + }) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + const adapter = new MockAdapter([textResponse('ready')]) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = await ctx.agentLoop.create(SessionId('initial-time'), { provider: 'mock', model: 'mock' }) + const register = ctx.sessionProjections.register.bind(ctx.sessionProjections) + const spy = vi.spyOn(ctx.sessionProjections, 'register').mockImplementation(async (definition) => { + const handle = await register(definition) + if (definition.key === 'timeContext') { + registered.resolve(undefined) + await release.promise + } + return handle + }) + onTestFinished(() => { spy.mockRestore() }) + const fiber = ctx.plugin(timeContext, { timeZone: 'UTC' }) + await registered.promise + const preparing = Promise.withResolvers() + ctx.on('agent/pre-step', (_payload, next) => { + preparing.resolve(undefined) + return next() + }, { prepend: true }) + const errors: unknown[] = [] + ctx.on('agent/error', ({ error }) => { errors.push(error) }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'what time is it?' }], source: { kind: 'user' } })) + await preparing.promise + + if (outcome === 'cancelled') agent.cancel({ kind: 'user' }) + const disposal = outcome === 'disposed' ? fiber.dispose() : undefined + if (outcome === 'failed') { + const rejected = expect(Promise.resolve(fiber)).rejects.toThrow('time registration failed') + release.reject(new Error('time registration failed')) + await rejected + } else { + release.resolve(undefined) + await (disposal ?? fiber) + } + await agent.whenIdle() + + const readings = agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'time-context') + expect(readings).toHaveLength(outcome === 'ready' ? 1 : 0) + expect(adapter.requests).toHaveLength(outcome === 'ready' ? 1 : 0) + if (outcome === 'ready') { + expect(adapter.requests[0]?.messages.flatMap(message => message.content) + .some(block => block.type === 'text' && block.text.startsWith('Time sampled while preparing turn 1, step 1:'))).toBe(true) + } + if (outcome === 'failed') expect(errors).toContainEqual(new Error('time registration failed')) + }) +}) From 10ec80ce4817183ce13318232b125c6a032ced54 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:26:30 +0800 Subject: [PATCH 35/37] fix(tmux-context): await projection readiness before querying --- .../context/tmux-context/README.i18n.yaml | 4 +- packages/context/tmux-context/README.md | 2 + packages/context/tmux-context/README.zh.md | 2 + packages/context/tmux-context/src/index.ts | 9 ++- .../tmux-context/tests/tmux-context.spec.ts | 61 ++++++++++++++++++- 5 files changed, 72 insertions(+), 6 deletions(-) diff --git a/packages/context/tmux-context/README.i18n.yaml b/packages/context/tmux-context/README.i18n.yaml index 5535862beb..4a14f8421c 100644 --- a/packages/context/tmux-context/README.i18n.yaml +++ b/packages/context/tmux-context/README.i18n.yaml @@ -36,8 +36,8 @@ en: 14bc5e3279f9da27 zh: 90953dfbf892033a /deepseek-ai-dsh-tmux-context/understand-the-implementation/main-flow: - en: 2d5768fe44120780 - zh: cd601c59d9604e6e + en: ffe590c1e3c46225 + zh: 5c0acd97c4bad094 /deepseek-ai-dsh-tmux-context/further-exploration: en: 08c09f5eb13c30e6 zh: 1b34ef9ca42ac9e7 diff --git a/packages/context/tmux-context/README.md b/packages/context/tmux-context/README.md index 50c3f47312..b2434f0321 100644 --- a/packages/context/tmux-context/README.md +++ b/packages/context/tmux-context/README.md @@ -73,6 +73,8 @@ The plugin prepends an `agent/pre-step` listener that runs only on the first ste ### Main flow +The pre-step listener is installed before awaiting projection registration. Steps arriving during activation wait for the reader; registration failure or owner disposal stops the request, and cancellation while waiting performs no shell query or context injection. + At the first step of a turn, the listener checks whether an injection is due, queries the location through `ctx.shell`, and compares the rendered state with the latest durable injection of this source. Change suppression and interval scheduling scan the raw durable session events, so the schedule survives compaction and resumed processes without process-local cache state; sessions schedule independently. A downstream pre-step listener that rejects or fails prevents the reading from being recorded. diff --git a/packages/context/tmux-context/README.zh.md b/packages/context/tmux-context/README.zh.md index 0f4e707d40..9076c11224 100644 --- a/packages/context/tmux-context/README.zh.md +++ b/packages/context/tmux-context/README.zh.md @@ -73,6 +73,8 @@ kind: "package-reference" ### 主要流程 +pre-step 监听器在等待投影注册前安装。激活期间到达的步骤会等待读取句柄;注册失败或所属插件释放会终止请求,等待期间取消不会执行 shell 查询或注入上下文。 + 在每轮的第一个步骤,监听器检查注入是否到期,通过 `ctx.shell` 查询位置,并把渲染状态与该来源最近一次持久注入比较。变化抑制与间隔调度会扫描原始持久会话事件,因此调度可跨压缩(compaction)与恢复的进程存续,无需进程内缓存状态;各会话独立调度。下游在步骤前运行的监听器拒绝或失败时,该读数不会被记录。 diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts index 02c756f90f..9e3b69d8b0 100644 --- a/packages/context/tmux-context/src/index.ts +++ b/packages/context/tmux-context/src/index.ts @@ -210,16 +210,18 @@ const tmuxContextStateSchema = zod.object({ /** * Register a prepended pre-step listener for the lifetime of `ctx`. + * Steps admitted during activation await the projection registration; a + * registration failure or owner disposal prevents the request from proceeding. * @param ctx - plugin context; the listener is disposed with it. * @param config - durable refresh scheduling configuration. - * @returns when the listener is registered. + * @returns when the listener and projection are ready. * @throws when the refresh interval is invalid. */ export async function apply(ctx: Context, config: Config): Promise { const refreshIntervalMs = config.refreshIntervalMs validateRefreshInterval(refreshIntervalMs) - const tmuxContextProjectionHandle = await ctx.sessionProjections.register({ + const projectionReady = ctx.sessionProjections.register({ key: 'tmuxContext', stateVersion: 1, stateSchema: tmuxContextStateSchema, @@ -239,6 +241,8 @@ export async function apply(ctx: Context, config: Config): Promise { { agent, turn, step, signal }, next, ): Promise => { + const tmuxContextProjectionHandle = await projectionReady + ctx.fiber.assertActive() const decision = await next() if (decision.kind === 'reject' || signal.aborted || step !== 1) return decision const bash = ctx.get('shell') @@ -264,4 +268,5 @@ export async function apply(ctx: Context, config: Config): Promise { ], } }, { prepend: true }) + await projectionReady } diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index 33a7aabeba..3c1444e25c 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' @@ -8,7 +8,9 @@ import { ShellExecutor } from '@deepseek-ai/dsh-shell' import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell' import * as tmuxContext from '@deepseek-ai/dsh-tmux-context' import type { Config } from '@deepseek-ai/dsh-tmux-context' -import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' +import { mountAgentLoopTestDependencies, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** Empty offset readers for fakes that never produce output. */ const silentReader = { readFrom: (fromByte: number) => ({ text: '', nextOffset: fromByte, lossy: false }) } @@ -167,6 +169,61 @@ afterEach(() => { }) describe('tmux-context injection', () => { + it.each(['ready', 'cancelled', 'disposed', 'failed'] as const)('handles a %s registration before querying the first turn location', async (outcome) => { + const ctx = new Context() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(async () => { + release.resolve(undefined) + await ctx.fiber.dispose() + }) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(FakeBash) + const bash = ctx.shell as FakeBash + const adapter = new MockAdapter([textResponse('ready')]) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = await ctx.agentLoop.create(SessionId('initial-tmux'), { provider: 'mock', model: 'mock' }) + const register = ctx.sessionProjections.register.bind(ctx.sessionProjections) + vi.spyOn(ctx.sessionProjections, 'register').mockImplementation(async (definition) => { + const handle = await register(definition) + if (definition.key === 'tmuxContext') { + entered.resolve(undefined) + await release.promise + } + return handle + }) + const fiber = ctx.plugin(tmuxContext, {}) + await entered.promise + const preparing = Promise.withResolvers() + ctx.on('agent/pre-step', (_payload, next) => { + preparing.resolve(undefined) + return next() + }, { prepend: true }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'where is this terminal?' }], source: { kind: 'user' } })) + await preparing.promise + if (outcome === 'cancelled') agent.cancel({ kind: 'user' }) + const disposal = outcome === 'disposed' ? fiber.dispose() : undefined + if (outcome === 'failed') { + const rejected = expect(Promise.resolve(fiber)).rejects.toThrow('tmux registration failed') + release.reject(new Error('tmux registration failed')) + await rejected + } else { + release.resolve(undefined) + await (disposal ?? fiber) + } + await agent.whenIdle() + + expect(bash.commands).toHaveLength(outcome === 'ready' ? 1 : 0) + expect(contextTexts(agent.session)).toHaveLength(outcome === 'ready' ? 1 : 0) + expect(adapter.requests).toHaveLength(outcome === 'ready' ? 1 : 0) + if (outcome === 'ready') { + expect(adapter.requests[0]?.messages.flatMap(message => message.content)).toContainEqual({ + type: 'text', text: contextTexts(agent.session)[0], + }) + } + }) + it('injects the tmux location on the first step of a turn', async () => { const { ctx } = await mount({}, true) const session = Session.create(SessionId('first')) From cc79cf553bd53bfaa95a7bcad19180161f1f83e6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:26:30 +0800 Subject: [PATCH 36/37] fix(llm-retry): retain recovery requests during activation --- packages/llm/llm-retry/README.i18n.yaml | 4 +- packages/llm/llm-retry/README.md | 2 + packages/llm/llm-retry/README.zh.md | 2 + packages/llm/llm-retry/src/index.ts | 9 +- .../llm-retry/tests/initialization.spec.ts | 82 +++++++++++++++++++ snapshots/session/headless.snapshot.ts | 8 ++ 6 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 packages/llm/llm-retry/tests/initialization.spec.ts diff --git a/packages/llm/llm-retry/README.i18n.yaml b/packages/llm/llm-retry/README.i18n.yaml index 6dbc01a710..691b098e1c 100644 --- a/packages/llm/llm-retry/README.i18n.yaml +++ b/packages/llm/llm-retry/README.i18n.yaml @@ -39,8 +39,8 @@ en: 9b79cdd6597930e0 zh: 0838f3efbc1e83ac /deepseek-ai-dsh-llm-retry/understand-the-implementation/recovery-flow: - en: 85bace285b029101 - zh: 6e0237450ca6ccc0 + en: 4ba0a3ef069bd416 + zh: e1cf971729a8717c /deepseek-ai-dsh-llm-retry/understand-the-implementation/waterfall-composition: en: 388e8db333e03cb1 zh: 54b6446ac3584724 diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 2393ffbfea..3b1455864e 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -81,6 +81,8 @@ The executor is built on one rule: **durable before wait, open-step boundaries.* ### Recovery flow +The recovery listener is installed before awaiting projection registration. Failures arriving during activation wait for the reader before applying the provider policy. Registration failure propagates to the agent; cancellation or plugin disposal while waiting prevents retry records and further attempts. + A failed step arrives on the waterfall with its provider and resolved policy. Always mode settles downstream recovery first and honors a downstream `retry` decision; normal mode first checks that the failure code is eligible and the budget is not exhausted. The plugin computes the delay — provider `Retry-After` when valid and within bounds, otherwise local bounded exponential backoff with symmetric jitter — appends the `llm/retry` event, waits on a cancellable timer, appends `llm/retry-started`, and returns `{ kind: 'retry' }`. The loop then re-runs the failed step inside the same open turn over the same durable history. ### Waterfall composition diff --git a/packages/llm/llm-retry/README.zh.md b/packages/llm/llm-retry/README.zh.md index eef7a17aae..bbad68655d 100644 --- a/packages/llm/llm-retry/README.zh.md +++ b/packages/llm/llm-retry/README.zh.md @@ -81,6 +81,8 @@ kind: "package-reference" ### 恢复流程 +恢复监听器在等待投影注册前安装。激活期间到达的失败会先等待读取句柄,再应用提供方策略。注册失败会传播给 agent;等待期间取消或释放插件,不会写入重试记录或发起后续尝试。 + 失败步骤连同其提供方与解析后的策略一起到达 waterfall。always mode 先结算下游恢复,并遵循下游的 `retry` 决定;normal mode 先检查失败 code 是否合格、预算是否未耗尽。插件计算延迟——有效且在边界内的提供方 `Retry-After`,否则带对称抖动的本地有界指数退避——追加 `llm/retry` 事件,在可取消定时器上等待,追加 `llm/retry-started`,然后返回 `{ kind: 'retry' }`。loop 随后在同一个打开的轮次内重跑失败步骤(仍基于同一份持久历史)。 ### Waterfall 组合 diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 0c181260ce..45fa11c5d4 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -110,6 +110,8 @@ const llmRetryStateSchema: zod.ZodType = zod.record(zod.string(), /** * Install provider-routed normal or unbounded request recovery. + * Recovery admitted during activation awaits projection registration. A + * registration failure propagates; cancellation or disposal suppresses retries. * @param ctx - plugin context that owns the listener and active waits. * @param config - empty executor config; provider registrations own policy. * @param internals - non-serializable deterministic hooks for tests. @@ -117,7 +119,7 @@ const llmRetryStateSchema: zod.ZodType = zod.record(zod.string(), */ export async function apply(ctx: Context, config: Config = {}, internals: RetryInternals = {}): Promise { validateConfig(config) - const llmRetryProjectionHandle = await ctx.sessionProjections.register({ + const projectionReady = ctx.sessionProjections.register({ key: 'llmRetry', stateVersion: 1, stateSchema: llmRetryStateSchema, @@ -155,7 +157,6 @@ export async function apply(ctx: Context, config: Config = {}, internals: RetryI signal: AbortSignal, ): Promise { const fusedSignal = AbortSignal.any([signal, lifetime.signal]) - if (fusedSignal.aborted) return const eventData: LlmRetryEventData = policy.mode === 'normal' ? { retryId, @@ -191,8 +192,9 @@ export async function apply(ctx: Context, config: Config = {}, internals: RetryI next: () => Promise, ): Promise { if (policy === undefined) return next() + const llmRetryProjectionHandle = await projectionReady + if (signal.aborted || ctx.fiber.uid === null || lifetime.signal.aborted) return if (policy.mode === 'always') { - if (signal.aborted || lifetime.signal.aborted) return const fusedSignal = AbortSignal.any([signal, lifetime.signal]) // The loop and plugin lifetime stay open until delegated recovery settles. // An abort then wins before the decision or fallback can mutate later state. @@ -251,4 +253,5 @@ export async function apply(ctx: Context, config: Config = {}, internals: RetryI lifetime.abort(new Error('llm-retry plugin disposed')) await Promise.allSettled([...active]) }, 'llm-retry: abort and drain active recovery') + await projectionReady } diff --git a/packages/llm/llm-retry/tests/initialization.spec.ts b/packages/llm/llm-retry/tests/initialization.spec.ts new file mode 100644 index 0000000000..f8b88d3f9f --- /dev/null +++ b/packages/llm/llm-retry/tests/initialization.spec.ts @@ -0,0 +1,82 @@ +/** Provider recovery overlapping retry projection registration. */ +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { createUserMessage, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' +import type { ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' +import * as retry from '@deepseek-ai/dsh-llm-retry' +import { SessionId } from '@deepseek-ai/dsh-session' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +class RetryAdapter extends MockAdapter { + override providerRetryPolicy(): ResolvedRetryPolicy { + return resolveRetryPolicy({ + mode: 'normal', maxRetries: 1, + backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, + }, 'initialization test') + } +} + +describe('retry activation', () => { + it.each(['ready', 'cancelled', 'disposed', 'failed'] as const)('handles a %s registration before settling the first provider failure', async (outcome) => { + const ctx = new Context() + const registered = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(async () => { + release.resolve(undefined) + await ctx.fiber.dispose() + }) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + const adapter = new RetryAdapter([ + [{ type: 'finish', reason: { kind: 'error', failure: { code: 'SERVER', message: 'temporary failure' } } }], + textResponse('recovered'), + ]) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = await ctx.agentLoop.create(SessionId('initial-retry'), { provider: 'mock', model: 'mock' }) + const register = ctx.sessionProjections.register.bind(ctx.sessionProjections) + const spy = vi.spyOn(ctx.sessionProjections, 'register').mockImplementation(async (definition) => { + const handle = await register(definition) + if (definition.key === 'llmRetry') { + registered.resolve(undefined) + await release.promise + } + return handle + }) + onTestFinished(() => { spy.mockRestore() }) + const fiber = ctx.plugin(retry) + await registered.promise + const recovering = Promise.withResolvers() + ctx.on('agent/request-error', (_payload, next) => { + recovering.resolve(undefined) + return next() + }, { prepend: true }) + const errors: unknown[] = [] + ctx.on('agent/error', ({ error }) => { errors.push(error) }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'recover this request' }], source: { kind: 'user' } })) + await recovering.promise + + if (outcome === 'cancelled') agent.cancel({ kind: 'user' }) + const disposal = outcome === 'disposed' ? fiber.dispose() : undefined + if (outcome === 'failed') { + const rejected = expect(Promise.resolve(fiber)).rejects.toThrow('retry registration failed') + release.reject(new Error('retry registration failed')) + await rejected + } else { + release.resolve(undefined) + await (disposal ?? fiber) + } + await agent.whenIdle() + + expect(adapter.requests).toHaveLength(outcome === 'ready' ? 2 : 1) + const log = agent.session.snapshotEvents() + expect(log.filter(event => event.type === 'llm/retry')).toHaveLength(outcome === 'ready' ? 1 : 0) + expect(log.filter(event => event.type === 'llm/retry-started')).toHaveLength(outcome === 'ready' ? 1 : 0) + if (outcome === 'ready') { + expect(adapter.requests[1]?.messages).toEqual(adapter.requests[0]?.messages) + expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'recovered' }]) + } + if (outcome === 'failed') expect(errors).toContainEqual(new Error('retry registration failed')) + }) +}) diff --git a/snapshots/session/headless.snapshot.ts b/snapshots/session/headless.snapshot.ts index 503b64ecd9..9c039898f0 100644 --- a/snapshots/session/headless.snapshot.ts +++ b/snapshots/session/headless.snapshot.ts @@ -1334,6 +1334,14 @@ describe('headless recorded-session snapshots', () => { }, inspect: async (cwd) => { actualLogs = await persistedSessions(cwd) + if (scenario.name === 'empty-response-retry-current') { + const events = parseSessionLog(actualLogs[0]!.content) + expect(events.filter(event => event.type === 'assistant/attempt' || event.type === 'llm/retry' + || event.type === 'llm/retry-started' || event.type === 'assistant/message') + .map(event => event.type)).toEqual([ + 'assistant/attempt', 'llm/retry', 'llm/retry-started', 'assistant/message', + ]) + } if (retainedToolInput !== undefined) { verifyToolResultWriterParity(primaryFixture, actualLogs[0]!.content) } From d3f486e98e9fc39df705889add624603767d4beb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:26:30 +0800 Subject: [PATCH 37/37] fix(plan-mode): reject requests before guidance is ready --- apps/web/tests/plan-review.e2e.ts | 7 +++ docs/subsystems/plan.md | 2 +- docs/subsystems/plan.zh.md | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/plan/plan-mode/README.i18n.yaml | 4 +- packages/plan/plan-mode/README.md | 4 +- packages/plan/plan-mode/README.zh.md | 4 +- packages/plan/plan-mode/src/index.ts | 26 ++++---- .../plan-mode/tests/initialization.spec.ts | 62 +++++++++++++++++++ 9 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 packages/plan/plan-mode/tests/initialization.spec.ts diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index fd8217a2d8..e59c1239ba 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -87,6 +87,13 @@ describe('web e2e: plan review takeover round trip', () => { // waitFor is race-free. const card = page.locator('[data-plan-review-key]') await card.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 }) + const firstHeader = sessionEvents.find(event => event.type === 'request/header') + const firstPrompt = sessionEvents.find(event => event.type === 'system/message') + expect(firstHeader?.data.header.tools?.map(tool => tool.name)).toContain('exit_plan_mode') + expect(firstPrompt?.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('\n')) + .toContain('You are in plan mode.') + const clock = sessionEvents.find(event => event.type === 'user/message' && event.data.source.kind === 'time-context') + expect(clock?.seq).toBeLessThan(firstHeader!.seq) // The plan-review request must NOT land on the generic question flow. expect(await page.locator('[data-question-key]').count()).toBe(0) await expect.poll(() => card.getByText('Plan review').count(), { timeout: 10_000 }).toBeGreaterThan(0) diff --git a/docs/subsystems/plan.md b/docs/subsystems/plan.md index 56af5f14d2..68e982ec34 100644 --- a/docs/subsystems/plan.md +++ b/docs/subsystems/plan.md @@ -50,7 +50,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.planMode` — `PlanModeController` -`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection's cropped `{ active, pending }` view. +`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection's cropped `{ active, pending }` view. Agent prompt assembly fails while the projection reader is not ready; callers must await plugin activation before running an agent. ```ts cordis-catalog /** diff --git a/docs/subsystems/plan.zh.md b/docs/subsystems/plan.zh.md index f0bb4a0d99..25c31455ad 100644 --- a/docs/subsystems/plan.zh.md +++ b/docs/subsystems/plan.zh.md @@ -50,7 +50,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.planMode` — `PlanModeController` -`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection's cropped `{ active, pending }` view. +`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection's cropped `{ active, pending }` view. Agent prompt assembly fails while the projection reader is not ready; callers must await plugin activation before running an agent. ```ts cordis-catalog /** diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 770f814860..1bbf98afe5 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1730,7 +1730,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'planMode', summary: '`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool.', - description: '`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection\'s cropped `{ active, pending }` view.', + description: '`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection\'s cropped `{ active, pending }` view. Agent prompt assembly fails while the projection reader is not ready; callers must await plugin activation before running an agent.', methods: [ { signature: 'get(agent: Agent): { active: boolean; pending?: boolean }', diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index 5123d81ae9..da73c82206 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/README.i18n.yaml @@ -48,8 +48,8 @@ en: 56798d0f45fb5775 zh: 282e4a522ce6dae0 /deepseek-ai-dsh-plan-mode/understand-the-implementation/session-projection-unit: - en: 42189cbdbd1ef615 - zh: 711142761cc29491 + en: 396d66816890f715 + zh: fab5d67c027b33f5 /deepseek-ai-dsh-plan-mode/understand-the-implementation/source-map: en: fa97e858495a8105 zh: 8be1fbd7e420ff8a diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index dce61a5e38..6b0ecefb81 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -94,7 +94,9 @@ The command child activates only when a commands service is composed. It maps ba ### Session projection unit -The package requires `ctx.sessionProjections` and awaits registration of the `plan` and `turnBoundary` units during activation. The unit turns logged `/plan` command runs into a candidate target, commits the logged state on `plan/mode`, and derives `{ active, pending }` for `view`, where `pending` is true only while an unsettled or successful selection differs from the logged state — a pure replay quantity recoverable from the log alone. The key merges into `SessionProjectionMap` from [`src/types.ts`](src/types.ts); the framework drives the unit, and unloading the plugin fiber unregisters the key. State reads use the retained handles and remain available after unregistration. +The package requires `ctx.sessionProjections` and awaits registration of the `plan` unit during activation. Its prompt section is registered before that wait. Agent prompt assembly fails explicitly while the reader is unavailable, so a restored plan cannot run without its guidance. Callers must await plugin activation before running an agent. + +The unit turns logged `/plan` command runs into a candidate target, commits the logged state on `plan/mode`, and derives `{ active, pending }` for `view`, where `pending` is true only while an unsettled or successful selection differs from the logged state — a pure replay quantity recoverable from the log alone. The key merges into `SessionProjectionMap` from [`src/types.ts`](src/types.ts); the framework drives the unit, and unloading the plugin fiber unregisters the key. State reads use the retained handle and remain available after unregistration. ### Source map diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index b325573434..3577205475 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -94,7 +94,9 @@ agent 完成计划后,会以 markdown 形式、从标题开头书写计划并 ### 会话投影单元 -本包要求 `ctx.sessionProjections`,并在激活期间等待 `plan` 与 `turnBoundary` 单元注册完成。该单元把已记录的 `/plan` 命令运行转为候选目标,在 `plan/mode` 上提交已记录状态,并为 `view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true——这是仅凭日志即可恢复的纯回放量。key 由 [`src/types.ts`](src/types.ts) 的声明合并加入 `SessionProjectionMap`;框架负责驱动该单元,卸载插件 fiber 会注销该 key。状态读取使用保留的句柄,注销后仍然可用。 +本包要求 `ctx.sessionProjections`,并在激活期间等待 `plan` 单元注册完成。提示词段落在等待前注册。读取句柄不可用时,Agent 提示词装配会明确失败,避免恢复的计划模式缺少指引仍继续运行。调用方必须等待插件激活完成后再运行 Agent。 + +该单元把已记录的 `/plan` 命令运行转为候选目标,在 `plan/mode` 上提交已记录状态,并为 `view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true——这是仅凭日志即可恢复的纯回放量。key 由 [`src/types.ts`](src/types.ts) 的声明合并加入 `SessionProjectionMap`;框架负责驱动该单元,卸载插件 fiber 会注销该 key。状态读取使用保留的句柄,注销后仍然可用。 ### 源码地图 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index e4034fa549..cddeafab0b 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -171,13 +171,15 @@ export const planProjectionDefinition = { * `ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, * the `plan:policy` section, the `/plan` command, and the stable exit tool. * Client carriers expose the projection's cropped `{ active, pending }` view. + * Agent prompt assembly fails while the projection reader is not ready; + * callers must await plugin activation before running an agent. */ export class PlanModeController extends Service { static inject = ['tools', 'systemPrompt', 'sessionProjections', 'agents'] /** Validated deployment-owned guidance. */ private readonly section: string - private planProjectionHandle!: ProjectionHandle + private planProjectionHandle: ProjectionHandle | undefined /** * Latest selection per session awaiting the next accepted in-turn pre-step. @@ -193,6 +195,15 @@ export class PlanModeController extends Service { protected async [Service.init](): Promise { const ctx = this.ctx + ctx.systemPrompt.section({ + name: 'plan:policy', + order: ctx.systemPrompt.getSectionOrder('PLAN_POLICY'), + text: (context) => { + if (context.agent === undefined) return '' + const pending = this.pendingIntents.get(context.agent.session) + return (pending?.active ?? this.loggedActive(context.agent.session)) ? this.section : '' + }, + }) this.planProjectionHandle = await ctx.sessionProjections.register(planProjectionDefinition) let disposed = false // Pre-step is outside Session.append publication, so it can append the @@ -219,16 +230,6 @@ export class PlanModeController extends Service { }) ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close service lifetime') - ctx.systemPrompt.section({ - name: 'plan:policy', - order: ctx.systemPrompt.getSectionOrder('PLAN_POLICY'), - text: (context) => { - if (context.agent === undefined) return '' - const pending = this.pendingIntents.get(context.agent.session) - return (pending?.active ?? this.loggedActive(context.agent.session)) ? this.section : '' - }, - }) - // The command child activates only when a command registry is composed. ctx.inject(['commands'], (commandCtx) => { commandCtx.commands.register({ @@ -389,6 +390,9 @@ export class PlanModeController extends Service { } private planState(session: Session): PlanUnitState { + if (this.planProjectionHandle === undefined) { + throw new Error('plan mode is not ready; await plugin activation before running an agent') + } return this.planProjectionHandle.stateOf(session) } diff --git a/packages/plan/plan-mode/tests/initialization.spec.ts b/packages/plan/plan-mode/tests/initialization.spec.ts new file mode 100644 index 0000000000..781a42d274 --- /dev/null +++ b/packages/plan/plan-mode/tests/initialization.spec.ts @@ -0,0 +1,62 @@ +/** Prompt assembly must not omit plan guidance during activation. */ +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import PlanModeController from '@deepseek-ai/dsh-plan-mode' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +describe('plan-mode activation', () => { + it.each([false, true])('rejects premature requests with restored active=%s, then includes the complete plan contribution', async (active) => { + const ctx = new Context() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(async () => { + release.resolve(undefined) + await ctx.fiber.dispose() + }) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + const adapter = new MockAdapter([textResponse('ready')]) + ctx.llm.registerAdapter(['mock'], adapter) + const seed = Session.create(SessionId('plan-seed')) + seed.append('plan/mode', { active }) + const { agent } = await ctx.agents.create({ + sessionId: SessionId('initial-plan'), seed: seed.snapshotEvents(), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const register = ctx.sessionProjections.register.bind(ctx.sessionProjections) + const spy = vi.spyOn(ctx.sessionProjections, 'register').mockImplementation(async (definition) => { + const handle = await register(definition) + if (definition.key === 'plan') { + entered.resolve(undefined) + await release.promise + } + return handle + }) + onTestFinished(() => { spy.mockRestore() }) + const fiber = ctx.plugin(PlanModeController, { section: 'Plan the work before executing it.' }) + await entered.promise + const errors: unknown[] = [] + ctx.on('agent/error', ({ error }) => { errors.push(error) }) + const prompt = createUserMessage({ content: [{ type: 'text', text: 'continue the task' }], source: { kind: 'user' } }) + agent.followup(prompt) + await agent.whenIdle() + expect(adapter.requests).toHaveLength(0) + expect(errors.some(error => error instanceof Error && /plan.*not ready/.test(error.message))).toBe(true) + expect(agent.session.snapshotEvents().filter(event => event.type === 'request/header')).toHaveLength(0) + + release.resolve(undefined) + await fiber + agent.followup(prompt) + await agent.whenIdle() + expect(adapter.requests).toHaveLength(1) + expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toContain('exit_plan_mode') + const text = adapter.requests[0]?.messages.flatMap(message => message.content) + .filter(block => block.type === 'text').map(block => block.text).join('\n') + expect(text?.includes('Plan the work before executing it.')).toBe(active) + expect(ctx.planMode.get(agent)).toEqual({ active }) + }) +})