refactor(plugins): migrate projection consumers to retained handles
This commit is contained in:
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 的持久日志检查。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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、任务与投影类型。
|
||||
|
||||
|
||||
@@ -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' } }))
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>` 结束指令,让模型在轮次结束前向用户做一次交代;人类直接变更绝不会延后该上下文。
|
||||
|
||||
@@ -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:~",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 } : {},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"/)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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。状态读取使用保留的句柄,注销后仍然可用。
|
||||
|
||||
### 源码地图
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user