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:~