refactor(plugins): migrate projection consumers to retained handles

This commit is contained in:
_Kerman committed 2026-10-12 12:03:36 +08:00
1 parent 5ecf33aab1
commit 08e48f34b5
136 files changed
+1253 -1029

No files matched your search

@@ -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
@@ -27,6 +27,8 @@ Desktop analytics follows the [product collection policy](../../client/product-a
<a id="use-this-package"></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.
@@ -27,6 +27,8 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
控制器独立于预设执行能力注册 `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 的持久日志检查。
+20 -10
View File
@@ -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<ModelSelectionProjectionState>
private agentPreset!: ProjectionHandle<string | null>
private readonly resumes = new Map<SessionId, Promise<Agent>>()
private readonly creations = new Map<SessionId, Promise<Agent>>()
private readonly selections = new WeakMap<Agent, InstalledSelection>()
private readonly imageAdmissionChains = new WeakMap<Agent, Promise<void>>()
/** @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<void> {
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) {
+21 -16
View File
@@ -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<void>
private readonly fileApplications: typeof nativeFileApplications
private readonly openFileApplication: typeof openNativeFileApplication
private readonly revealPath: (path: string, signal: AbortSignal) => Promise<void>
private readonly canOpenPath: () => boolean
private readonly listWorkSliceMs: number
private readonly promotions = new Set<Promise<void>>()
/**
@@ -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<void> {
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)
+18 -14
View File
@@ -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<void> {
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,
})
}
@@ -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)
}
@@ -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'))
@@ -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({
@@ -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<undefined>()
const release = Promise.withResolvers<undefined>()
@@ -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<string, Session>()
const agents = new Map<string, { status: Agent['status'] }>()
@@ -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)
@@ -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,
})
@@ -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)
@@ -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)
@@ -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', {
@@ -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)
},
@@ -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()
@@ -23,7 +23,7 @@ async function context(): Promise<Context> {
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({
@@ -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 }
}
@@ -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<never> { return Promise.reject(new Error('unused')) }
readImage(): Promise<never> { 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))
@@ -65,14 +65,14 @@ async function liveAgent(ctx: Context, id: string, turns: number): Promise<Sessi
return session
}
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('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')
@@ -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'),
@@ -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()
@@ -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<void>
readonly canOpenPath?: () => boolean
readonly listWorkSliceMs?: number
}
const installed = new WeakMap<Context, SessionController>()
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<SessionController> {
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<void> {
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<SessionController> {
return await installControllers(ctx, defaults)
}
function remoteResult<T>(
@@ -341,11 +350,11 @@ function remoteResult<T>(
}
/** 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<TestSessionRemote> {
const direct = await createSessionTestController(ctx, defaults)
return {
workspacePathApplications: (request, signal = new AbortController().signal) => remoteResult(
() => direct.workspacePathApplications(request, signal), signal,
@@ -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 }
}
@@ -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
@@ -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 `<system-reminder>` 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 `<system-reminder>` framing and every injected message reaches the model verbatim. The package exports `AgentInstructionSource` for typed consumers of these message sources.
### Source map
@@ -85,7 +85,7 @@ export interface Config {
### 设计理念
该插件建立在一个原则上:工作区指令是持久的对话内容,按 agent 与会话分别归属。基线消息与刷新消息都是普通的带来源 `user/message` 事件,因此与其他历史一样可回放、可压缩(compaction)、可恢复,模型可见状态总能从会话日志重建。插件拥有完整的 `<system-reminder>` 框架,每条注入消息都原样到达模型。
该插件建立在一个原则上:工作区指令是持久的对话内容,按 agent 与会话分别归属。基线消息与刷新消息都是普通的带来源 `user/message` 事件,因此与其他历史一样可回放、可压缩(compaction)、可恢复,模型可见状态总能从会话日志重建。插件拥有完整的 `<system-reminder>` 框架,每条注入消息都原样到达模型。 本包导出 `AgentInstructionSource`,供这些消息来源的消费者进行类型检查。
### 源码地图
@@ -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<void> {
const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
const resolved: ResolvedConfig = resolveConfig(config)
const instructionVersions: InstructionVersionCache = new WeakMap()
const baselinePreparations = new WeakMap<Session, {
@@ -303,10 +311,7 @@ export function apply(ctx: Context, config: Config): void {
}
const stepIsOpen = (session: Session): boolean => {
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
@@ -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<Awaited<ReturnType<Context['plugin']>>> {
async function mountAgentInstructionsPlugin(ctx: Context, config: AgentInstructionsConfig): Promise<Awaited<ReturnType<Context['plugin']>>> {
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<Awaited<ReturnType<Context['plugin']>>> {
async function mountAgentInstructions(ctx: Context, config: AgentInstructionsConfig): Promise<Awaited<ReturnType<Context['plugin']>>> {
await ctx.plugin(LocalFileSystem, { cwd: '/' })
return mountAgentInstructionsPlugin(ctx, config)
}
async function mountFileToolsAndAgentInstructions(ctx: Context, config: AgentInstructions.Config): Promise<Awaited<ReturnType<Context['plugin']>>> {
async function mountFileToolsAndAgentInstructions(ctx: Context, config: AgentInstructionsConfig): Promise<Awaited<ReturnType<Context['plugin']>>> {
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)
@@ -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
@@ -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.
</details>
@@ -57,7 +57,7 @@ kind: "package-reference"
### 主流程
激活时注册投影单元、两个段落与两个上下文。每次组装时,段落在已提交的早期段落与当前说明之间选择;每次请求准入时,上下文读取公告 URL。销毁插件 fiber 会移除全部注册。
激活时等待取得投影 handle,再注册两个段落与两个上下文。每次组装时,段落读取 handle,在已提交的早期段落与当前说明之间选择;每次请求准入时,上下文读取公告 URL。销毁插件 fiber 会移除全部注册。
</details>
@@ -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<void> {
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,
+4 -4
View File
@@ -49,7 +49,6 @@ const timeContextStateSchema = zod.object({
/** Folded time-context readings. */
type TimeContextProjection = zod.infer<typeof timeContextStateSchema>
/** 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<void> {
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
@@ -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,
})
})
+7 -5
View File
@@ -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<void> {
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
@@ -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<Config> = 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<void> {
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 }
@@ -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 = []
@@ -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')
}
})
@@ -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
+2 -2
View File
@@ -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.
</details>
@@ -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.
@@ -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 事件更新读取结果。
</details>
@@ -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、任务与投影类型。
+11 -15
View File
@@ -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<void> {
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)
}
@@ -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 = <T extends TeamEventType>(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<TeamProjectionState>,
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
}
@@ -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 })
@@ -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<Agent> {
}
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<void> { 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<undefined>()
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
@@ -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
@@ -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
@@ -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) 列出了全部差异。
## 目录
@@ -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<AgentBinding> {
export async function createHostOps(options: HostOpsOptions): Promise<OpTable<AgentBinding>> {
const { ctx, aliases } = options
const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
const contextPressure = await ctx.sessionProjections.register(contextPressureProjectionDefinition)
let storeDomain: Promise<Domain<typeof storeDomainSpec>> | undefined
function store(): Promise<Domain<typeof storeDomainSpec>> {
@@ -225,11 +224,6 @@ export function createHostOps(options: HostOpsOptions): OpTable<AgentBinding> {
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<K extends keyof SessionProjectionStateMap>(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<AgentBinding> {
},
'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: {
@@ -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<AgentBinding>
private engine!: ModsEngine<AgentBinding>
private readonly registrations = new Map<string, Set<() => 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<void> {
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<string, LoadedMod>()
const modCommands = new Set<string>()
@@ -259,7 +262,7 @@ export class ClaudeCodeMods extends TypertRemoteService {
}, 0).unref()
}
const modSubmissions = new Map<string, string>()
const ops = createHostOps({
const ops = await createHostOps({
ctx, aliases, processTimeoutMs, registrations: this.registrations, callOrigins, modCommands, modTools, redraw,
submitted: (messageId, mod) => { modSubmissions.set(messageId, mod) },
})
@@ -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)
@@ -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()
@@ -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<string, Set<() => 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)
@@ -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
@@ -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<void> {
// Validate before config parsing so a bad value cannot be hidden by its early return.
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
@@ -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<PreToolDecision> => {
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<PostToolDecision> => {
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<ContentBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('')
@@ -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' } }))
+38 -33
View File
@@ -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<void> {
// Validate before config parsing so a bad value cannot be hidden by its early return.
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
@@ -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<PreToolDecision> => {
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<PostToolDecision> => {
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<void> => {
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<string, unknown> {
return { ...base(agent, event, model), turn_id: String(lastTurn(agent)) }
}
function preToolPayload(exec: ToolExecution, model: string): Record<string, unknown> {
// `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<string, unknown> {
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<ContentBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('')
@@ -303,11 +325,6 @@ function base(agent: Agent | undefined, event: string, model: string): Record<st
}
}
/** Base + turn_id, for the turn-scoped events (PreToolUse/PostToolUse/UserPromptSubmit/Stop). */
function turnBase(ctx: Context, agent: Agent | undefined, event: string, model: string): Record<string, unknown> {
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<string, unknown> {
// `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<string, unknown> {
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) }
}
@@ -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)
@@ -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 })
@@ -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<void> {
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),
}))
}
@@ -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<T> {
readonly capped: boolean
}
async function executeSessionSearch(
ctx: Context,
args: SessionSearchArgs,
exec: ToolRunContext,
maxResults: number,
): Promise<string> {
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<SessionId>()
: await workspaceAccess.authorizeSessionIds(ctx, caller, requestedParentIds, exec.signal)
const parentValues: Array<SessionId | null> = 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<TurnBoundaryProjection>
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<string> {
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<void> {
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<SessionEventSearchPage> => {
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<string> {
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<string> {
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<string> {
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<SessionId>()
: await workspaceAccess.authorizeSessionIds(ctx, caller, requestedParentIds, exec.signal)
const parentValues: Array<SessionId | null> = 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<string> {
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<string> {
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<SessionEventSearchPage> => {
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<string> {
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<string> {
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<string> {
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<T>(
@@ -275,12 +333,3 @@ async function collectPages<T>(
cursor = page.nextCursor
}
}
/** Five model-facing session-query operation implementations. */
export const operations = {
executeSessionSearch,
executeEventSearch,
executeSessionTrace,
executeEventTrace,
executeEventRead,
}
@@ -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<SessionIdValue>(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,
@@ -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<void> {
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' })
@@ -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<ToolExecutionResult>
}
function registerTurnBoundary(ctx: Context): void {
ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
async function registerTurnBoundary(ctx: Context): Promise<void> {
await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
}
async function mount(
config: ToolSessionQuery.Config = {},
config: ToolSessionQueryConfig = {},
callerCwd: string | null = '/work',
enforceTimeout = false,
): Promise<Mounted> {
@@ -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 () => {
+1 -1
View File
@@ -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)
+11 -8
View File
@@ -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<Session, GoalRuntimeState>()
private projection!: ProjectionHandle<GoalProjectionState>
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'goals')
this.resolved = {
defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256),
}
}
protected async [Service.init](): Promise<void> {
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', {
+8 -4
View File
@@ -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<void> { 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)
+3 -2
View File
@@ -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()
+2 -2
View File
@@ -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
+1
View File
@@ -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 `<goal_complete>` or `<goal_blocked>` instruction so the model addresses the user once before the turn ends; direct-human mutations never defer this context.
+1
View File
@@ -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` 会延后一条 `<goal_complete>` 或 `<goal_blocked>` 结束指令,让模型在轮次结束前向用户做一次交代;人类直接变更绝不会延后该上下文。
+2 -1
View File
@@ -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:~",
+85 -55
View File
@@ -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<typeof turnAuthoritySchema>
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<GoalToolExecution, 'events' | 'openTurnStartSeq'> {
// 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<TurnAuthority>
/**
* 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<void> {
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)
}
/**
+8 -5
View File
@@ -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<void> {
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 } : {},
+36 -13
View File
@@ -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)
@@ -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
@@ -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.
</details>
@@ -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` 注册表时注册。派生当前预设或固定初始选择的调用使用保留的投影句柄。
</details>
@@ -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<PermissionProjectionState>
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<void> {
const ctx = this.ctx
const selectionSchema = zod.object({
currentValue: zod.string().min(1),
}) as zod.ZodType<PermissionSelection>
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
}
@@ -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'])
@@ -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<UserQuestionProjectionState> | undefined
private readonly waits = new Map<Agent, Map<ToolCallId, TimedQuestionWait>>()
private readonly queuedReplies = new WeakMap<Session, Map<ToolCallId, QueuedReply>>()
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<void> {
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') ?? []
}
/**
@@ -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()
+11 -9
View File
@@ -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<boolean
})
}
/**
* Install provider-routed normal or unbounded request recovery.
* @param ctx - plugin context that owns the listener and active waits.
* @param config - empty executor config; provider registrations own policy.
* @param internals - non-serializable deterministic hooks for tests.
*/
interface RetryStateEntry {
retry: number
retryId: RetryId
@@ -120,9 +115,16 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
}
}
export function apply(ctx: Context, config: Config = {}, internals: RetryInternals = {}): void {
/**
* Install provider-routed normal or unbounded request recovery.
* @param ctx - plugin context that owns the listener and active waits.
* @param config - empty executor config; provider registrations own policy.
* @param internals - non-serializable deterministic hooks for tests.
* @returns when recovery listeners and their projection are ready.
*/
export async function apply(ctx: Context, config: Config = {}, internals: RetryInternals = {}): Promise<void> {
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()
+14 -15
View File
@@ -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<StreamChunk> | AsyncIterable<StreamChunk>
@@ -103,7 +104,7 @@ async function harness(
adapter: ScriptedAdapter,
policies: Readonly<Record<string, RetryPolicyConfig | undefined>> = { 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<retry.Config>()
expectTypeOf<{ retryPolicy: { mode: 'always' } }>().not.toExtend<retry.Config>()
it('rejects retry policy configured on the executor instead of a provider', async () => {
expectTypeOf<{}>().toExtend<retryConfig>()
expectTypeOf<{ retryPolicy: { mode: 'always' } }>().not.toExtend<retryConfig>()
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"/)
})
})
+7 -3
View File
@@ -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<void> {
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.
@@ -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
}
@@ -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<Harness> {
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]) }
}
@@ -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()
})
})
+4 -4
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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。状态读取使用保留的句柄,注销后仍然可用。
### 源码地图
+12 -6
View File
@@ -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<PlanUnitState>
private turnBoundary!: ProjectionHandle<TurnBoundaryProjection>
/**
* 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<void> {
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
}
@@ -56,8 +56,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
/** 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
}
@@ -89,7 +89,7 @@ function foldPlanMode(events: readonly SessionEvent[], end = events.length): boo
async function mountProjectionSeam(ctx: Context): Promise<void> {
await ctx.plugin(SessionProjectionRegistry)
ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
}
async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise<Context> {
@@ -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)
@@ -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 () => {
@@ -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<TurnBoundaryProjection>
private readonly owner: Context
private readonly definitions = new Map<string, Definition>()
private readonly generations = new Map<ScopeKey, Generation>()
@@ -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<void> {
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<string> {
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)
@@ -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 () => {
+8 -3
View File
@@ -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<SandboxMode | null>
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<void> {
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
}
}
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more