Merge branch 'master' into feat/llm-empty-response-retry

This commit is contained in:
Tianyi Cui
2026-07-25 13:05:55 +08:00
committed by GitHub
905 changed files with 28304 additions and 3812 deletions

View File

@@ -34,6 +34,8 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface |
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface |
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra |

View File

@@ -109,7 +109,7 @@ describe('bash tool through the agent loop', () => {
const location = ctx.sessionPersistence.locate(agent.session.header)
expect(location?.kind).toBe('jsonl')
agent.send([{ type: 'text', text: 'inspect the current session' }])
agent.followup([{ type: 'text', text: 'inspect the current session' }])
await waitForIdle(ctx, agent)
const result = findEvent(events(agent), 'tool/result')
@@ -128,7 +128,7 @@ describe('bash tool through the agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'run echo integration-ok' }])
agent.followup([{ type: 'text', text: 'run echo integration-ok' }])
await waitForIdle(ctx, agent)
const log = events(agent)
@@ -160,7 +160,7 @@ describe('bash tool through the agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'run exit 9' }])
agent.followup([{ type: 'text', text: 'run exit 9' }])
await waitForIdle(ctx, agent)
const toolResult = findEvent(events(agent), 'tool/result')
@@ -168,7 +168,7 @@ describe('bash tool through the agent loop', () => {
expect(resultText(toolResult)).toContain('[exit code: 9]')
})
it('background: start ack → completion notice as context/message → task_output collects it', async () => {
it('background: start ack → completion notice as user/message → task_output collects it', async () => {
// The task id is deterministic (a fresh TaskService counts per kind from 1),
// so the script can name `bash-1` without threading a generated id.
const adapter = new MockAdapter([
@@ -180,7 +180,7 @@ describe('bash tool through the agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }])
agent.followup([{ type: 'text', text: 'run echo bg-ok in the background' }])
await waitForIdle(ctx, agent)
const firstResult = findEvent(events(agent), 'tool/result')
@@ -188,17 +188,19 @@ describe('bash tool through the agent loop', () => {
expect(resultText(firstResult)).toBe('started background task bash-1')
// The task settles on its own; the tool-tasks notice listener injects a
// durable context/message into the owning agent's session (settlement may
// race turn end, so poll for it).
await pollUntil(() => events(agent).some(event => event.type === 'context/message'))
const notice = findEvent(events(agent), 'context/message')
// durable plugin-sourced user/message into the owning agent's session
// (settlement may race turn end, so poll for it).
const isNotice = (e: SessionEvent): e is SessionEvent<'user/message'> =>
e.type === 'user/message' && e.data.source.kind === 'plugin'
await pollUntil(() => events(agent).some(isNotice))
const notice = events(agent).find(isNotice)!
expect(notice.data.content.some(
block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'),
)).toBe(true)
expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' })
// The next turn collects the output through the generic task tool.
agent.send([{ type: 'text', text: 'collect it' }])
agent.followup([{ type: 'text', text: 'collect it' }])
await waitForIdle(ctx, agent)
const readResult = findEvent(events(agent), 'tool/result', 'last')
expect(readResult.data.isError).toBe(false)

View File

@@ -43,10 +43,12 @@
"src"
],
"peerDependencies": {
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -0,0 +1,8 @@
/**
* The /api URL prefix — single source for both halves of the web transport.
* The node half registers this prefix on the web server; browser-side path
* literals currently live in the apiproxy client layer (out of scope here).
*/
/** Route prefix owning every api request (`/api` and `/api/<anything>`). */
export const API_PATH = '/api'

View File

@@ -76,7 +76,7 @@ function buildAlphaLog(): SessionEvent[] {
})
}
if (turn % 9 === 4) {
push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入turn ${turn}`), source: { kind: 'plugin', plugin: 'fixture' } } })
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入turn ${turn}`), source: { kind: 'plugin', plugin: 'fixture' } } })
}
push({ type: 'step/start', data: { turn, step: 0 } })
const withTool = turn % 5 === 2

View File

@@ -0,0 +1,59 @@
/**
* node:http ↔ WHATWG fetch bridge for the /api transport (host side of the
* web carrier; the fetch-shaped handler itself is transport-agnostic).
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
/**
* Bridge one node:http request to the fetch-shaped handler (client close
* aborts; SSE bodies stream out chunk by chunk).
* @param req - incoming node:http request (fully read before dispatch).
* @param res - node:http response the bridge writes and owns to completion.
* @param apiHandler - fetch-shaped API carrier the request is dispatched to.
*/
export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise<void> {
const abort = new AbortController()
// Client-disconnect detection MUST hang off the response, not the request:
// since Node 16, IncomingMessage 'close' fires as soon as the request body is
// fully consumed (immediately for a bodyless GET), which would abort every SSE
// stream right after open. ServerResponse 'close' fires on connection teardown;
// writableEnded distinguishes a normal end() from the client going away.
res.on('close', () => {
if (!res.writableEnded) abort.abort()
})
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(chunk as Buffer)
/* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server
requests; the fields are only optional on the client-side IncomingMessage type */
const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), {
method: req.method ?? 'GET',
headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]),
...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {},
signal: abort.signal,
})
const response = await apiHandler.fetch(request)
res.writeHead(response.status, Object.fromEntries(response.headers.entries()))
if (response.body === null) {
res.end()
return
}
for await (const chunk of response.body) {
// Backpressure: a false return means the socket buffer is full — wait for drain
// instead of buffering unboundedly (slow/suspended SSE consumers). 'close' also
// resolves so a mid-wait disconnect can't park this loop forever; the close
// handler above aborts the handler stream, which then ends the iteration.
if (!res.write(chunk)) {
await new Promise<void>((resolve) => {
const done = (): void => {
res.off('drain', done)
res.off('close', done)
resolve()
}
res.once('drain', done)
res.once('close', done)
})
}
}
res.end()
}

View File

@@ -1,10 +1,36 @@
/**
* Connection plugin, node half. The package IS a dshClient plugin: the wire
* consumer layer lives in its client half in full (src/client/ — contract:
* api-contracts v3 section 3, inventory §3.2); consumers import the /client
* subpath. The empty apply exists so the plugin appears in the host Loader
* (lifecycle governance + dshClient discovery).
* Connection plugin, node half: the host end of the web transport. Registers
* the /api prefix route on the web server and bridges node:http requests to
* the transport-agnostic fetch-shaped api handler. The wire consumer layer
* lives in the client half (src/client/ — contract: api-contracts v3
* section 3); consumers import the /client subpath.
*/
import type { Context } from 'cordis'
// Type-only route import; it also carries the httpServer Context merge.
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { API_PATH } from './api-path.ts'
import { bridge } from './http-bridge.ts'
/** Host plugin body — no host-side behavior for the connection plugin. */
export function apply(_ctx: unknown): void {}
export { API_PATH } from './api-path.ts'
/** Cordis plugin name. */
export const name = 'client-connection'
/** Required services: the route registry and the api gateway. */
export const inject = ['httpServer', 'apiProxy']
/**
* Mount the /api transport: wrap the api gateway into a fetch handler and
* serve it under the /api prefix.
* @param ctx - host plugin context carrying httpServer and apiProxy.
*/
export function apply(ctx: Context): void {
const apiHandler = toFetchHandler(ctx.apiProxy)
const route: WebRoute = {
kind: 'prefix',
path: API_PATH,
handler: (req, res) => bridge(req, res, apiHandler),
}
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
}

View File

@@ -15,10 +15,11 @@ export const name = 'client-connection-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: the pure wire layer emits no cordis events and owns no
* No runtime invariant: the wire layer emits no cordis events and owns no
* mutable cross-plugin relation — stream/reconnect sequencing is exercised
* directly by its behavior specs, and rpcId round-trip discipline is owned by
* the apiproxy contract layer.
* directly by its behavior specs, rpcId round-trip discipline is owned by the
* apiproxy contract layer, and the node half's single route registration's
* register/dispose symmetry is audited by the webserver package's invariant.
*/
const install: InvariantInstaller = () => {}

View File

@@ -1,10 +1,33 @@
/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */
/** Node half: registers the /api prefix route bridging to the api gateway. */
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { apply } from '../src/index.ts'
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { API_PATH, apply, inject } from '../src/index.ts'
describe('node half', () => {
it('apply is a no-op host placeholder', () => {
apply(undefined)
expect(true).toBe(true) // reaching here without throw is the contract
describe('connection node half', () => {
it('registers the /api prefix route and removes it with the fiber', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
// Structural fake: the plugin only touches register(); the service class
// carries private state a literal cannot (and need not) reproduce.
const httpServer: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex: () => () => {},
port: 0,
}
ctx.provide('httpServer', httpServer as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
await fiber.dispose()
expect(routes).toHaveLength(0)
})
})

View File

@@ -2,7 +2,8 @@
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
"outDir": "lib/types",
"types": ["node"]
},
"include": [
"src"
@@ -20,6 +21,9 @@
{
"path": "../../host/apiproxy"
},
{
"path": "../../host/webserver"
},
{
"path": "../../ui/user-approval"
},

View File

@@ -28,15 +28,20 @@
"immediately": true
},
"license": "BSD-3-Clause",
"dependencies": {
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-client-modules": "^0.0.1",
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
},

View File

@@ -64,20 +64,11 @@
*/
import type { Context } from 'cordis'
import type { Entry, Loader } from '@cordisjs/plugin-loader'
import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
import type { PluginsEventFrame } from '../events.ts'
import { EVENTS_ENDPOINT } from '../events.ts'
/**
* Frames on the `GET /plugins/events` system SSE channel (owned host-side by
* dsh-host-webserver's PluginEventFrame). Mirrored here because this is a
* wire boundary: frames arrive as JSON text and are validated at the parse
* point, not shared as a same-process typed seam.
*/
export type PluginsEventFrame =
| { type: 'graph'; graph: WebBootGraph }
| { type: 'rebuilt'; id: string; rev: string }
/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
export const EVENTS_ENDPOINT = '/plugins/events'
export type { PluginsEventFrame } from '../events.ts'
export { EVENTS_ENDPOINT } from '../events.ts'
/** Cordis plugin name. */
export const name = 'client-hmr'

View File

@@ -0,0 +1,16 @@
/**
* Wire protocol of the `/plugins/events` dev SSE channel — single source for
* both halves of this package. Frames still cross a wire boundary: the
* browser half validates them at its JSON parse point; sharing the type keeps
* the two ends from drifting, not from parsing.
*/
import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
/** One SSE frame: the full graph on connect, or one rebuilt bundle notice. */
export type PluginsEventFrame =
| { type: 'graph'; graph: WebBootGraph }
| { type: 'rebuilt'; id: string; rev: string }
/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
export const EVENTS_ENDPOINT = '/plugins/events'

View File

@@ -1,9 +1,152 @@
/**
* HMR plugin, node half. The package IS a dshClient plugin (dev-only row in
* the host graph): the reload driver lives in its client half in full
* (src/client/); the empty apply exists so the plugin appears in the host
* Loader (lifecycle governance + dshClient discovery).
* HMR plugin, node half: the host end of the dev reload chain. Stat-polls
* every graph row's client bundle (fs.watchFile — polling by design: network
* mounts deliver no inotify events), reports content changes through
* `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel
* broadcasting graph/rebuilt frames to the browser half (src/client/).
* Dev-only row: prod compositions never mount this plugin.
*/
import type { Stats } from 'node:fs'
import { unwatchFile, watchFile } from 'node:fs'
import type { ServerResponse } from 'node:http'
import type { Context } from 'cordis'
import z from 'schemastery'
// Empty type imports carry the clientModuleHost/httpServer Context merges.
import type {} from '@deepseek-ai/dsh-client-modules'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type { PluginsEventFrame } from './events.ts'
import { EVENTS_ENDPOINT } from './events.ts'
/** Host plugin body — no host-side behavior for the HMR plugin. */
export function apply(): void {}
export type { PluginsEventFrame } from './events.ts'
export { EVENTS_ENDPOINT } from './events.ts'
/** Cordis plugin name. */
export const name = 'client-hmr'
/** Required services: the web plugin table and the route registry. */
export const inject = ['clientModuleHost', 'httpServer']
/** Plugin config, validated by the same-named schemastery schema. */
export interface Config {
/** Bundle stat-poll interval in milliseconds (default 500, the build-side watcher's polling default). */
pollIntervalMs?: number
}
export const Config: z<Config> = z.object({
pollIntervalMs: z.number().step(1).min(1).default(500),
})
/** Serialize one frame as an SSE data line. */
function sseData(frame: PluginsEventFrame): string {
return `data: ${JSON.stringify(frame)}\n\n`
}
/**
* Mount the dev chain: bundle watches, rebuilt reporting, and the SSE channel.
* @param ctx - host plugin context carrying clientModuleHost and httpServer.
* @param config - validated {@link Config}.
*/
export function apply(ctx: Context, config: Config): void {
// schemastery's .default() guarantees the field is set after validation.
const pollIntervalMs = config.pollIntervalMs as number
// --- bundle watch: one fs.watchFile stat poll per graph row -------------
const watched = new Map<string, { path: string; listener: (curr: Stats, prev: Stats) => void }>()
const watchRow = (id: string, path: string): void => {
const listener = (curr: Stats, prev: Stats): void => {
// fs.watchFile fires on any stat delta (atime included); only content
// signals count. An all-zero curr means the file vanished mid-rebuild
// — the completing write fires the next tick, so skipping is safe.
if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return
if (curr.mtimeMs === 0) return
try {
// rebuilt() re-hashes; an unchanged hash stays silent (clientModuleHost
// fires onRebuilt only on a real rev change). A torn read of a
// half-written bundle self-heals on the next poll tick.
ctx.clientModuleHost.rebuilt(id)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT') return // mid-rename window; the completed write fires the next poll tick
ctx.logger.warn(error)
}
}
watchFile(path, { interval: pollIntervalMs, persistent: false }, listener)
watched.set(id, { path, listener })
}
// Diff the watch set against the current graph: drop watches for removed
// rows (or rows whose bundle path moved), add watches for new rows.
const syncWatches = (): void => {
const rows = new Map<string, string>()
for (const row of ctx.clientModuleHost.graph().entries) {
const path = ctx.clientModuleHost.clientPath(row.id)
if (path !== undefined) rows.set(row.id, path)
}
for (const [id, watch] of watched) {
if (rows.get(id) === watch.path) continue
unwatchFile(watch.path, watch.listener)
watched.delete(id)
}
for (const [id, path] of rows) {
if (!watched.has(id)) watchRow(id, path)
}
}
ctx.effect(() => {
// Initial sync covers rows already in the graph; the subscription covers
// rows arriving later (boot-window activations, including this plugin's
// own row — no self-exemption, a modules/hmr rebuild rides the same chain).
syncWatches()
const unsubscribe = ctx.clientModuleHost.onGraphChanged(syncWatches)
return () => {
unsubscribe()
for (const { path, listener } of watched.values()) unwatchFile(path, listener)
watched.clear()
}
}, 'client-hmr: bundle watches')
// --- /plugins/events SSE channel ----------------------------------------
const connections = new Set<ServerResponse>()
const connect = (res: ServerResponse): void => {
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
'connection': 'keep-alive',
})
// Comment line on open so clients/proxies see a live channel even when
// no rebuild ever happens; EventSource frame parsing skips it naturally.
res.write(': connected\n\n')
res.write(sseData({ type: 'graph', graph: ctx.clientModuleHost.graph() }))
connections.add(res)
res.on('close', () => { connections.delete(res) })
}
ctx.effect(() => {
const disposeRoute = ctx.httpServer.register({
kind: 'exact',
path: EVENTS_ENDPOINT,
handler: (req, res) => {
// Named routes match ahead of the carrier's method gate; keep the old
// global 405 semantics for non-GET hits on this endpoint.
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405)
res.end()
return
}
connect(res)
},
})
const unsubscribe = ctx.clientModuleHost.onRebuilt((id, rev) => {
const line = sseData({ type: 'rebuilt', id, rev })
for (const res of connections) res.write(line)
})
return () => {
unsubscribe()
disposeRoute()
for (const res of connections) res.destroy()
connections.clear()
}
}, 'client-hmr: /plugins/events channel')
}

View File

@@ -3,8 +3,7 @@
* @module @deepseek-ai/dsh-client-hmr/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { Context, Fiber } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-hmr'
@@ -14,14 +13,42 @@ export const name = 'client-hmr-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** Live fs.watchFile pollers (this package is the composition's only stat-poll user). */
function statWatchers(): number {
return process.getActiveResourcesInfo().filter(kind => kind === 'StatWatcher').length
}
/**
* No runtime invariant: a dev-only reload driver — it consumes the loader
* entry tree and module cache but owns no events and no cross-plugin mutable
* state; reload correctness (dispose → style removal → re-execute ordering)
* is observable only through the assembled browser runtime, not a host-side
* event relation.
* Owned relation: every bundle stat watcher the node half starts must die
* with its fiber — a surviving poller would keep re-hashing bundles for a
* torn-down dev chain forever. Checked as a baseline delta: the StatWatcher
* count observed at fiber creation must be restored once disposal has drained
* the fiber's effects (`internal/plugin` fires at dispose start; the microtask
* hop lets the disposer queue its unload before `fiber.await()` joins it).
* SSE-connection and listener teardown live inside the same ctx.effect
* disposers, so the watcher count is the relation's observable proxy.
*/
const install: InvariantInstaller = () => {}
const install: InvariantInstaller = (ctx, fail) => {
const baselines = new WeakMap<Fiber, number>()
// Async listener by design: emitPluginDisposed awaits-and-logs returned
// promises, so a violation surfaces loudly instead of unhandled.
// eslint-disable-next-line @typescript-eslint/no-misused-promises
ctx.on('internal/plugin', async (fiber) => {
if (fiber.name !== 'client-hmr') return
if (fiber.uid !== null) {
baselines.set(fiber, statWatchers())
return
}
const baseline = baselines.get(fiber)
if (baseline === undefined) return
await Promise.resolve()
await fiber.await()
const remaining = statWatchers()
if (remaining > baseline) {
fail(`client-hmr fiber disposed but ${remaining - baseline} bundle stat watcher(s) survived teardown`)
}
}, { global: true })
}
/**
* Register this package's invariant companion.
@@ -30,4 +57,3 @@ const install: InvariantInstaller = () => {}
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -1,14 +1,116 @@
/**
* Node half of the HMR plugin: an empty apply placeholder (the reload driver
* lives in the client half) whose only contract is mounting and disposing
* cleanly in the host Loader.
* Node half of the HMR plugin: bundle watches follow the graph, stat changes
* report through clientModuleHost.rebuilt, and everything dies with the fiber.
*/
import { describe, expect, it } from 'vitest'
import { apply } from '@deepseek-ai/dsh-client-hmr'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WebBootGraph, ClientModuleHostService } from '@deepseek-ai/dsh-client-modules'
import type { WebRoute, HttpServerService } from '@deepseek-ai/dsh-host-webserver'
import { apply, Config, EVENTS_ENDPOINT, inject } from '../src/index.ts'
const POLL_MS = 20
let dir: string
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-')) })
afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
/**
* Controllable clientModuleHost fake over a mutable id → bundle-path table.
* Structural (Pick+cast): the plugin only touches the read/notify surface;
* the service class carries private scan state a literal need not reproduce.
*/
type FakeHost = ClientModuleHostService & { rebuiltCalls: string[]; fireGraphChanged(): void }
function fakeClientModuleHost(rows: Map<string, string>): FakeHost {
const graphListeners = new Set<() => void>()
const rebuiltCalls: string[] = []
const fake: Pick<FakeHost, 'graph' | 'clientPath' | 'rebuilt' | 'onRebuilt' | 'onGraphChanged' | 'rebuiltCalls' | 'fireGraphChanged'> = {
rebuiltCalls,
fireGraphChanged: () => { for (const l of graphListeners) l() },
graph: (): WebBootGraph => ({
rev: 'r',
entries: [...rows.keys()].map(id => ({ id, url: `/plugins/${id}/client.js?rev=r`, rev: 'r' })),
}),
clientPath: id => rows.get(id),
rebuilt: (id) => { rebuiltCalls.push(id); return 'r2' },
onRebuilt: () => () => {},
onGraphChanged: (listener) => {
graphListeners.add(listener)
return () => { graphListeners.delete(listener) }
},
}
return fake as FakeHost
}
// Structural fake: the plugin only touches register(); the service class
// carries private state a literal cannot (and need not) reproduce.
function fakeHttpServer(routes: WebRoute[]): HttpServerService {
const fake: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex: () => () => {},
port: 0,
}
return fake as HttpServerService
}
async function mount(clientModuleHost: FakeHost, httpServer: HttpServerService) {
const ctx = new Context()
ctx.provide('clientModuleHost', clientModuleHost)
ctx.provide('httpServer', httpServer)
const fiber = ctx.plugin(
{ inject: [...inject], Config, apply },
{ pollIntervalMs: POLL_MS },
)
await fiber.await()
return fiber
}
describe('hmr node half', () => {
it('apply is a no-op host placeholder', () => {
apply()
expect(true).toBe(true) // reaching here without throw is the contract
it('watches graph bundles, reports stat changes, and unwatches on dispose', async () => {
const bundle = join(dir, 'a.js')
writeFileSync(bundle, 'v1')
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
const routes: WebRoute[] = []
const fiber = await mount(clientModuleHost, fakeHttpServer(routes))
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'exact', path: EVENTS_ENDPOINT })
// Nudge mtime past stat granularity so the poller sees a content signal.
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
writeFileSync(bundle, 'v2-longer')
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 })
await fiber.dispose()
expect(routes).toHaveLength(0)
// Watcher gone: further file changes report nothing.
clientModuleHost.rebuiltCalls.length = 0
writeFileSync(bundle, 'v3-even-longer')
await new Promise(resolve => setTimeout(resolve, POLL_MS * 4))
expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
})
it('follows graph changes: rows added after activation get watched', async () => {
const early = join(dir, 'early.js')
const late = join(dir, 'late.js')
writeFileSync(early, 'v1')
const rows = new Map([['pkg-early', early]])
const clientModuleHost = fakeClientModuleHost(rows)
const fiber = await mount(clientModuleHost, fakeHttpServer([]))
writeFileSync(late, 'v1')
rows.set('pkg-late', late)
clientModuleHost.fireGraphChanged()
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
writeFileSync(late, 'v2-longer')
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-late') }, { timeout: 3_000 })
await fiber.dispose()
})
})

View File

@@ -8,7 +8,7 @@
"DOM",
"DOM.Iterable"
],
"types": []
"types": ["node"]
},
"include": [
"src"
@@ -23,6 +23,12 @@
{
"path": "../modules"
},
{
"path": "../../host/webserver"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../support/invariants"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-modules",
"description": "Client module loader: the browser peer of Node's internal ESM loader, consumed by the vendored cordis Loader as its internal seam (resolve/import/loadCache/invalidate over seed table, static registry and fetch bundles)",
"description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dshClient scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -11,6 +11,10 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
@@ -18,14 +22,26 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"platform": "web",
"inject": [],
"immediately": true
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"

View File

@@ -0,0 +1,34 @@
/**
* Browser half (the standard `./client` export): the module-system class and
* wire contract, plus the enrollment plugin face. The module system itself is
* built by the shell kernel BEFORE cordis exists (the bootstrap exception,
* design §4.7 — the mechanism that loads plugins cannot arrive through
* itself); the plugin face only enrolls that pre-existing instance by
* providing it as `ctx.modules`. The kernel statically registers this module,
* so the graph row for this package never triggers a real fetch — arrival is
* a no-op against the already-registered entry.
* @module @deepseek-ai/dsh-client-modules/client
*/
import type { Context } from 'cordis'
import type { DshWindow } from './manifest.ts'
export { ClientModuleSystem } from './system.ts'
export { parseBootManifest } from './manifest.ts'
export type {
BootManifest, BootModuleRow, BootPluginRow, ClientModuleLoader, ClientModuleRecord,
ClientModuleSystemOptions, ClientPluginHandoff, DshWindow, WebBootEntry, WebBootGraph,
} from './manifest.ts'
/**
* Enroll the kernel-built module system as `ctx.modules`.
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
const modules = (globalThis as DshWindow).__DSH_MODULES__
// The kernel writes the slot right after constructing the instance, before
// any cordis entry exists — a missing slot means the kernel sequencing broke.
if (modules === undefined) {
throw new Error('client-modules: window.__DSH_MODULES__ missing — the shell kernel must construct the module system before plugin boot')
}
ctx.reflect.provide('modules', modules)
}

View File

@@ -0,0 +1,243 @@
/**
* Client module system: the browser peer of Node's internal ESM loader, built
* as a lazy CJS table. The vendored cordis Loader consumes this object
* through its `internal` seam (the only call site is `EntryTree.import` →
* `internal.import`), which keeps entry governance (fiber lifecycle, inject
* waiting, update/refresh) entirely on the vendored side while this package
* owns code arrival.
*
* Lazy CJS model (web2 §0): executing a plugin bundle only REGISTERS its
* factory (`window.__ModuleLoader__.load({id, factory})`); every module body
* side effect — including CSS injection — lives inside the factory closure
* and runs at materialization, not at script execution. Materialization
* (factory(require) → export surface) happens on first import/require and is
* memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
* another registered-but-unmaterialized module materializes it recursively,
* so load order needs no external sequencing.
*
* Resolution branch order (import): seed word → shell instance; memoized
* record → surface; static registry (shell-own modules, e.g. app-shell) →
* module; registered factory → materialize; graph row → fetch + execute +
* materialize; anything else → throw (loud — the runtime mirror of the
* build-time bundle purity gate). The synchronous `require` handed to
* factories walks the same order minus the fetch branch: fetching is async,
* so only already-executed bundles can be required — and cross-plugin value
* imports are a build error anyway.
*
* This file is the browser-safe contract face (zero node imports): the
* `__DSH_BOOT__` wire types, the boot-manifest parser, and the seams around
* {@link ClientModuleSystem}. The package root is the host-side service that
* composes the wire.
*/
import type {} from 'cordis'
import type { ClientModuleSystem } from './system.ts'
declare module 'cordis' {
interface Context {
/** The client module system the web shell builds at boot (contract C5; provided by the `./client` wrapper plugin). */
modules: ClientModuleLoader
}
}
/**
* One composed client entry pushed by the host (web2 §0 graph row). Wire
* single source: the host node half (package root) produces this same shape.
* `immediately` marks stage-one prefetch; `inject` is informational graph
* metadata (the authoritative edges live in each package's dshClient
* declaration and reach fibers through entry creation).
*/
export interface WebBootEntry {
/** Entry name == package name. */
id: string
/** Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. */
url: string
/** Bundle content hash (cache-busting consistency anchor). */
rev: string
/** Package-name dependency edges, informational (preflight display / HMR diffing). */
inject?: string[]
/** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
immediately?: boolean
}
/** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */
export interface WebBootGraph {
/** Consistency anchor over the whole graph (content + bundle hashes). */
rev: string
/** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
entries: WebBootEntry[]
}
/** The npm-package view of one boot row: what the module table needs to fetch the bundle. */
export interface BootModuleRow {
/** Entry name == package name (module-table key). */
id: string
/** Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. */
url: string
/** Bundle content hash. */
rev: string
}
/** The cordis-plugin view of one boot row: what entry composition needs (optional wire fields normalized). */
export interface BootPluginRow {
/** Entry name == package name. */
id: string
/** Package-name dependency edges ([] when the wire omits them). */
inject: string[]
/** Stage-one prefetch tier (false when the wire omits it). */
immediately: boolean
}
/** The parsed boot manifest: one wire, two consumer views. */
export interface BootManifest {
/** Consistency anchor over the whole graph. */
rev: string
/** Rows as the module table consumes them. */
modules: BootModuleRow[]
/** Rows as entry composition consumes them. */
plugins: BootPluginRow[]
}
/**
* Parse `window.__DSH_BOOT__` into the two consumer views. Wire boundary:
* a missing or malformed graph throws (the shell shows the loud failure —
* a page without a valid manifest cannot boot anything).
* @param wire - the raw `window.__DSH_BOOT__` value.
* @returns the manifest with optional plugin-view fields normalized.
*/
export function parseBootManifest(wire: unknown): BootManifest {
if (typeof wire !== 'object' || wire === null) {
throw new Error('client-modules: window.__DSH_BOOT__ is missing or not an object')
}
const graph = wire as Record<string, unknown>
if (typeof graph.rev !== 'string') {
throw new Error('client-modules: boot manifest rev must be a string')
}
if (!Array.isArray(graph.entries)) {
throw new Error('client-modules: boot manifest entries must be an array')
}
const modules: BootModuleRow[] = []
const plugins: BootPluginRow[] = []
for (const value of graph.entries as unknown[]) {
if (typeof value !== 'object' || value === null) {
throw new Error('client-modules: boot manifest entry is not an object')
}
const row = value as Record<string, unknown>
const where = typeof row.id === 'string' ? `"${row.id}"` : JSON.stringify(row)
if (typeof row.id !== 'string' || typeof row.url !== 'string' || typeof row.rev !== 'string') {
throw new Error(`client-modules: boot manifest entry ${where} must carry string id/url/rev`)
}
if (row.inject !== undefined && (!Array.isArray(row.inject) || row.inject.some(i => typeof i !== 'string'))) {
throw new Error(`client-modules: boot manifest entry ${where} inject must be a string array`)
}
if (row.immediately !== undefined && typeof row.immediately !== 'boolean') {
throw new Error(`client-modules: boot manifest entry ${where} immediately must be a boolean`)
}
modules.push({ id: row.id, url: row.url, rev: row.rev })
plugins.push({
id: row.id,
inject: row.inject === undefined ? [] : [...row.inject as string[]],
immediately: row.immediately === true,
})
}
return { rev: graph.rev, modules, plugins }
}
/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff, contract C6). */
export interface ClientPluginHandoff {
/** Plugin id (package name) — the registration key; must match the graph row being executed. */
id: string
/**
* Closure factory holding the whole bundle body: receives the synchronous
* require bound to the module table and returns the bundle's export
* surface. Runs once, at materialization.
*/
factory: (require: (spec: string) => unknown) => Record<string, unknown>
}
/** Window surface of the web boot protocol: the host-injected graph, the registration sink, and the kernel handoff slot. */
export interface DshWindow {
/** Host-composed entry graph, injected before the shell bundle runs; wire-boundary raw until {@link parseBootManifest}. */
__DSH_BOOT__?: unknown
/** Bundle registration sink; installed once per page by the {@link ClientModuleSystem} constructor (contract C6). */
__ModuleLoader__?: { load(handoff: ClientPluginHandoff): void }
/**
* Kernel handoff slot: the shell kernel stores the instance here right
* after construction (before cordis exists) so the `./client` wrapper
* plugin can provide it as `ctx.modules`. Missing slot at wrapper apply
* time = kernel sequencing bug, thrown loud.
*/
__DSH_MODULES__?: ClientModuleSystem
}
/** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph seam, flat today). */
export interface ClientModuleRecord {
/** Module id (entry name / package name). */
id: string
/** The materialized export surface (factory `module.exports`, or the shell module for static registrations). */
surface: unknown
/** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
styles: string[]
/** Observed `require()` edges (module-graph seam; only table words can appear today). */
edges: Set<string>
}
/**
* The internal-seam subset the vendored Loader and the client HMR plugin
* consume. Mounted on `ctx.loader.internal` by the shell boot and provided
* as `ctx.modules` (contract C5).
*/
export interface ClientModuleLoader {
/** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
version: 'client'
/** Materialized-module registry: id → record. The governance-side read face for entry export surfaces. */
loadCache: Map<string, ClientModuleRecord>
/**
* Internal seam consumed by the vendored Loader's `tree.import`. Resolves
* `specifier` through the branch order documented on the module, fetching
* and executing a bundle when needed.
* @param specifier - module specifier (entry name or table word).
* @param parentURL - importer URL (unused — the client module graph is flat).
* @param attrs - import attributes (unused; interface parity with Node's seam).
* @returns the module's export surface.
*/
import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
/**
* Register a shell-own module (app-shell — code that ships inside the shell
* bundle and never arrives as a plugin bundle).
* @param id - entry name (shell-owned pseudo id).
* @param module - the statically imported module namespace.
*/
registerStatic(id: string, module: unknown): void
/**
* Stage-one arrival: fetch the entry's bundle and execute it, registering
* its factory (no materialization — module side effects wait for import).
* No-op for static-registered ids and ids whose factory is already
* registered; concurrent calls share one in-flight task. To force a fresh
* fetch (HMR), {@link invalidate} first.
* @param id - graph entry name.
*/
prefetch(id: string): Promise<void>
/**
* Full reset of one module: drop its registered factory, its materialized
* record, and any consumed bundle text, so the next prefetch/import
* refetches and re-executes (the HMR invalidation hook).
* @param id - entry name to invalidate.
*/
invalidate(id: string): void
}
/** Options for {@link ClientModuleSystem} (assembled by the web shell kernel at boot). */
export interface ClientModuleSystemOptions {
/** Boot rows in the module-table view (from {@link parseBootManifest}). */
modules: BootModuleRow[]
/** Module-table seed: platform-singleton specifier → shell instance. */
staticModules: Record<string, unknown>
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
fetchBundle?: (url: string) => Promise<string>
/**
* Bundle execution seam (synchronously performs the load() registration).
* Defaults to a <script> element carrying the code.
*/
executeBundle?: (code: string, url: string) => void
}

View File

@@ -1,13 +1,13 @@
/**
* ClientModuleLoaderImpl the implementation behind the {@link ClientModuleLoader}
* ClientModuleSystem the implementation behind the {@link ClientModuleLoader}
* seam. The conceptual contract (lazy CJS model, resolution branch order) is
* documented on the package module and the public interfaces in `./index.ts`;
* this file owns the state tables and the fetch/execute/materialize machinery.
* documented on the public interfaces in `./manifest.ts`; this file owns the
* state tables and the fetch/execute/materialize machinery.
*/
import type {
ClientModuleLoader, ClientModuleLoaderOptions, ClientModuleRecord,
ClientPluginHandoff, DshWindow, WebBootEntry,
} from './index.ts'
BootModuleRow, ClientModuleLoader, ClientModuleRecord,
ClientModuleSystemOptions, ClientPluginHandoff, DshWindow,
} from './manifest.ts'
/** A registered-but-unmaterialized bundle: the factory plus its source URL (diagnostics). */
interface RegisteredFactory {
@@ -35,13 +35,6 @@ const defaultExecuteBundle = (code: string, url: string): void => {
el.remove()
}
const urlOf = (row: WebBootEntry): string => {
// url is conditional on the wire (shell-own pseudo rows omit it); those
// ids resolve through the static registry and never reach a fetch.
if (row.url === undefined) throw new Error(`client-modules: entry "${row.id}" has no bundle url and no static registration`)
return row.url
}
/**
* A plugin bundle IS its package's client half: `<id>/client` (the exports
* subpath external bundles emit) and the bare graph id name the same
@@ -70,10 +63,10 @@ const claimStyles = (id: string): string[] => {
/**
* The client module system: state tables plus the arrival/materialization
* machinery implementing {@link ClientModuleLoader} (whose members carry the
* seam contract docs). Construction indexes the boot graph and installs the
* seam contract docs). Construction indexes the boot rows and installs the
* `window.__ModuleLoader__` registration sink (contract C6) once per page.
*/
export class ClientModuleLoaderImpl implements ClientModuleLoader {
export class ClientModuleSystem implements ClientModuleLoader {
readonly version = 'client'
readonly loadCache = new Map<string, ClientModuleRecord>()
@@ -84,7 +77,7 @@ export class ClientModuleLoaderImpl implements ClientModuleLoader {
private readonly pendingArrival = new Map<string, Promise<void>>()
/** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
private readonly materializing = new Set<string>()
private readonly graphRows = new Map<string, WebBootEntry>()
private readonly graphRows = new Map<string, BootModuleRow>()
// Execution URL of the bundle currently being executed (bound into the
// factory registration so diagnostics can name the source).
private executingUrl = ''
@@ -97,17 +90,17 @@ export class ClientModuleLoaderImpl implements ClientModuleLoader {
private readonly executeBundle: (code: string, url: string) => void
/**
* Build the module system over the host graph.
* @param options - entry graph, module-table staticModules, fetch/execute seams.
* Build the module system over the parsed boot rows.
* @param options - module rows, module-table staticModules, fetch/execute seams.
*/
constructor(options: ClientModuleLoaderOptions) {
constructor(options: ClientModuleSystemOptions) {
this.seed = new Map(Object.entries(options.staticModules))
this.fetchBundle = options.fetchBundle ?? defaultFetchBundle
this.executeBundle = options.executeBundle ?? defaultExecuteBundle
for (const entry of options.graph.entries) {
if (this.graphRows.has(entry.id)) throw new Error(`client-modules: duplicate graph entry "${entry.id}"`)
this.graphRows.set(entry.id, entry)
for (const row of options.modules) {
if (this.graphRows.has(row.id)) throw new Error(`client-modules: duplicate graph entry "${row.id}"`)
this.graphRows.set(row.id, row)
}
const win = globalThis as DshWindow
@@ -129,13 +122,12 @@ export class ClientModuleLoaderImpl implements ClientModuleLoader {
}
/** Fetch + execute one graph row so its factory is registered (idempotent per in-flight arrival). */
private arrive(row: WebBootEntry): Promise<void> {
const { id } = row
private arrive(row: BootModuleRow): Promise<void> {
const { id, url } = row
const pending = this.pendingArrival.get(id)
if (pending !== undefined) return pending
if (this.factories.has(id)) return Promise.resolve()
const task = (async (): Promise<void> => {
const url = urlOf(row)
const code = await this.fetchBundle(url)
this.executingUrl = url
this.executingId = id

View File

@@ -1,175 +1,393 @@
/**
* Client module system: the browser peer of Node's internal ESM loader, built
* as a lazy CJS table. The vendored cordis Loader consumes this object
* through its `internal` seam (the only call site is `EntryTree.import` →
* `internal.import`), which keeps entry governance (fiber lifecycle, inject
* waiting, update/refresh) entirely on the vendored side while this package
* owns code arrival.
* Node half of the client module system (dshClient dual-face package): scans
* the host Loader's entries for `dshClient` packages, composes the
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
* in `./client/manifest.ts`), serves `/plugins/<id>/client.js`, taps the
* index render to inject the boot manifest, and provides the
* `clientModuleHost` service (the HMR node half's registration/notification
* face).
*
* Lazy CJS model (web2 §0): executing a plugin bundle only REGISTERS its
* factory (`window.__ModuleLoader__.load({id, factory})`); every module body
* side effect — including CSS injection — lives inside the factory closure
* and runs at materialization, not at script execution. Materialization
* (factory(require) → export surface) happens on first import/require and is
* memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
* another registered-but-unmaterialized module materializes it recursively,
* so load order needs no external sequencing.
*
* Resolution branch order (import): seed word → shell instance; memoized
* record → surface; static registry (shell-own modules, e.g. app-shell) →
* module; registered factory → materialize; graph row → fetch + execute +
* materialize; anything else → throw (loud — the runtime mirror of the
* build-time bundle purity gate). The synchronous `require` handed to
* factories walks the same order minus the fetch branch: fetching is async,
* so only already-executed bundles can be required — and cross-plugin value
* imports are a build error anyway.
* Scanning is incremental per package — there is no full-rescan code path.
* Every cordis `internal/plugin` emission (fiber construction/disposal) marks
* the fiber's entry name dirty; a microtask flush reconciles each dirty name
* against the live loader entries. The activation pass seeds the same dirty
* set with all current entries and flushes synchronously, so first scan and
* steady state share one implementation. Package metadata (including the
* negative "not a client package" verdict) is cached per name and never
* expires — plugin-set changes take effect on restart per the config-source
* ruling; bundle content changes reach the graph only through
* {@link ClientModuleHostService.rebuilt}.
* @module @deepseek-ai/dsh-client-modules
*/
import { ClientModuleLoaderImpl } from './loader.ts'
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { Service } from 'cordis'
import type { Context } from 'cordis'
import type {} from '@cordisjs/plugin-loader'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type { WebBootEntry, WebBootGraph } from './client/manifest.ts'
export { ClientModuleLoaderImpl }
export type {
BootManifest, BootModuleRow, BootPluginRow, WebBootEntry, WebBootGraph,
} from './client/manifest.ts'
declare module 'cordis' {
interface Context {
/** The client module system the web shell provides at boot (contract C5). */
modules: ClientModuleLoader
/** The web plugin table (provided by the client-modules node half). */
clientModuleHost: ClientModuleHostService
}
}
/** package.json `dshClient` declaration shape (file boundary — validated field by field). */
interface DshClientDeclaration {
inject?: string[]
platform: string
/** Boot phase-one prefetch mark; absent means lazy (fetched on demand). */
immediately?: boolean
}
/** Resolved package metadata for one dshClient package (cached per name, never expires). */
interface PkgMeta {
clientPath: string
inject?: string[]
immediately: boolean
}
/** One composed table row: the wire entry plus its bundle path. */
interface WebPluginRecord {
entry: WebBootEntry
clientPath: string
}
/** Narrow an unknown parsed JSON value to the dshClient declaration, throwing on malformed fields. */
function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration | undefined {
if (value === undefined) return undefined
if (typeof value !== 'object' || value === null) {
throw new Error(`client-modules: ${pkgName} has a non-object dshClient declaration`)
}
const decl = value as Record<string, unknown>
if (typeof decl.platform !== 'string') {
throw new Error(`client-modules: ${pkgName} dshClient.platform must be a string`)
}
if (decl.inject !== undefined && (!Array.isArray(decl.inject) || decl.inject.some(i => typeof i !== 'string'))) {
throw new Error(`client-modules: ${pkgName} dshClient.inject must be a string array`)
}
if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
throw new Error(`client-modules: ${pkgName} dshClient.immediately must be a boolean`)
}
return {
platform: decl.platform,
...(decl.inject !== undefined ? { inject: decl.inject as string[] } : {}),
...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}),
}
}
/** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
function clientExportOf(pkgName: string, exportsField: unknown): string | undefined {
if (typeof exportsField !== 'object' || exportsField === null) return undefined
const client = (exportsField as Record<string, unknown>)['./client']
if (client === undefined) return undefined
if (typeof client === 'string') return client
if (typeof client === 'object' && client !== null) {
const fallback = (client as Record<string, unknown>).default
if (typeof fallback === 'string') return fallback
}
throw new Error(`client-modules: ${pkgName} exports["./client"] has an unsupported shape`)
}
/** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */
function shortHash(input: string | Buffer): string {
return createHash('sha1').update(input).digest('hex').slice(0, 12)
}
/** Graph row for one bundle rev (url carries the rev as its cache-busting query). */
function graphRow(id: string, rev: string, injectEdges: string[] | undefined, immediately: boolean): WebBootEntry {
return {
id,
url: `/plugins/${id}/client.js?rev=${rev}`,
rev,
...(injectEdges !== undefined ? { inject: injectEdges } : {}),
...(immediately ? { immediately: true } : {}),
}
}
/**
* One composed client entry pushed by the host (web2 §0 graph row).
* `immediately` marks stage-one prefetch; `inject` is informational graph
* metadata (the authoritative edges live in each package's dshClient
* declaration and reach fibers through entry creation).
*
* Wire contract, held on both sides: the producing peer lives in
* `@deepseek-ai/dsh-host-webserver` (host packages keep zero workspace
* dependencies, so neither side imports the other's shape — drift between
* the two declarations is a bug against the web2 contract).
* Inject the boot entry graph into index.html: `window.__DSH_BOOT__` as the
* first script in <head> (before the shell bundle reads it). `<` is escaped in
* the JSON so plugin-controlled strings cannot break out of the script element.
* @param html - the index.html source.
* @param graph - the composed entry graph.
* @returns the html with the graph script injected.
*/
export interface WebBootEntry {
/** Entry name == package name (or a shell-owned pseudo id, e.g. app-shell). */
id: string
/**
* Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. Absent only on
* shell-owned pseudo rows (app-shell) whose module is statically registered
* — a row that is neither fetchable nor static-registered fails loud.
*/
url?: string
/** Bundle content hash (cache-busting consistency anchor); absent with url. */
rev?: string
/** Package-name dependency edges, informational (preflight display / HMR diffing). */
inject?: string[]
/** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
immediately?: boolean
}
/** The composed client entry graph the host injects as `window.__DSH_BOOT__` (dual-held wire contract — see {@link WebBootEntry}). */
export interface WebBootGraph {
/** Consistency anchor over the whole graph (content + bundle hashes). */
rev: string
/** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
entries: WebBootEntry[]
}
/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff, contract C6). */
export interface ClientPluginHandoff {
/** Plugin id (package name) — the registration key; must match the graph row being executed. */
id: string
/**
* Closure factory holding the whole bundle body: receives the synchronous
* require bound to the module table and returns the bundle's export
* surface. Runs once, at materialization.
*/
factory: (require: (spec: string) => unknown) => Record<string, unknown>
}
/** Window surface this loader owns (bundle side of the handoff protocol) plus the host-injected graph. */
export interface DshWindow {
/** Host-composed entry graph, injected before the shell bundle runs. */
__DSH_BOOT__?: WebBootGraph
/** Bundle registration sink; installed once per page by {@link createClientModuleLoader} (contract C6). */
__ModuleLoader__?: { load(handoff: ClientPluginHandoff): void }
}
/** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph seam, flat today). */
export interface ClientModuleRecord {
/** Module id (entry name / package name). */
id: string
/** The materialized export surface (factory `module.exports`, or the shell module for static registrations). */
surface: unknown
/** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
styles: string[]
/** Observed `require()` edges (module-graph seam; only table words can appear today). */
edges: Set<string>
export function injectBootManifest(html: string, graph: WebBootGraph): string {
const json = JSON.stringify(graph).replaceAll('<', '\\u003c')
const script = `<script>window.__DSH_BOOT__ = ${json}</script>`
const head = html.indexOf('<head>')
if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
// Headless fixture pages may lack <head>; prepending keeps the read-before-shell ordering.
return `${script}${html}`
}
/**
* The internal-seam subset the vendored Loader and the client HMR plugin
* consume. Mounted on `ctx.loader.internal` by the shell boot and provided
* as `ctx.modules` (contract C5).
* The web plugin table service: incremental dshClient scan + wire composition
* + bundle route + index tap. Construction runs the activation scan
* synchronously — a malformed declaration or missing bundle among the
* already-loaded entries aggregates into one loud throw (FAILED fiber; the
* boot sweep reports it).
*/
export interface ClientModuleLoader {
/** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
version: 'client'
/** Materialized-module registry: id → record. The governance-side read face for entry export surfaces. */
loadCache: Map<string, ClientModuleRecord>
export class ClientModuleHostService extends Service {
static inject = ['httpServer', 'loader']
private readonly table = new Map<string, WebPluginRecord>()
// Negative verdicts (unresolvable specifier — builtins like cordis:include,
// subpath rows — or a package without a web dshClient declaration) are
// cached as null and never expire: plugin-set changes take effect on restart.
private readonly pkgMeta = new Map<string, PkgMeta | null>()
private readonly rebuildListeners = new Set<(id: string, rev: string) => void>()
private readonly graphListeners = new Set<() => void>()
private readonly dirty = new Set<string>()
private readonly resolvePkgJson: (spec: string) => string
private flushQueued = false
private composed: WebBootGraph
/**
* Internal seam consumed by the vendored Loader's `tree.import`. Resolves
* `specifier` through the branch order documented on the module, fetching
* and executing a bundle when needed.
* @param specifier - module specifier (entry name or table word).
* @param parentURL - importer URL (unused — the client module graph is flat).
* @param attrs - import attributes (unused; interface parity with Node's seam).
* @returns the module's export surface.
* Build the service: subscribe, seed, and run the activation flush.
* @param ctx - plugin context carrying httpServer and loader.
*/
import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
constructor(ctx: Context) {
super(ctx, 'clientModuleHost')
// Resolution anchor: the config tree's baseUrl (the cordis.yml directory,
// whose package declares every composed plugin as a dependency). The
// modules package's own URL would miss sibling packages under pnpm's
// isolated node_modules.
if (ctx.baseUrl === undefined) {
throw new Error('client-modules: ctx.baseUrl is unset — the node half needs the config-tree anchor to resolve plugin packages')
}
const require = createRequire(ctx.baseUrl)
this.resolvePkgJson = spec => require.resolve(`${spec}/package.json`)
// Subscribe before seeding so a fiber arriving mid-activation lands in the
// same dirty set (Set idempotence makes the overlap harmless). An entry-less
// fiber is a child plugin or a manual mount — never a loader row; O(1) drop.
ctx.on('internal/plugin', (fiber) => {
const entryName = fiber.entry?.options.name
if (entryName === undefined) return
this.dirty.add(entryName)
if (this.flushQueued) return
this.flushQueued = true
queueMicrotask(() => {
this.flushQueued = false
this.flush((err) => { ctx.logger.warn(err) })
})
})
// Activation pass: the initial scan IS the incremental path over the
// current entries, flushed synchronously (nothing async between subscribe,
// seed, and flush).
for (const entry of ctx.loader.entries()) this.dirty.add(entry.options.name)
this.composed = this.compose()
const failures: Error[] = []
this.flush(err => failures.push(err))
if (failures.length > 0) {
throw new AggregateError(
failures,
`client-modules: ${String(failures.length)} client package(s) failed to compose:\n${failures.map(e => ` - ${e.message}`).join('\n')}`,
)
}
ctx.effect(
() => ctx.httpServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }),
'client-modules: bundle route',
)
ctx.effect(
() => ctx.httpServer.tapIndex(html => injectBootManifest(html, this.composed)),
'client-modules: boot manifest injection',
)
}
/**
* Register a shell-own module (app-shell — code that ships inside the shell
* bundle and never arrives as a plugin bundle).
* @param id - entry name (shell-owned pseudo id).
* @param module - the statically imported module namespace.
* Current composed entry graph (stable object between changes).
* @returns the graph served as `window.__DSH_BOOT__`.
*/
registerStatic(id: string, module: unknown): void
graph(): WebBootGraph {
return this.composed
}
/**
* Stage-one arrival: fetch the entry's bundle and execute it, registering
* its factory (no materialization — module side effects wait for import).
* No-op for static-registered ids and ids whose factory is already
* registered; concurrent calls share one in-flight task. To force a fresh
* fetch (HMR), {@link invalidate} first.
* @param id - graph entry name.
* Absolute path of an entry's client bundle.
* @param id - entry id (package name).
* @returns the path, or undefined for an unknown id.
*/
prefetch(id: string): Promise<void>
clientPath(id: string): string | undefined {
return this.table.get(id)?.clientPath
}
/**
* Full reset of one module: drop its registered factory, its materialized
* record, and any consumed bundle text, so the next prefetch/import
* refetches and re-executes (the HMR invalidation hook).
* @param id - entry name to invalidate.
* Re-hash one bundle (the HMR watch's registration hook — the only entry
* point through which bundle content changes reach the graph).
* @param id - entry id (package name).
* @returns the new rev, or undefined for an unknown id.
*/
invalidate(id: string): void
rebuilt(id: string): string | undefined {
const record = this.table.get(id)
if (record === undefined) return undefined
const rev = shortHash(readFileSync(record.clientPath))
if (rev === record.entry.rev) return rev
record.entry = graphRow(id, rev, record.entry.inject, record.entry.immediately === true)
this.composed = this.compose()
for (const notify of this.rebuildListeners) {
// Containment: rebuilt() runs inside the HMR watch callback — a
// throwing subscriber must not kill the poll or skip later subscribers.
try {
notify(id, rev)
} catch (error) {
this.ctx.logger.error(error)
}
}
this.notifyGraphChanged()
return rev
}
/**
* Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.
* @param listener - receives the entry id and its new bundle rev.
* @returns the unsubscriber.
*/
onRebuilt(listener: (id: string, rev: string) => void): () => void {
this.rebuildListeners.add(listener)
return () => { this.rebuildListeners.delete(listener) }
}
/**
* Fires after any flush that recomposed the graph (row added/removed, or a
* rebuilt rev change). Pull model: listeners re-read {@link graph}.
* @param listener - notified with no payload.
* @returns the unsubscriber.
*/
onGraphChanged(listener: () => void): () => void {
this.graphListeners.add(listener)
return () => { this.graphListeners.delete(listener) }
}
private compose(): WebBootGraph {
const entries = [...this.table.values()].map(record => record.entry)
return { rev: shortHash(JSON.stringify(entries)), entries }
}
private notifyGraphChanged(): void {
for (const listener of this.graphListeners) {
// A throwing subscriber must not skip later subscribers (or escape into
// whatever triggered the flush — possibly an fs.watchFile callback).
try {
listener()
} catch (error) {
this.ctx.logger.error(error)
}
}
}
private resolveMeta(pkgName: string): PkgMeta | null {
const cached = this.pkgMeta.get(pkgName)
if (cached !== undefined) return cached
let pkgPath: string
try {
pkgPath = this.resolvePkgJson(pkgName)
} catch {
// Not a resolvable package root: loader builtins (cordis:include) and
// subpath entries (…/gateway) land here — permanently not a client row.
this.pkgMeta.set(pkgName, null)
return null
}
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
const decl = parseDshClient(pkgName, pkg.dshClient)
if (decl === undefined || decl.platform !== 'web') {
this.pkgMeta.set(pkgName, null)
return null
}
const clientRel = clientExportOf(pkgName, pkg.exports)
if (clientRel === undefined) {
throw new Error(`client-modules: ${pkgName} declares dshClient but exports no "./client" bundle`)
}
const meta: PkgMeta = {
clientPath: join(dirname(pkgPath), clientRel),
...(decl.inject !== undefined ? { inject: decl.inject } : {}),
immediately: decl.immediately === true,
}
this.pkgMeta.set(pkgName, meta)
return meta
}
/** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
private processOne(entryName: string): boolean {
let qualifies = false
for (const entry of this.ctx.loader.entries()) {
if (entry.options.name === entryName && entry.fiber !== undefined && !entry.disabled) {
qualifies = true
break
}
}
if (!qualifies) return this.table.delete(entryName)
if (this.table.has(entryName)) return false
const meta = this.resolveMeta(entryName)
if (meta === null) return false
// The rev rides the row from here on: a fiber restart reuses the row (and
// its rev) untouched; only rebuilt() re-reads the bundle.
const rev = shortHash(readFileSync(meta.clientPath))
this.table.set(entryName, { entry: graphRow(entryName, rev, meta.inject, meta.immediately), clientPath: meta.clientPath })
return true
}
private flush(onError: (err: Error) => void): void {
let changed = false
for (const entryName of [...this.dirty]) {
this.dirty.delete(entryName)
try {
if (this.processOne(entryName)) changed = true
} catch (error) {
// Steady state: one broken package must not poison the others; the
// activation pass aggregates these into a loud throw instead.
onError(error instanceof Error ? error : new Error(String(error)))
}
}
if (changed) {
this.composed = this.compose()
this.notifyGraphChanged()
}
}
private readonly serveBundle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405)
res.end()
return
}
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
// The id may contain a scope slash. Anything else under /plugins (including
// /plugins/events when the HMR row is absent) is an unknown resource.
const path = pathname.startsWith('/plugins/') && pathname.endsWith('/client.js')
? this.clientPath(pathname.slice('/plugins/'.length, -'/client.js'.length))
: undefined
if (path === undefined) {
res.writeHead(404)
res.end()
return
}
try {
const body = await readFile(path)
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' })
res.end(body)
} catch {
// Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.
res.writeHead(404)
res.end()
}
}
}
/** Options for {@link createClientModuleLoader} (assembled by the web shell at boot). */
export interface ClientModuleLoaderOptions {
/** Host-composed entry graph. */
graph: WebBootGraph
/** Module-table seed: platform-singleton specifier → shell instance. */
staticModules: Record<string, unknown>
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
fetchBundle?: (url: string) => Promise<string>
/**
* Bundle execution seam (synchronously performs the load() registration).
* Defaults to a <script> element carrying the code.
*/
executeBundle?: (code: string, url: string) => void
}
/**
* Build the client module system.
* @param options - entry graph, module-table staticModules, fetch/execute seams.
* @returns the loader the shell mounts as `ctx.loader.internal` and provides as `ctx.modules`.
*/
export function createClientModuleLoader(options: ClientModuleLoaderOptions): ClientModuleLoader {
return new ClientModuleLoaderImpl(options)
}
export default ClientModuleHostService

View File

@@ -15,14 +15,25 @@ export const name = 'client-modules-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: the module loader is pre-plugin kernel machinery —
* it emits no cordis events (the vendored Loader owns entry lifecycle events)
* and its mutable state (loadCache, handoff slot) lives below the plugin
* layer where invariant observers cannot mount before it runs; resolve branch
* order and handoff discipline are asserted by the web boot specs against the
* real execution path.
* Owned relation: the node half's boot entry graph must stay self-consistent
* — every row must resolve a clientPath under the same id (the
* /plugins/<id>/client.js URL it advertises would otherwise 404 on a browser
* that just received the graph). Checked on every scan trigger (cordis
* 'internal/plugin'): graph() and clientPath() read the same table object,
* so the relation holds at any instant — no need to wait out the node half's
* own microtask-debounced flush.
*/
const install: InvariantInstaller = () => {}
const install: InvariantInstaller = (ctx, fail) => {
ctx.on('internal/plugin', () => {
const host = ctx.get('clientModuleHost')
if (host === undefined) return // browser side / host without the node half: nothing to audit
for (const row of host.graph().entries) {
if (host.clientPath(row.id) === undefined) {
fail(`web plugin graph row "${row.id}" advertises ${row.url} but resolves no client bundle path — the served __DSH_BOOT__ would 404 on fetch`)
}
}
}, { global: true })
}
/**
* Register this package's invariant companion.

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
/**
* ClientModuleLoaderImpl behavior: lazy CJS arrival (bundle execution only
* ClientModuleSystem behavior: lazy CJS arrival (bundle execution only
* registers the factory), materialization on first import/require with
* memoization and recursive self-sequencing, the resolution branch order,
* shared in-flight arrival, invalidate-refetch (HMR), style claiming, the
@@ -9,9 +9,9 @@
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ClientModuleLoaderImpl, createClientModuleLoader,
type ClientModuleLoader, type ClientPluginHandoff, type DshWindow, type WebBootEntry,
} from '../src/index.ts'
ClientModuleSystem,
type BootModuleRow, type ClientModuleLoader, type ClientPluginHandoff, type DshWindow,
} from '../src/client/index.ts'
const win = globalThis as DshWindow
@@ -24,7 +24,7 @@ afterEach(() => {
for (const el of document.querySelectorAll('style, script')) el.remove()
})
const row = (id: string): WebBootEntry => ({ id, url: `/plugins/${id}/client.js?rev=0` })
const row = (id: string): BootModuleRow => ({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0' })
interface Bench {
loader: ClientModuleLoader
@@ -38,14 +38,14 @@ interface Bench {
* through the window sink (`null` scripts a bundle that never calls load).
*/
function bench(
entries: WebBootEntry[],
entries: BootModuleRow[],
bundles: Record<string, Factory | null> = {},
opts: { seed?: Record<string, unknown>; gated?: string[] } = {},
): Bench {
const fetched: string[] = []
const gates = new Map<string, () => void>()
const loader = createClientModuleLoader({
graph: { rev: 'test', entries },
const loader = new ClientModuleSystem({
modules: entries,
staticModules: opts.seed ?? {},
fetchBundle: (url) => {
fetched.push(url)
@@ -175,7 +175,7 @@ describe('require resolution', () => {
describe('static registry', () => {
it('serves shell-own modules to import and require without any fetch', async () => {
const shell = { marker: 'app-shell' }
const b = bench([row('a'), { id: 'app-shell' }], {
const b = bench([row('a')], {
a: req => ({ dep: req('app-shell') }),
})
b.loader.registerStatic('app-shell', shell)
@@ -216,18 +216,13 @@ describe('failure modes', () => {
await expect(b.loader.prefetch('nope')).rejects.toThrow('prefetch("nope") — not a graph entry')
})
it('a graph row with no url and no static registration is loud', async () => {
const b = bench([{ id: 'ghost' }])
await expect(b.loader.import('ghost', '', {})).rejects.toThrow('no bundle url and no static registration')
})
it('a duplicate graph entry is loud at construction', () => {
expect(() => bench([row('a'), row('a')])).toThrow('duplicate graph entry "a"')
})
it('double boot is loud', () => {
bench([])
expect(() => new ClientModuleLoaderImpl({ graph: { rev: 't', entries: [] }, staticModules: {} }))
expect(() => new ClientModuleSystem({ modules: [], staticModules: {} }))
.toThrow('already installed (double boot?)')
})
})
@@ -289,7 +284,7 @@ describe('default transport seams', () => {
const code = 'window.__ModuleLoader__ = document.__realmBridge;\n'
+ 'window.__ModuleLoader__.load({ id: "dee", factory: function () { return { marker: "via-script" } } })'
vi.stubGlobal('fetch', async () => ({ ok: true, text: async () => code }))
const loader = createClientModuleLoader({ graph: { rev: 't', entries: [row('dee')] }, staticModules: {} })
const loader: ClientModuleLoader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
;(document as unknown as Record<string, unknown>).__realmBridge = win.__ModuleLoader__
const surface = await loader.import('dee', '', {})
expect((surface as { marker: string }).marker).toBe('via-script')
@@ -300,7 +295,7 @@ describe('default transport seams', () => {
it('a non-ok bundle response is loud with the status', async () => {
vi.stubGlobal('fetch', async () => ({ ok: false, status: 404 }))
const loader = createClientModuleLoader({ graph: { rev: 't', entries: [row('dee')] }, staticModules: {} })
const loader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
await expect(loader.prefetch('dee')).rejects.toThrow('answered 404')
})
})

View File

@@ -3,22 +3,14 @@
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"types": ["node"]
},
"include": [
"src"
],
"include": ["src"],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
}
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/loader" },
{ "path": "../../host/webserver" },
{ "path": "../../support/invariants" }
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-modules', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -40,6 +40,15 @@ function materializeNode(
): ConversationNode {
switch (event.type) {
case 'user/message':
// Injected context (plugin/goal source) folds to a context node, not a
// user message; only a direct human prompt is a user node.
if (event.data.source.kind !== 'user') {
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
meta: event.data.meta,
}
}
return {
kind: 'user', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
@@ -55,12 +64,6 @@ function materializeNode(
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
content: event.data.content, source: event.data.source,
}
case 'context/message':
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
meta: event.data.meta,
}
case 'tool/result': {
const call = callIndex.get(String(event.data.callId))
return {
@@ -75,7 +78,7 @@ function materializeNode(
resultView,
}
}
/* v8 ignore next 2 -- defensive arm: fold output only carries the five
/* v8 ignore next 2 -- defensive arm: fold output only carries the four
surface-eligible types, and each has a case above; reachable only if core
adds an eligible type. */
default:

View File

@@ -40,7 +40,7 @@ describe('FoldAdapter', () => {
ev.user(0, '用户'),
ev.assistant(1, 0, '助手'),
at(2, { type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: [{ type: 'text', text: '插话' }], source: { kind: 'user' } } }),
at(3, { type: 'context/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
at(3, { type: 'user/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(5, 0, 'c1', '结果'),
]

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-client-web
Web shell kernel: `bootWebShell(el, seams?)` mounts the whole client through the two-stage boot (web2). Stage one (module face): build the client module system (`@deepseek-ai/dsh-client-modules`) over the host-pushed entry graph (`window.__DSH_BOOT__`) and prefetch the `immediately` tier in parallel — bundle execution registers factories only. Stage two (plugin face): mount the vendored cordis Loader with the module system injected as its `internal` seam, create one loader entry per graph row plus the shell-own app-shell assembly entry (tree.import materializes each module), and gate AppRoot on the settle (loader quiesced + every entry fiber ACTIVE → full UI in one switch). Composition is entirely the host graph's: the roster and the immediately tier live in the composing app; the shell makes zero composition decisions.
Web shell kernel: `new AppWebEntry(el, seams?).run()` mounts the whole client through the two-stage boot (web2). Stage one (module face): build the client module system (`@deepseek-ai/dsh-client-modules`) over the host-pushed entry graph (`window.__DSH_BOOT__`) and prefetch the `immediately` tier in parallel — bundle execution registers factories only. Stage two (plugin face): mount the vendored cordis Loader with the module system injected as its `internal` seam, create one loader entry per graph row plus the shell-own app-shell assembly entry (tree.import materializes each module), and gate AppRoot on the settle (loader quiesced + every entry fiber ACTIVE → full UI in one switch). Composition is entirely the host graph's: the roster and the immediately tier live in the composing app; the shell makes zero composition decisions.
Shell self-sufficiency (web2 hard rule): the kernel value-imports no plugin package — the boot status store and signals are hand-rolled here (`loader-status.ts`), so the loading page works while (and especially when) plugins fail. The app-shell assembly (`@deepseek-ai/dsh-client-app-shell`, a shell-owned pseudo entry with no npm package behind it) is the only module registered through `registerStatic`; it inject-waits on slots/sessions/layout like any plugin.

View File

@@ -1,23 +1,32 @@
/**
* Web shell boot — the kernel face consumed by the apps/web entry. Everything
* here is machinery that cannot itself be an entry, and none of it
* Web shell boot kernel — the face consumed by the apps/web entry. Everything
* here is machinery that cannot itself be a loader entry, and none of it
* value-imports a plugin package (web2 shell self-sufficiency rule: the
* loading page must work while — especially when — plugins fail).
* loading page must work while — especially when — plugins fail). The one
* sanctioned exception is the modules package (design §4.7 bootstrap
* identity): the module system cannot arrive through itself, so its class
* and its client-half wrapper are shell-bundled and the kernel adopts its
* plugin entry once cordis is up.
*
* Two-stage boot (web2 §0):
* Stage one (module face): build the module system over the host graph
* (`window.__DSH_BOOT__`) and prefetch every `immediately` row in parallel
* — fetch + execute registers factories only; module side effects wait for
* materialization. Prefetch failures are non-fatal here: stage two's
* import path retries the fetch and owns the loud failure.
* Stage two (plugin face): mount the vendored cordis Loader, inject the
* module system as its internal seam (BEFORE any entry exists — the
* bare-import fallback in tree.import must never run in a browser), create
* one loader entry per graph row (tree.import materializes each module),
* let fibers activate on service availability, then loader.await() + a
* full fiber sweep (all ACTIVE, else reject listing who/what/which
* service) → flip the settled signal so AppRoot switches to the real UI in
* one pass.
* AppWebEntry.run(), module face first, then plugin face: parse
* `window.__DSH_BOOT__` into the two-view BootManifest (wire boundary, D16)
* → build the module system over the module-view rows → render the loading
* page → prefetch every `immediately` row in parallel with mounting the
* vendored cordis Loader (internal-seam injection BEFORE any entry exists —
* the bare-import fallback in tree.import must never run in a browser) →
* await the prefetch tier, THEN adopt the modules entry and create one
* loader entry per plugin-view row plus the shell-own app-shell assembly
* entry → loader.await() + a full fiber sweep (all ACTIVE, else fail
* listing who/what/which service) → flip the settled signal so AppRoot
* switches to the real UI in one pass.
*
* Entry creation waits for the whole immediately tier: materialization runs
* synchronous cross-package require edges (e.g. i18n → runtime/client) that
* fiber inject waiting cannot protect — a bundle's factory must be
* registered before any dependent entry materializes. Per-row prefetch
* failures still resolve silently (the create-side import refetches and
* owns the loud failure), so the barrier never turns one bad bundle into a
* boot-wide fail-fast.
*
* Composition lives in the host graph; the shell makes zero composition
* decisions (the app-shell assembly is itself a graph entry, the only
@@ -25,148 +34,205 @@
*/
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { createRoot } from 'react-dom/client'
import { createRoot, type Root } from 'react-dom/client'
import * as ModulesClient from '@deepseek-ai/dsh-client-modules/client'
import {
createClientModuleLoader,
type ClientModuleLoader, type ClientModuleLoaderOptions, type DshWindow, type WebBootGraph,
} from '@deepseek-ai/dsh-client-modules'
ClientModuleSystem, parseBootManifest,
type BootManifest, type ClientModuleSystemOptions, type DshWindow,
} from '@deepseek-ai/dsh-client-modules/client'
import * as AppShell from './app-shell.ts'
import { APP_SHELL_ID } from './app-shell.ts'
import { AppRoot } from './AppRoot.tsx'
import { getStaticModules } from './seed.ts'
import {
STATE_LABELS, createLoaderStatusStore, createSignal, type LoaderStatusStore,
} from './loader-status.ts'
import { STATE_LABELS, createLoaderStatusStore, createSignal } from './loader-status.ts'
import './base.css'
/** Module transport seams the shell passes through (jsdom tests replace the <script> path). */
export type BootSeams = Pick<ClientModuleLoaderOptions, 'fetchBundle' | 'executeBundle'>
export type BootSeams = Pick<ClientModuleSystemOptions, 'fetchBundle' | 'executeBundle'>
/**
* Sweep every loader entry after the tree quiesced: an entry without a fiber
* failed its import; a fiber not ACTIVE is FAILED (apply threw) or PENDING
* (a required service never arrived — cordis inject waiting has no timeout,
* so this sweep is the fail-loud compensation).
* The modules package's own graph row id. The kernel adopts that entry
* itself (its wrapper is statically registered — shell-bundled code, never
* fetched), so the plugin-row loop must skip it: the vendored Group.create
* does not deduplicate by name, and a second fiber would provide 'modules'
* twice.
*/
function assertEntriesActive(ctx: Context): void {
const failures: string[] = []
for (const entry of ctx.loader.entries()) {
const name = entry.options.name
if (entry.fiber === undefined) {
failures.push(`${name}: import failed (see console for the import error)`)
continue
}
const state = STATE_LABELS[entry.fiber.state]
if (state === 'active') continue
if (state === 'pending') {
const missing = Object.keys(entry.fiber.inject).filter((service) => ctx.get(service) === undefined)
failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
} else {
failures.push(`${name}: ${state}`)
}
}
if (failures.length > 0) {
throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
}
}
/** Stage one: prefetch the immediately tier (factory registration only; failures defer to stage two's import). */
async function prefetchImmediateTier(modules: ClientModuleLoader, graph: WebBootGraph): Promise<void> {
await Promise.all(graph.entries
.filter((row) => row.immediately === true)
.map((row) => modules.prefetch(row.id).catch(() => {
// Import (stage two) refetches and reports this loudly per entry;
// swallowing here keeps one failing prefetch from masking the others.
})))
}
/** Stage two: mount the Loader, inject the internal seam, create the graph entries, settle, sweep. */
async function runPluginBoot(
ctx: Context, modules: ClientModuleLoader, graph: WebBootGraph, status: LoaderStatusStore,
): Promise<void> {
await ctx.plugin(Loader)
const loader = ctx.loader
// Inject the module system BEFORE any entry exists: tree.import falls back
// to a bare dynamic import when internal is undefined, which in a browser
// is a guaranteed loud failure — correct as a tripwire, never as a path.
loader.internal = modules as never
// Status projection: AppRoot displays fiber truth. Every internal/status
// transition under an entry re-projects that entry's row from its ROOT
// fiber (child plugin fibers share the same entry).
ctx.on('internal/status', (fiber) => {
const entry = fiber.entry
if (entry === undefined || entry.fiber === undefined) return
status.set(entry.options.name, STATE_LABELS[entry.fiber.state])
})
// Entry creation order carries no semantics (fiber inject waiting owns
// activation order); creating concurrently lets non-prefetched bundle
// fetches parallelize. The app-shell assembly entry is appended by the
// kernel: it is shell-own code (host graph rows are all plugin bundles),
// and mounting the assembly is not a composition decision — it rides the
// same entry lifecycle so the sweep and status cover it uniformly.
const rows = [...graph.entries.map((row) => row.id), APP_SHELL_ID]
await Promise.all(rows.map(async (name) => {
status.set(name, 'loading')
const id = await loader.create({ name })
// A failed import leaves the entry fiberless (Entry._init logs and
// returns); project it as failed — no fiber means no status event.
if (loader.resolve(id).fiber === undefined) {
status.set(name, 'failed')
}
}))
await loader.await()
assertEntriesActive(ctx)
}
const MODULES_ID = '@deepseek-ai/dsh-client-modules'
/**
* Mount the web shell into a DOM element and start the two-stage boot chain.
* @param el - mount point (the app's #root).
* @param seams - optional module transport overrides (test environments).
* @returns unmount disposer.
* The web shell kernel: mounts the loading page into a DOM element and runs
* the two-stage boot over the host graph. Fields hold only what must exist
* before cordis does — the parsed manifest, the module system, and the
* loading-page UI handles; everything else lives in plugins.
*/
export function bootWebShell(el: HTMLElement, seams?: BootSeams): () => void {
const graph = (globalThis as DshWindow).__DSH_BOOT__
if (graph === undefined) throw new Error('web boot: no entry graph (window.__DSH_BOOT__ missing)')
export class AppWebEntry {
private readonly el: HTMLElement
private readonly seams: BootSeams | undefined
private readonly status = createLoaderStatusStore()
private readonly settled = createSignal(false)
private readonly error = createSignal<string | undefined>(undefined)
// Assigned by run() before any private method or settled-gated closure reads them.
private ctx!: Context
private modules!: ClientModuleSystem
private manifest!: BootManifest
private root: Root | undefined
const ctx = new Context()
const modules = createClientModuleLoader({ graph, staticModules: getStaticModules(), ...seams })
// The app-shell assembly is the only shell-own module: every other graph
// row is a plugin bundle arriving through fetch (web2 single package form).
modules.registerStatic(APP_SHELL_ID, AppShell)
// Contract C5: the module system is a boot-owned kernel service (ctx.modules).
ctx.reflect.provide('modules', modules)
/**
* Hold the mount point; all work happens in {@link run}.
* @param el - mount point (the app's #root).
* @param seams - optional module transport overrides (test environments).
*/
constructor(el: HTMLElement, seams?: BootSeams) {
this.el = el
this.seams = seams
}
const status = createLoaderStatusStore()
const settled = createSignal(false)
const error = createSignal<string | undefined>(undefined)
/**
* Run the boot chain to settlement. Boot-chain failures resolve (not
* reject): the loading page stays up and renders the failure report (the
* fail-loud surface the kernel owns). Rejects only when the boot manifest
* is missing or malformed — there is nothing to boot against.
* @returns resolves once the UI settled or the failure report rendered.
*/
async run(): Promise<void> {
this.manifest = parseBootManifest((globalThis as DshWindow).__DSH_BOOT__)
const root = createRoot(el)
root.render(
<AppRoot
settled={settled}
status={status}
error={error}
renderApp={() => {
const shell = ctx.get('appShell')
// Unreachable after a clean settle (the app-shell entry is in every graph).
if (shell === undefined) throw new Error('web boot: appShell service missing after settled')
return shell.renderApp()
}}
/>,
)
this.modules = new ClientModuleSystem({
modules: this.manifest.modules, staticModules: getStaticModules(), ...this.seams,
})
// The app-shell assembly is the only shell-own module: every other graph
// row is a plugin bundle arriving through fetch (web2 single package form).
this.modules.registerStatic(APP_SHELL_ID, AppShell)
// Adoption handoff, supply side (design §4.7): register the modules
// package's own client half under its bare package name (= graph row id
// = entry name — a suffixed key would miss the statics branch and
// trigger a real fetch), and put the instance on the kernel slot the
// wrapper's apply reads to provide ctx.modules.
this.modules.registerStatic(MODULES_ID, ModulesClient)
;(globalThis as DshWindow).__DSH_MODULES__ = this.modules
prefetchImmediateTier(modules, graph)
.then(() => runPluginBoot(ctx, modules, graph, status))
.then(
() => { settled.set(true) },
(reason: unknown) => {
// Stay on the loading page; surface the sweep report (fail loud).
console.error(reason)
error.set(reason instanceof Error ? reason.message : String(reason))
},
this.root = createRoot(this.el)
this.root.render(
<AppRoot
settled={this.settled}
status={this.status}
error={this.error}
renderApp={() => {
const shell = this.ctx.get('appShell')
// Unreachable after a clean settle (the app-shell entry is in every graph).
if (shell === undefined) throw new Error('web boot: appShell service missing after settled')
return shell.renderApp()
}}
/>,
)
return () => { root.unmount() }
// The immediately tier prefetches in parallel with Loader mounting;
// runPluginBoot awaits it before creating entries (see module comment:
// cross-package synchronous require edges need every immediately-tier
// factory registered before any materialization).
const prefetching = this.prefetchImmediateTier()
this.ctx = new Context()
try {
await this.runPluginBoot(prefetching)
this.settled.set(true)
} catch (reason) {
// Stay on the loading page; surface the sweep report (fail loud).
console.error(reason)
this.error.set(reason instanceof Error ? reason.message : String(reason))
}
}
/** Unmount the shell (loading page or settled UI). */
dispose(): void {
this.root?.unmount()
}
/** Prefetch the immediately tier (factory registration only; failures defer to the import path). */
private async prefetchImmediateTier(): Promise<void> {
await Promise.all(this.manifest.plugins
.filter((row) => row.immediately)
.map((row) => this.modules.prefetch(row.id).catch(() => {
// Import refetches and reports this loudly per entry; swallowing
// here keeps one failing prefetch from masking the others.
})))
}
/** Plugin face: mount the Loader, inject the internal seam, adopt modules, create the graph entries, settle, sweep. */
private async runPluginBoot(prefetching: Promise<void>): Promise<void> {
const ctx = this.ctx
await ctx.plugin(Loader)
const loader = ctx.loader
// Inject the module system BEFORE any entry exists: tree.import falls back
// to a bare dynamic import when internal is undefined, which in a browser
// is a guaranteed loud failure — correct as a tripwire, never as a path.
loader.internal = this.modules as never
// Status projection: AppRoot displays fiber truth. Every internal/status
// transition under an entry re-projects that entry's row from its ROOT
// fiber (child plugin fibers share the same entry).
ctx.on('internal/status', (fiber) => {
const entry = fiber.entry
if (entry === undefined || entry.fiber === undefined) return
this.status.set(entry.options.name, STATE_LABELS[entry.fiber.state])
})
// Barrier before any entry exists: entry creation materializes bundles,
// and materialization runs synchronous cross-package require edges that
// need every immediately-tier factory already registered (module
// comment). Resolves even when individual prefetches failed.
await prefetching
// Adoption handoff, plugin side: the modules entry is created first —
// its wrapper apply reads the kernel slot and provides ctx.modules (the
// provide lives on the plugin face; see MODULES_ID for why the row loop
// must then skip it).
const rows = [MODULES_ID, ...this.manifest.plugins.map((row) => row.id).filter((id) => id !== MODULES_ID), APP_SHELL_ID]
// Entry creation order carries no semantics (fiber inject waiting owns
// activation order); creating concurrently lets non-prefetched bundle
// fetches parallelize. The app-shell assembly entry is appended by the
// kernel: it is shell-own code (host graph rows are all plugin bundles),
// and mounting the assembly is not a composition decision — it rides the
// same entry lifecycle so the sweep and status cover it uniformly.
await Promise.all(rows.map(async (name) => {
this.status.set(name, 'loading')
const id = await loader.create({ name })
// A failed import leaves the entry fiberless (Entry._init logs and
// returns); project it as failed — no fiber means no status event.
if (loader.resolve(id).fiber === undefined) {
this.status.set(name, 'failed')
}
}))
await loader.await()
this.assertEntriesActive()
}
/**
* Sweep every loader entry after the tree quiesced: an entry without a
* fiber failed its import; a fiber not ACTIVE is FAILED (apply threw) or
* PENDING (a required service never arrived — cordis inject waiting has no
* timeout, so this sweep is the fail-loud compensation).
*/
private assertEntriesActive(): void {
const ctx = this.ctx
const failures: string[] = []
for (const entry of ctx.loader.entries()) {
const name = entry.options.name
if (entry.fiber === undefined) {
failures.push(`${name}: import failed (see console for the import error)`)
continue
}
const state = STATE_LABELS[entry.fiber.state]
if (state === 'active') continue
if (state === 'pending') {
const missing = Object.keys(entry.fiber.inject).filter((service) => ctx.get(service) === undefined)
failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
} else {
failures.push(`${name}: ${state}`)
}
}
if (failures.length > 0) {
throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
}
}
}

View File

@@ -1,13 +1,13 @@
/**
* Web shell library entry. The shell's product is {@link bootWebShell} —
* apps/web's vite entry calls it against #root; everything else (AppRoot
* Web shell library entry. The shell's product is {@link AppWebEntry} —
* apps/web's vite entry runs it against #root; everything else (AppRoot
* gate, app-shell assembly entry, module-table staticModules, platform constants) is
* internal to the boot chain. PLATFORM_MODULES is re-exported as the C1
* single source of truth for the tsdown client externals projection.
* @module @deepseek-ai/dsh-client-web
*/
export { bootWebShell, type BootSeams } from './boot.tsx'
export { AppWebEntry, type BootSeams } from './boot.tsx'
export { AppRoot, type AppRootProps } from './AppRoot.tsx'
export { buildRenderApp, type AssemblyDeps } from './app.tsx'
export { DocumentTitle, type DocumentTitleProps } from './DocumentTitle.tsx'

View File

@@ -563,7 +563,10 @@ describe('pressure measurement and retention', () => {
const result = await compactIfNeeded(compact, session)
expect(result).not.toBeNull()
expect(prefix).toHaveLength(1)
expect(session.events.some(event => event.type === 'context/message')).toBe(false)
// The routed request prefix must not reach the surface as its own message
// (the compaction summary itself is an expected plugin-sourced checkpoint).
expect(session.events.some(event => event.type === 'user/message'
&& event.data.content.some(block => block.type === 'text' && block.text.includes('p'.repeat(600))))).toBe(false)
})
it('uses the latest logged request envelope without an AgentOptions override', async () => {
@@ -959,7 +962,7 @@ describe('compaction region transaction', () => {
const compact = service()
const session = conversation(2)
compact.mutateDuringSummary = () => {
session.append('context/message', {
session.append('user/message', {
content: [{ type: 'text', text: 'concurrent surface mutation' }],
source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: 'append' })

View File

@@ -197,7 +197,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
provider: 'unconfigured-agent-fallback',
model: 'unconfigured-agent-fallback',
})
agent.send([{ type: 'text', text: 'do a routed multi-step task' }])
agent.followup([{ type: 'text', text: 'do a routed multi-step task' }])
await waitForIdle(ctx, agent)
expect(agent.session.requestHeader()?.config.model).toBe('mock')
@@ -215,7 +215,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
const { ctx } = await harness(8)
try {
const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'do tool work' }])
agent.followup([{ type: 'text', text: 'do tool work' }])
await waitForIdle(ctx, agent)
const events = [...agent.session.events]
@@ -241,7 +241,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
const { ctx } = await harness(8)
try {
const agent = ctx.agentLoop.create(SessionId('repro'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'do a long multi-step task' }])
agent.followup([{ type: 'text', text: 'do a long multi-step task' }])
await waitForIdle(ctx, agent)
const events = [...agent.session.events]
@@ -297,7 +297,7 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
})
seedOverflowHistory(agent)
agent.send([{ type: 'text', text: 'continue from history' }])
agent.followup([{ type: 'text', text: 'continue from history' }])
await agent.whenIdle()
expect(adapter.conversationRequests).toHaveLength(2)
@@ -360,7 +360,7 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
try {
const agent = ctx.agentLoop.create(SessionId('alternating-recovery'), { provider: 'mock', model: 'mock' })
seedOverflowHistory(agent)
agent.send([{ type: 'text', text: 'continue from history' }])
agent.followup([{ type: 'text', text: 'continue from history' }])
await agent.whenIdle()
expect(adapter.conversationRequests).toHaveLength(3)

View File

@@ -33,7 +33,7 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an
## Surface contract
`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead:
`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead:
1. appends `compact/start` (log-only) — acquires the lock,
2. summarizes the range,

View File

@@ -96,23 +96,23 @@ describe('tool-pairing boundaries', () => {
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
provenance: { provider: 'mock', model: 'mock' },
}, SURFACE)
midStep.append('context/message', {
midStep.append('user/message', {
content: [{ type: 'text', text: 'background update' }],
source: { kind: 'plugin', plugin: 'test' },
}, SURFACE)
midStep.append('tool/result', {
turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false,
}, SURFACE)
expect(before(midStep, 'context/message')).toBe(false)
expect(after(midStep, 'context/message')).toBe(false)
expect(before(midStep, 'user/message')).toBe(false)
expect(after(midStep, 'user/message')).toBe(false)
const free = new Session(SessionId('neutral-free'))
free.append('context/message', {
free.append('user/message', {
content: [{ type: 'text', text: 'idle injection' }],
source: { kind: 'user' },
}, SURFACE)
expect(before(free, 'context/message')).toBe(true)
expect(after(free, 'context/message')).toBe(true)
expect(before(free, 'user/message')).toBe(true)
expect(after(free, 'user/message')).toBe(true)
})
})

View File

@@ -5,7 +5,7 @@
## Public API
- `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id or cwd, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses its latest log-backed title as the mention label and falls back to the session id; titles and message bodies are not searched.
- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `HookContext`. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `send()` or `steer()`.
- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `HookContext`. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `followup()` or `steer()`.
- `encodeSessionReferenceUri()` and `decodeSessionReferenceUri()` implement `dsh-session:<base64url(JSON.stringify(sessionId))>` so every JavaScript string id round-trips exactly. `formatSessionReferenceMention()` emits `@[label](uri)`, and `parseSessionReferenceText()` replaces Markdown mentions or bare canonical URIs with readable `@label` text while returning structured references. Explicit Markdown mentions reject every malformed URI; bare text is considered a reference only when a non-empty base64url-shaped payload follows the scheme, and a matching noncanonical candidate still fails. Empty or punctuation-only scheme mentions remain ordinary discussion text.
## Snapshot semantics

View File

@@ -57,7 +57,6 @@ function projectSessionConversation(snapshot: SessionSurfaceSnapshot): Projected
break
}
case 'tool/result':
case 'context/message':
break
/* v8 ignore next 2 -- SurfaceEventType is closed and every variant is handled above. */
default:

View File

@@ -75,7 +75,7 @@ function appendConversation(session: Session): void {
{ surfaceOp: 'append' },
)
session.append(
'context/message',
'user/message',
{ content: [{ type: 'text', text: 'workspace secret' }], source: { kind: 'plugin', plugin: 'workspace' } },
{ surfaceOp: 'append' },
)

View File

@@ -18,9 +18,9 @@ When `timeZone` is omitted, the plugin resolves the Node process's system zone o
## Timing semantics
The plugin prepends an `agent/pre-step` listener. When an injection is due, it appends one `context/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing.
The plugin prepends an `agent/pre-step` listener. When an injection is due, it appends one injected `user/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing.
Positive-interval scheduling scans the raw durable session events for the latest `context/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently.
Positive-interval scheduling scans the raw durable session events for the latest `user/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently.
Step 1 measures from the latest preceding model-visible message, including the prompt that opened the turn. Later steps measure from the preceding time-context event in the same turn. Both baselines use durable session-event timestamps; backward wall-clock movement clamps elapsed time to zero. A missing first-step baseline, or a later step with no earlier same-turn reading because interval suppression skipped it, reports `unavailable`.

View File

@@ -64,7 +64,6 @@ function precedingMessageTime(agent: Agent): number | undefined {
case 'user/message':
case 'assistant/message':
case 'tool/result':
case 'context/message':
case 'steering/message':
return event.time
default:
@@ -79,7 +78,7 @@ function precedingMessageTime(agent: Agent): number | undefined {
function precedingStepContextTime(agent: Agent, turn: number): number | undefined {
for (const event of [...agent.session.events].reverse()) {
if (event.type === 'turn/start' && event.data.turn === turn) return undefined
if (event.type === 'context/message'
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === name) {
return event.time
@@ -91,7 +90,7 @@ function precedingStepContextTime(agent: Agent, turn: number): number | undefine
/** Find this plugin's latest durable injection, including a shadowed surface event. */
function latestInjectionTime(agent: Agent): number | undefined {
for (const event of [...agent.session.events].reverse()) {
if (event.type === 'context/message'
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === name) {
return event.time

View File

@@ -48,7 +48,7 @@ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFa
/** Validate one plugin-attributed time reading against its session position and timestamp. */
function validateReading(
history: readonly SessionEvent[],
event: SessionEvent<'context/message'>,
event: SessionEvent<'user/message'>,
fail: InvariantFailure,
): void {
const [block] = event.data.content
@@ -84,7 +84,7 @@ function validateReading(
/** Validate all package-owned readings already present in one session. */
function validateSession(session: Session, fail: InvariantFailure): void {
for (const [index, event] of session.events.entries()) {
if (event.type !== 'context/message'
if (event.type !== 'user/message'
|| event.data.source.kind !== 'plugin'
|| event.data.source.plugin !== SOURCE_NAME) continue
validateReading(session.events.slice(0, index), event, fail)
@@ -97,7 +97,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
if (event.type !== 'context/message'
if (event.type !== 'user/message'
|| event.data.source.kind !== 'plugin'
|| event.data.source.plugin !== SOURCE_NAME) return
validateReading(session.events, event, fail)

View File

@@ -17,7 +17,7 @@ async function setup(): Promise<Context> {
function event(text: string, time = SECOND + 456, content?: unknown[]): SessionEvent {
return {
type: 'context/message',
type: 'user/message',
seq: 0,
time,
data: {
@@ -56,7 +56,7 @@ function preparing(turn: number, step: number): Session {
}
function appendReading(session: Session, text: string): void {
session.append('context/message', {
session.append('user/message', {
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'time-context' },
}, { surfaceOp: 'append' })
@@ -162,7 +162,7 @@ describe('time-context invariants', () => {
it('ignores context messages owned by another package', async () => {
const ctx = await setup()
const other = event('unrelated') as SessionEvent<'context/message'>
const other = event('unrelated') as SessionEvent<'user/message'>
other.data.source = { kind: 'plugin', plugin: 'other' }
expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow()
other.data.source = { kind: 'user' }

View File

@@ -48,7 +48,8 @@ describe('time-context through a real headless cordis.yml', () => {
expect(stderr).not.toContain('UNHANDLED')
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2)
const contexts = events.filter(event => event.type === 'context/message')
const contexts = events.filter(
(event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin')
const starts = events.filter(event => event.type === 'step/start')
expect(contexts).toHaveLength(2)
expect(starts).toHaveLength(2)

View File

@@ -3,8 +3,8 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import AgentRegistry, { agentEvents, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -42,14 +42,17 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
session,
status: 'running',
ctx: new Context(),
send() {},
steer() {},
followup: () => AgentMessageId('stub'),
queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject(content, options) {
session.append('context/message', {
session.append('user/message', {
content,
source: options?.source ?? { kind: 'user' },
}, { surfaceOp: 'append' })
return AgentMessageId('stub')
},
send: () => AgentMessageId('stub'),
cancel() {},
whenIdle: () => Promise.resolve(),
}
@@ -66,7 +69,7 @@ function openMessageTurn(session: Session, turn: number): void {
function contextTexts(session: Session): string[] {
const texts: string[] = []
for (const event of session.events) {
if (event.type === 'context/message'
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'time-context') {
texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '')
@@ -151,8 +154,8 @@ describe('durable step context', () => {
+ 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.',
])
const event = session.events.at(-1)
expect(event?.type).toBe('context/message')
if (event?.type !== 'context/message') throw new Error('missing time context')
expect(event?.type).toBe('user/message')
if (event?.type !== 'user/message') throw new Error('missing time context')
expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' })
expect(event.surfaceOp).toBe('append')
})
@@ -230,10 +233,10 @@ describe('durable step context', () => {
const original = new Session(SessionId('seed-source'))
openMessageTurn(original, 1)
await fire(ctx, sessionAgent(original), 1, 1)
const user = original.events.find(event => event.type === 'user/message')
const reading = original.events.find(event => event.type === 'context/message')
const user = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'user')
const reading = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
if (user === undefined || reading === undefined) throw new Error('missing source surface events')
original.append('context/message', {
original.append('user/message', {
content: [{ type: 'text', text: 'compacted history' }],
source: { kind: 'plugin', plugin: 'compact-basic' },
}, {
@@ -292,7 +295,7 @@ describe('durable step context', () => {
openMessageTurn(session, 1)
let ordinarySawContext = false
ctx.on('agent/pre-step', (subject) => {
ordinarySawContext = subject.session.events.some(event => event.type === 'context/message')
ordinarySawContext = subject.session.events.some(event => event.type === 'user/message')
})
await fire(ctx, agent, 1, 1)
@@ -371,7 +374,7 @@ describe('real agent-loop request history', () => {
})
const agent = ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'start' }])
agent.followup([{ type: 'text', text: 'start' }])
await agent.whenIdle()
expect(laterSawReading).toBe(true)
@@ -397,11 +400,12 @@ describe('real agent-loop request history', () => {
}))
const agent = ctx.agentLoop.create(SessionId('loop'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'start' }])
agent.followup([{ type: 'text', text: 'start' }])
await agent.whenIdle()
expect(adapter.requests).toHaveLength(2)
const contexts = agent.session.events.filter(event => event.type === 'context/message')
const contexts = agent.session.events.filter(
(event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin')
const starts = agent.session.events.filter(event => event.type === 'step/start')
expect(contexts).toHaveLength(adapter.requests.length)
expect(starts).toHaveLength(adapter.requests.length)

View File

@@ -28,7 +28,7 @@ Instructions from: AGENTS.md
</system-reminder>
```
Newly reached scopes use a durable raw `context/message`:
Newly reached scopes use a durable injected `user/message` (plugin source):
```md
<system-reminder>
@@ -42,11 +42,11 @@ These instructions apply to work under `packages/app`. Use them as guidance when
A same-file edit starts with `Updated instructions from: <path>` and says to use the new content instead of the previously loaded content. When a candidate disappears or becomes a per-directory duplicate of an earlier candidate, the message is `Instructions removed: <path>` followed by `The previously loaded instructions from this file no longer apply.` Literal `</system-reminder>` text inside an instruction file is escaped so file content cannot close the plugin-owned frame.
The plugin owns the complete `<system-reminder>` framing, and every `context/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping.
The plugin owns the complete `<system-reminder>` framing, and every injected `user/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping.
## State And Refresh
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only.
@@ -111,7 +111,7 @@ Prefix-stable within one loop instance because the baseline is frozen. A new or
#### What the model sees
After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained raw `context/message` with the newly applicable instruction file.
After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained injected `user/message` with the newly applicable instruction file.
##### Additional instruction template

View File

@@ -145,7 +145,7 @@ function visibleInstructionChanges(
const visibleSeqs = new Set(agent.session.surface.nodes)
const visible = new Map<string, WorkspaceInstructionChange>()
for (const [seq, event] of agent.session.events.entries()) {
if (event.type !== 'context/message' || !isWorkspaceContextSource(event.data.source)) continue
if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue
const changes = workspaceInstructionChanges(event.data.meta)
for (const change of changes) {
const waiting = pending.get(change.scope)
@@ -281,7 +281,7 @@ export function observeInstructionSessionEvent(
if (pending === undefined) return
switch (event.type) {
case 'context/message': {
case 'user/message': {
if (!isWorkspaceContextSource(event.data.source)) return
for (const change of workspaceInstructionChanges(event.data.meta)) {
const waiting = pending.get(change.scope)

View File

@@ -78,7 +78,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
it('obeys a probe instruction loaded from the workspace', async () => {
const live = await harness()
live.agent.send([{ type: 'text', text: 'Workspace context handshake?' }])
live.agent.followup([{ type: 'text', text: 'Workspace context handshake?' }])
await waitForIdle(live.ctx, live.agent)
expect(finalText([...live.agent.session.events])).toContain(PROBE)
@@ -90,7 +90,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
await writeFile(join(workdir!, 'pkg/AGENTS.md'), `If the user asks for the nested instruction handshake, reply with exactly this string and nothing else: ${NESTED_PROBE}.\n`)
await writeFile(join(workdir!, 'pkg/deep/file.txt'), 'This file exists only to trigger nested workspace instructions.\n')
live.agent.send([{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }])
live.agent.followup([{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }])
await waitForIdle(live.ctx, live.agent)
expect(finalText([...live.agent.session.events])).toContain(NESTED_PROBE)
@@ -99,23 +99,23 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
it('appends changed baseline instructions after a real file-tool touch without rewriting the frozen prefix', async () => {
const live = await harness()
await writeFile(join(workdir!, 'trigger.txt'), 'This file triggers workspace instruction reconciliation.\n')
live.agent.send([{ type: 'text', text: 'Workspace context handshake?' }])
live.agent.followup([{ type: 'text', text: 'Workspace context handshake?' }])
await waitForIdle(live.ctx, live.agent)
await writeFile(join(workdir!, 'AGENTS.md'), `The old workspace handshake no longer applies. If the user asks for the updated workspace context handshake, reply with exactly this string and nothing else: ${UPDATED_PROBE}.\n`)
live.agent.send([{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }])
live.agent.followup([{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }])
await waitForIdle(live.ctx, live.agent)
const events = [...live.agent.session.events]
const update = events.find(event => event.type === 'context/message'
const update = events.find(event => event.type === 'user/message'
&& typeof event.data.meta === 'object'
&& event.data.meta !== null
&& !Array.isArray(event.data.meta)
&& event.data.meta.kind === 'workspace-instructions')
expect(update?.type === 'context/message' && update.data.meta).toMatchObject({
expect(update?.type === 'user/message' && update.data.meta).toMatchObject({
changes: [{ action: 'replace', scope: candidateScopeKey('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
const updateText = update?.type === 'context/message'
const updateText = update?.type === 'user/message'
? update.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
: ''
expect(updateText).toContain('Updated instructions from: AGENTS.md')

View File

@@ -7,7 +7,7 @@ import Loader from '@cordisjs/plugin-loader'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session'
import AgentRegistry, { type Agent, type HookContext } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { AgentMessageId, type Agent, type HookContext } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
@@ -177,15 +177,18 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
options: {},
session,
status: 'idle',
send() {},
steer() {},
followup: () => AgentMessageId('stub'),
queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject(content, options) {
session.append('context/message', {
session.append('user/message', {
content,
source: options?.source ?? { kind: 'user' },
...options?.meta !== undefined ? { meta: options.meta } : {},
}, { surfaceOp: 'append' })
return AgentMessageId('stub')
},
send: () => AgentMessageId('stub'),
cancel() {},
whenIdle: () => Promise.resolve(),
}
@@ -222,7 +225,7 @@ function workspaceChangeContext(scope: string, digest: string): HookContext {
function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: HookContext[] }): number | undefined {
let lastSeq: number | undefined
for (const context of result.additionalContexts ?? []) {
lastSeq = agent.session.append('context/message', {
lastSeq = agent.session.append('user/message', {
content: context.content,
source: context.source,
...context.meta !== undefined ? { meta: context.meta } : {},
@@ -976,7 +979,7 @@ describe('workspace context request injection', () => {
const second = await composeBaselinePrefix(ctx, agent)
expect(second).toEqual(first)
expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(0)
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0)
expect(derivedText(agent)).toContain('repo rule')
} finally {
await rm(root, { recursive: true, force: true })
@@ -1148,7 +1151,7 @@ describe('workspace context request injection', () => {
await composeBaselinePrefix(ctx, agent)
expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(0)
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0)
expect(derivedText(agent)).not.toContain('workspace-context:')
} finally {
await rm(root, { recursive: true, force: true })
@@ -1714,14 +1717,14 @@ describe('dynamic nested workspace context injection', () => {
},
}))
agent.send([{ type: 'text', text: 'read and abort' }])
agent.followup([{ type: 'text', text: 'read and abort' }])
await agent.whenIdle()
expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1)
agent.send([{ type: 'text', text: 'retry the read' }])
agent.followup([{ type: 'text', text: 'retry the read' }])
await agent.whenIdle()
const contexts = agent.session.events.filter(event => event.type === 'context/message')
const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
// The aborted batch drained its accepted context before step close, so the
// retry sees durable history without producing a duplicate instruction.
expect(contexts).toHaveLength(1)
@@ -2496,10 +2499,7 @@ describe('dynamic nested workspace context injection', () => {
agent,
})
appendAdditionalContexts(agent, first)
const resumed = {
...agent,
session: new Session(agent.session.id, [...agent.session.events], agent.session.header),
}
const resumed = stubAgent(root, [...agent.session.events])
const afterResume = await ctx.tools.execute({
signal: testToolSignal,
@@ -2537,11 +2537,11 @@ describe('dynamic nested workspace context injection', () => {
await composeBaselinePrefix(ctx, resumed)
const update = resumed.session.events.findLast(event => event.type === 'context/message')
expect(update?.type === 'context/message' && update.data.meta).toMatchObject({
const update = resumed.session.events.findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user')
expect(update?.type === 'user/message' && update.data.meta).toMatchObject({
changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(update?.type === 'context/message' && blocksText(update.data.content)).toContain('new nested rule after resume')
expect(update?.type === 'user/message' && blocksText(update.data.content)).toContain('new nested rule after resume')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -2687,7 +2687,7 @@ describe('dynamic nested workspace context injection', () => {
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
agent.session.append('context/message', {
agent.session.append('user/message', {
content: [
{ type: 'reasoning', text: 'Additional instructions from: pkg/AGENTS.md' },
{ type: 'text', text: 'Updated instructions from: pkg/AGENTS.md' },
@@ -2704,12 +2704,12 @@ describe('dynamic nested workspace context injection', () => {
],
},
}, { surfaceOp: 'append' })
agent.session.append('context/message', {
agent.session.append('user/message', {
content: [{ type: 'text', text: 'stale metadata version' }],
source: { kind: 'plugin', plugin: 'workspace-context' },
meta: { kind: 'workspace-instructions', version: 0, changes: [] },
}, { surfaceOp: 'append' })
agent.session.append('context/message', {
agent.session.append('user/message', {
content: [{ type: 'text', text: 'foreign plugin context' }],
source: { kind: 'plugin', plugin: 'other' },
meta: {
@@ -3237,14 +3237,14 @@ describe('workspace context pending state', () => {
path: join('pkg', 'AGENTS.md'), version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one',
}]]))
const unrelated = agent.session.append('context/message', {
const unrelated = agent.session.append('user/message', {
content: [], source: { kind: 'plugin', plugin: 'other' },
}, { surfaceOp: 'append' })
observeInstructionSessionEvent(agent.session, unrelated, pending, versions)
expect(pending.get(agent.session)?.has('pkg')).toBe(true)
const otherContext = workspaceChangeContext('other', 'other')
const otherWorkspaceEvent = agent.session.append('context/message', {
const otherWorkspaceEvent = agent.session.append('user/message', {
content: otherContext.content,
source: otherContext.source,
...otherContext.meta !== undefined ? { meta: otherContext.meta } : {},
@@ -3253,7 +3253,7 @@ describe('workspace context pending state', () => {
expect(pending.get(agent.session)?.has('pkg')).toBe(true)
const context = workspaceChangeContext('pkg', 'one')
const confirmed = agent.session.append('context/message', {
const confirmed = agent.session.append('user/message', {
content: context.content,
source: context.source,
...context.meta !== undefined ? { meta: context.meta } : {},

View File

@@ -188,6 +188,32 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'clientModuleHost',
summary: 'The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap.',
methods: [
{
signature: 'graph(): WebBootGraph',
jsDoc: '/**\n * Current composed entry graph (stable object between changes).\n * @returns the graph served as `window.__DSH_BOOT__`.\n */',
},
{
signature: 'clientPath(id: string): string | undefined',
jsDoc: '/**\n * Absolute path of an entry\'s client bundle.\n * @param id - entry id (package name).\n * @returns the path, or undefined for an unknown id.\n */',
},
{
signature: 'rebuilt(id: string): string | undefined',
jsDoc: '/**\n * Re-hash one bundle (the HMR watch\'s registration hook — the only entry\n * point through which bundle content changes reach the graph).\n * @param id - entry id (package name).\n * @returns the new rev, or undefined for an unknown id.\n */',
},
{
signature: 'onRebuilt(listener: (id: string, rev: string) => void): () => void',
jsDoc: '/**\n * Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.\n * @param listener - receives the entry id and its new bundle rev.\n * @returns the unsubscriber.\n */',
},
{
signature: 'onGraphChanged(listener: () => void): () => void',
jsDoc: '/**\n * Fires after any flush that recomposed the graph (row added/removed, or a\n * rebuilt rev change). Pull model: listeners re-read {@link graph}.\n * @param listener - notified with no payload.\n * @returns the unsubscriber.\n */',
},
],
},
{
key: 'codeRuntime',
summary: 'Registers one `ctx.codeRuntime` implementation.',
@@ -314,6 +340,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'httpServer',
summary: 'The web-shape HTTP carrier service.',
methods: [
{
signature: 'register(route: WebRoute): () => void',
jsDoc: '/**\n * Register a named route. Duplicate (kind, path) throws — route patterns are\n * a composition-level contract, so a collision is a misconfiguration.\n * @param route - kind, path, and the owning handler.\n * @returns the disposer removing the route.\n */',
},
{
signature: 'tapIndex(transform: (html: string) => string): () => void',
jsDoc: '/**\n * Register an index.html transform, applied to every index response in\n * registration order.\n * @param transform - pure html-to-html function.\n * @returns the disposer removing the transform.\n */',
},
],
},
{
key: 'invariants',
summary: 'Package-owned invariant registry with global and regex-based selection.',
@@ -642,6 +682,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'storage',
summary: 'The storage hub service.',
methods: [
{
signature: 'mount<K extends keyof StorageForms>(form: K, facility: StorageForms[K]): () => void',
jsDoc: '/**\n * Mount a data-form facility on the hub. Mounting is an effect: the\n * returned disposer unmounts the form.\n * @param form - Form key declared in {@link StorageForms}.\n * @param facility - The facility instance to expose.\n * @returns the disposer that unmounts the form.\n */',
},
{
signature: 'form<K extends keyof StorageForms>(form: K): StorageForms[K]',
jsDoc: '/**\n * Resolve a mounted data form.\n * @param form - Form key declared in {@link StorageForms}.\n * @returns the mounted facility.\n */',
},
],
},
{
key: 'subagents',
summary: 'Named provider registry and capability-checked start surface.',
@@ -846,6 +900,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'workspace',
summary: 'The workspace registry service.',
methods: [
{
signature: 'async create(path: string, title?: string): Promise<Workspace>',
jsDoc: '/**\n * Create a workspace over an existing directory. The path is canonicalized\n * through `fs.realpath` first — a nonexistent path rejects with the\n * original `ENOENT`, a path resolving to anything but a directory rejects,\n * and a canonical path already owned by another workspace (including a\n * symlink resolving to it) rejects.\n * @param path - Directory the workspace points at; canonicalized before storing.\n * @param title - Display title; defaults to `basename` of the canonical path.\n * @returns the created workspace after durability.\n */',
},
{
signature: 'get(id: WorkspaceId): Workspace | undefined',
jsDoc: '/**\n * Look up a workspace by id.\n * @param id - The workspace id.\n * @returns the workspace, or `undefined` when unknown.\n */',
},
{
signature: 'list(): Workspace[]',
jsDoc: '/**\n * Snapshot of all workspaces, in load-then-creation order.\n * @returns a fresh array of the cached entities.\n */',
},
{
signature: 'async resolveByPath(path: string): Promise<Workspace | undefined>',
jsDoc: '/**\n * Resolve a workspace by directory path, through the same `fs.realpath`\n * canon as {@link create} (hence async). A path that does not exist rejects\n * with the original error — a missing directory has no canonical form to\n * compare (a workspace whose recorded directory vanished is only reachable\n * by id; see `Workspace.status`).\n * @param path - Directory path in any spelling (symlinks, `..`, trailing slash).\n * @returns the owning workspace, or `undefined` when none matches.\n */',
},
],
},
]
/** Every harness event, sorted by name. */
@@ -885,6 +961,27 @@ export const EVENT_API: readonly EventApiEntry[] = [
jsDoc: '/**\n * A step or turn errored. The loop reports a failure here (plus the logger)\n * even when the error has no in-turn position for a session `error` event.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'A step or turn errored.',
},
{
name: 'agent/inbox/dequeue',
mode: 'emit',
signature: '\'agent/inbox/dequeue\'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void',
jsDoc: '/**\n * The driver claimed one item out of the inbox: a queued item at a turn\n * boundary, or steering drained between steps. Fires after the item leaves\n * its FIFO and before it becomes a durable message.\n * @param agent - the agent whose inbox item was claimed.\n * @param message - the claimed message (matching the `id` from its `agent/inbox/enqueue`).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'The driver claimed one item out of the inbox: a queued item at a turn boundary, or steering drained between steps.',
},
{
name: 'agent/inbox/discard',
mode: 'emit',
signature: '\'agent/inbox/discard\'(this: Scoped<Agent>, agent: Agent, messages: AgentMessage[]): void',
jsDoc: '/**\n * Pending inbox items were dropped without delivering them, so every\n * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR\n * `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after\n * `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop`\n * dropping pending steering (in-turn and on the post-turn late-steering\n * drain); and disposal of any still-pending items (before\n * `agent/status(\'disposed\')`). Fires once per drop with every dropped item.\n * @param agent - the agent whose inbox items were dropped.\n * @param messages - the discarded messages in FIFO order (queued then steering); never empty.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'Pending inbox items were dropped without delivering them, so every enqueued id receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`.',
},
{
name: 'agent/inbox/enqueue',
mode: 'emit',
signature: '\'agent/inbox/enqueue\'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void',
jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection through\n * `agent.inject()` or equivalent `send()` routing bypasses the FIFOs\n * and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).',
},
{
name: 'agent/post-step',
mode: 'serial',
@@ -906,13 +1003,6 @@ export const EVENT_API: readonly EventApiEntry[] = [
jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default. A listener wrapping a\n * downstream `allow` must preserve its `content` and `additionalContexts`\n * unless it intentionally replaces them. The signal controls only this turn;\n * listeners may cooperate with it but must not retain it to control another\n * turn. Steering messages do not dispatch this event; they join an open turn\n * at a steering checkpoint.\n * @param agent - the agent whose turn claimed the message.\n * @param content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
summary: 'Allow, rewrite, or block one claimed prompt before it becomes a user message.',
},
{
name: 'agent/queued',
mode: 'emit',
signature: '\'agent/queued\'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void',
jsDoc: '/**\n * Detached, frozen content entered the agent\'s inbox. Source defaults have\n * already been applied, so these are the exact values retained for the log.\n * @param agent - the agent whose inbox received the message.\n * @param content - the accepted content blocks retained by the inbox.\n * @param info - the accepted source, contexts, and whether it entered as steering.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'Detached, frozen content entered the agent\'s inbox.',
},
{
name: 'agent/request',
mode: 'waterfall',
@@ -945,7 +1035,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/status',
mode: 'emit',
signature: '\'agent/status\'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void',
jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does\n * not enter `running` synchronously; drive lifecycle from this event.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking\n * delivery does not enter `running` synchronously; drive lifecycle from this event.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'Agent status changed (`idle` ⇄ `running`, or → `disposed`).',
},
{
@@ -983,6 +1073,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
jsDoc: '/**\n * A command was registered or unregistered. This is an unfiltered registry\n * notification because a global or scoped change may affect any UI view.\n * Observer failures are contained and cannot veto the registry mutation.\n * @mode emit\n */',
summary: 'A command was registered or unregistered.',
},
{
name: 'domain/changed',
mode: 'emit',
signature: '\'domain/changed\'(change: DomainChanged): void',
jsDoc: '/**\n * A domain record or the global singleton changed, emitted once per write\n * strictly after the backend acknowledged durability. Events of one\n * domain arrive in its write-chain order.\n * @param change - domain, table (`\'\'` for global), key (`\'\'` for global),\n * operation discriminant, and on `put` the new snapshot.\n * @mode emit\n */',
summary: 'A domain record or the global singleton changed, emitted once per write strictly after the backend acknowledged durability.',
},
{
name: 'fs/edit-intent',
mode: 'waterfall',
@@ -1171,7 +1268,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
export const TYPE_API: readonly TypeApiEntry[] = [
{
name: 'Agent',
declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise<void>;\n}',
declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n}',
},
{
name: 'AgentCancelCause',
@@ -1185,6 +1282,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'AgentHandle',
declaration: 'export interface AgentHandle {\n agent: Agent;\n dispose(): Promise<void>;\n}',
},
{
name: 'AgentMessageId',
declaration: 'export type AgentMessageId = Branded<\'AgentMessageId\'>;',
},
{
name: 'AgentOptions',
declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n}',
@@ -1285,6 +1386,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'CallId',
declaration: 'export type CallId = Branded<\'CallId\'>;',
},
{
name: 'CancelOptions',
declaration: 'export interface CancelOptions {\n keepInbox?: boolean;\n}',
},
{
name: 'CodeBindingErrorClass',
declaration: 'export interface CodeBindingErrorClass {\n name: string;\n memberNameProperty: string;\n}',
@@ -1503,7 +1608,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'InjectOptions',
declaration: 'export interface InjectOptions extends Omit<SendOptions, \'contexts\'> {\n meta?: JsonValue;\n}',
declaration: 'export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n}',
},
{
name: 'InvariantFailure',
@@ -1529,6 +1634,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'JsonValue',
declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};',
},
{
name: 'LlmAdapter',
declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n listModels(_provider: string): Promise<readonly LlmModelInfo[]>;\n resolveModelContext(_provider: string, _model: string): Promise<LlmModelContext | undefined>;\n abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
},
{
name: 'LlmCallConfig',
declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}',
@@ -1591,7 +1700,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'PromptMessageData',
declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n}',
declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n}',
},
{
name: 'PromptMessageEnvelope',
@@ -1693,6 +1802,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ReasoningBlock',
declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}',
},
{
name: 'RequestHeaderReason',
declaration: 'export type RequestHeaderReason = \'initial\' | \'resume\' | \'change\';',
},
{
name: 'ResolvedAgentInput',
declaration: 'export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n} & ({\n target: \'next-turn\';\n wakeup: boolean;\n contexts: HookContext[];\n} | {\n target: \'next-step\';\n wakeup: true;\n contexts: HookContext[];\n} | {\n target: \'next-step\';\n wakeup: false;\n contexts: [\n ];\n});',
},
{
name: 'ResumeAgentOptions',
declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
@@ -1727,7 +1844,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SendOptions',
declaration: 'export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n}',
declaration: 'export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n}',
},
{
name: 'Session',
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
},
{
name: 'SessionAvailability',
@@ -1739,7 +1860,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionEventMap',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': PromptMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': PromptMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: R /* …truncated — full shape in source */',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': PromptMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': PromptMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n}',
},
{
name: 'SessionEventMetadataFilter',
@@ -1865,6 +1986,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionSearchRequest',
declaration: 'export interface SessionSearchRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: SessionSearchCursor;\n}',
},
{
name: 'SessionSurface',
declaration: 'export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n}',
},
{
name: 'SessionSurfaceSnapshot',
declaration: 'export interface SessionSurfaceSnapshot {\n session: SessionHeader;\n capturedThroughSeq: number | null;\n events: SurfaceEvent[];\n}',
@@ -1957,6 +2082,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SpillSource',
declaration: 'export interface SpillSource {\n toolName: string;\n callId: CallId;\n label: string;\n}',
},
{
name: 'StorageForms',
declaration: 'export interface StorageForms {\n}',
},
{
name: 'StreamChunk',
declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n replayState?: unknown;\n};',
@@ -1995,7 +2124,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SurfaceEventType',
declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'context/message\' | \'steering/message\';',
declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'steering/message\';',
},
{
name: 'SurfaceIntent',
declaration: 'export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n}',
},
{
name: 'SurfaceOp',
@@ -2245,6 +2378,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'WebFetchResult',
declaration: 'export interface WebFetchResult {\n readonly url: string;\n readonly statusCode: number;\n readonly body: WebFetchBody;\n readonly truncated: boolean;\n}',
},
{
name: 'WebRoute',
declaration: 'export interface WebRoute {\n kind: WebRouteKind;\n path: string;\n handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;\n}',
},
{
name: 'WebRouteKind',
declaration: 'export type WebRouteKind = \'exact\' | \'prefix\';',
},
{
name: 'WebSearchProvider',
declaration: 'export interface WebSearchProvider {\n readonly id: string;\n available(): boolean;\n search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;\n}',
@@ -2289,6 +2430,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'WorkflowStopReason',
declaration: 'export type WorkflowStopReason = \'completed\' | \'cancelled\' | \'error\';',
},
{
name: 'Workspace',
declaration: 'export interface Workspace {\n readonly id: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly sessionIds: readonly SessionId[];\n setTitle(title: string): Promise<void>;\n attachSession(sessionId: SessionId): Promise<void>;\n detachSession(sessionId: SessionId): Promise<void>;\n status(): Promise<\'ok\' | \'missing-dir\'>;\n}',
},
]
/** The inherited `ctx` surface (cordis core + loader/hmr/timer), in curated order. */

View File

@@ -61,6 +61,8 @@ describe('cordis_inspect', () => {
// generated TYPE_API — a consumer can see field types, not just names).
expect(report).toContain('type shapes (referenced by the signatures above')
expect(report).toContain('export interface ToolExecution')
expect(report).toContain('export class Session')
expect(report).toContain('export interface SessionSurface')
// A type only reachable through a NOT-live service (e.g. bash) is scoped out.
expect(report).not.toContain('export interface BashRunResult')
// The inherited ctx surface closes the section.

View File

@@ -47,7 +47,7 @@ describe('cordis tools through the agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-cordis'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }])
agent.followup([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }])
await waitForIdle(ctx, agent)
const log = agent.session.events

View File

@@ -50,9 +50,9 @@ Configured agents start automatically. A model call requires both `provider` and
### Internal concrete driver
The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
The concrete `ReactLoopAgent` adapter, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
Each concrete `send()` materializes content, resolved source, and attached contexts once as a detached, deeply frozen lossless-JSON FIFO item. If claimed, it is the sole ordinary message in its turn; its contexts are the prompt waterfall's default additional contexts and therefore materialize only after admission. Absent or `separate` placement appends an independent `context/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. Open-turn `inject()` uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls; successful batches place it after all results, and interrupted batches drain it before turn close. Malformed data throws before enqueue or append.
`ReactLoopAgent.send()` implements the public fully resolved acceptance path. The `followup()`/`queue()`/`steer()`/`inject()` helpers resolve every optional field before delegating to it; direct callers provide mandatory content, source, contexts, metadata, target, and wakeup facts through `ResolvedAgentInput`. `followup()` and `queue()` join the ordinary FIFO, respectively waking or leaving an idle driver parked. If claimed, an ordinary item is the sole message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` or equivalent `send()` routing enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `inject()` and non-waking next-step acceptance require an empty context tuple, bypass both FIFOs, and append durable context directly: an open-turn injection defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append.
### Loop lifecycle (`loop.ts`)

View File

@@ -6,15 +6,25 @@
* @module dsh-agent-loop/agent
*/
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { AgentCancelCause, AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent'
import type {
Agent,
AgentCancelCause,
AgentOptions,
AgentStatus,
CancelOptions,
HookContext,
InjectOptions,
ResolvedAgentInput,
SendOptions,
} from '@deepseek-ai/dsh-agent'
import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session'
import { DISPOSED_INTERRUPT_REASON, TurnCancellation } from './cancellation.ts'
import { Inbox, type InboxMessage } from './inbox.ts'
import { Inbox, agentMessage, type InboxMessage } from './inbox.ts'
import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
/** Sessions already claimed by a concrete driver construction. */
@@ -190,19 +200,17 @@ export class ReactLoopAgent implements Agent {
for (const resolve of waiters) resolve()
}
private resolveSource(options?: SendOptions): MessageSource {
return options?.source ?? { kind: 'user' }
}
/**
* Accept one public message payload as a detached record. Lossless-JSON
* materialization reads every nested field once; deep freeze prevents later
* caller mutation before an inbox or deferred-injection queue drains it.
*/
private acceptMessage(content: ContentBlock[], options?: SendOptions): InboxMessage {
const source = this.resolveSource(options)
const contexts = options?.contexts ?? []
const accepted = snapshotJsonValue({ content, source, contexts })
private snapshotMessage(id: AgentMessageId, input: ResolvedAgentInput): InboxMessage {
const { content, source, contexts, wakeup, meta } = input
const accepted = snapshotJsonValue({
id, content, source, contexts, wakeup,
...meta !== undefined ? { meta } : {},
})
if (accepted === undefined) {
throw new TypeError('agent message content, source, and contexts must be losslessly JSON-serializable')
}
@@ -223,33 +231,81 @@ export class ReactLoopAgent implements Agent {
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
}
send(content: ContentBlock[], options?: SendOptions): void {
/** Accept one fully resolved agent input through the concrete driver's routing matrix. */
send(input: ResolvedAgentInput): AgentMessageId {
this.assertNotDisposed()
const accepted = this.acceptMessage(content, options)
this.#inbox.enqueue(accepted)
const info = { source: accepted.source, contexts: accepted.contexts, steering: false } as const
agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
const id = AgentMessageId(randomUUID())
const { target, wakeup } = input
// next-step/no-wakeup is injection: durable context without running the model.
if (target === 'next-step' && !wakeup) { this.injectContext(input); return id }
// next-step/wakeup is steering into the running turn; idle falls back to a
// waking ordinary turn (there is no active turn to attach to).
const steering = target === 'next-step' && this._status === 'running'
const accepted = this.snapshotMessage(id, input)
if (steering) {
this.#inbox.steer(accepted)
} else {
this.#inbox.enqueue(accepted, wakeup)
}
agentEvents(this.loopCtx, this).emit('agent/inbox/enqueue', agentMessage(accepted, steering))
return id
}
steer(content: ContentBlock[], options?: SendOptions): void {
this.assertNotDisposed()
if (this._status !== 'running') { this.send(content, options); return }
const accepted = this.acceptMessage(content, options)
this.#inbox.steer(accepted)
const info = { source: accepted.source, contexts: accepted.contexts, steering: true } as const
agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
followup(content: ContentBlock[], options?: SendOptions): AgentMessageId {
return this.send({
content,
target: 'next-turn',
wakeup: true,
source: options?.source ?? { kind: 'user' },
contexts: options?.contexts ?? [],
meta: options?.meta,
})
}
inject(content: ContentBlock[], options?: InjectOptions): void {
this.assertNotDisposed()
const source = this.resolveSource(options)
const context = {
queue(content: ContentBlock[], options?: SendOptions): AgentMessageId {
return this.send({
content,
target: 'next-turn',
wakeup: false,
source: options?.source ?? { kind: 'user' },
contexts: options?.contexts ?? [],
meta: options?.meta,
})
}
steer(content: ContentBlock[], options?: SendOptions): AgentMessageId {
return this.send({
content,
target: 'next-step',
wakeup: true,
source: options?.source ?? { kind: 'user' },
contexts: options?.contexts ?? [],
meta: options?.meta,
})
}
inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId {
return this.send({
content,
target: 'next-step',
wakeup: false,
source: options?.source ?? { kind: 'plugin', plugin: '' },
contexts: [],
meta: options?.meta,
})
}
/** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */
private injectContext(input: Extract<ResolvedAgentInput, { target: 'next-step'; wakeup: false }>): void {
const { content, source, meta } = input
// Detach and validate the payload before any append, so malformed input
// cannot open a one-shot turn or otherwise mutate the session.
const accepted = this.acceptContext({
content,
source,
...options?.meta !== undefined ? { meta: options.meta } : {},
}
...meta !== undefined ? { meta } : {},
})
if (isTurnOpen(this.session)) {
const accepted = this.acceptContext(context)
// Provider protocols require every assistant tool-call batch to be
// followed only by its tool results. Historical interrupted batches do
// not own new context; only the currently executing batch may defer it.
@@ -257,27 +313,29 @@ export class ReactLoopAgent implements Agent {
this.deferredInjections.push(accepted)
return
}
this.session.append('context/message', accepted, { surfaceOp: 'append' })
this.session.append('user/message', accepted, { surfaceOp: 'append' })
return
}
// No turn open: wrap the injection in a one-shot turn so every event stays
// turn-enclosed (the durability/replay boundary is the turn).
// turn-enclosed (the durability/replay boundary is the turn). The payload is
// validated above, but `Session.append` can still reject a turn/start
// pre-commit (append re-entrancy from a session/event listener, or an
// internal-dispatch veto), so the finally owes a turn/end only when
// turn/start actually committed.
const turn = lastTurnNumber(this.session) + 1
// Once turn/start enters the log, a turn/end is owed even if the message
// append fails acceptance or pre-commit validation. The finally re-checks
// the log and closes only a turn that actually opened; post-commit observers
// are contained by Session and cannot create a false append failure.
try {
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
this.session.append('context/message', context, { surfaceOp: 'append' })
this.session.append('user/message', accepted, { surfaceOp: 'append' })
} finally {
// Close the turn if turn/start made it into the log. A pre-commit veto
// must escape rather than being mistaken for a committed turn/end.
if (isTurnOpen(this.session)) {
this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
// Decide the durability checkpoint from the log: an accepted one-shot
// turn must be flushed even when its message append was the failing step.
// Checkpoint only an accepted one-shot turn: a turn/start rejected
// pre-commit recorded nothing, so it owes no flush (and a spurious flush
// would emit a phantom-turn agent/error). The payload is validated up
// front, so a committed turn/start is always followed by its user/message.
const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
// Keep inject() synchronous: report checkpoint failures live instead of
// rejecting the caller, and track the task so disposal still drains it.
@@ -301,7 +359,7 @@ export class ReactLoopAgent implements Agent {
private drainDeferredInjections(): void {
const pending = this.deferredInjections.splice(0)
for (const accepted of pending) {
this.session.append('context/message', accepted, { surfaceOp: 'append' })
this.session.append('user/message', accepted, { surfaceOp: 'append' })
}
}
@@ -325,10 +383,14 @@ export class ReactLoopAgent implements Agent {
}
}
cancel(cause?: AgentCancelCause): void {
cancel(cause?: AgentCancelCause, options?: CancelOptions): void {
const resolvedCause = cause ?? { kind: 'user' }
const keepInbox = options?.keepInbox ?? false
const cancellation = this.turnCancellation
const preRun = cancellation === undefined && (this.#inbox.hasQueued || this.#inbox.hasSteering)
// keepInbox preserves pending work, so un-started items must not arm the
// pre-run cancel path that would otherwise drop the next queued turn.
const preRun = !keepInbox && cancellation === undefined
&& (this.#inbox.hasQueued || this.#inbox.hasSteering)
if (cancellation !== undefined || preRun) {
if (preRun) this.preRunCancelled = true
// Coordination consumers must update their own state before this call
@@ -336,9 +398,24 @@ export class ReactLoopAgent implements Agent {
// contained by the fused dispatcher and cannot veto cancellation.
agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedCause)
}
// Clear work already present before abort observers run. A replacement
// synchronously enqueued by an observer belongs to the next turn.
this.#inbox.clear()
if (!keepInbox) {
// Snapshot before clearing so the discard notification carries the exact
// dropped items; a replacement synchronously enqueued by an
// `agent/cancel-requested` observer belongs to the next turn, not here.
const discarded = this.#inbox.pending()
// Clear work already present before abort observers run.
this.#inbox.clear()
if (discarded.length > 0) {
const items = discarded.map(({ message, steering }) => agentMessage(message, steering))
agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items)
}
// No idle-waiter settle here: a `whenIdle` waiter exists only while the
// agent is `running` or a waking item is queued, and neither is left
// quiescent by clearing the inbox — a lone quiet item takes `whenIdle`'s
// fast path (no waiter), a waking item keeps the woken driver running,
// and a running agent owns its own idle transition (including the
// post-turn flush window).
}
cancellation?.request(resolvedCause)
}
@@ -349,7 +426,9 @@ export class ReactLoopAgent implements Agent {
*/
whenIdle(): Promise<void> {
if (this._status === 'disposed') return this.done
if (this._status !== 'running' && !this.#inbox.hasQueued) return Promise.resolve()
// A lone quiet (`wakeup:false`) queued item leaves the agent quiescent — the
// driver stays parked — so gate on hasWakingQueued, not hasQueued.
if (this._status !== 'running' && !this.#inbox.hasWakingQueued) return Promise.resolve()
// Agent-owned waiters survive concurrent fiber disposal.
return new Promise<void>((resolve) => {
this.idleWaiters.push(() => {
@@ -407,8 +486,21 @@ export class ReactLoopAgent implements Agent {
*/
private [stopDriver](): Promise<void> | void {
if (this._status !== 'disposed') {
// Snapshot any still-pending inbox items, then CLEAR and mark disposed
// BEFORE emitting the discard — mirroring cancel()'s snapshot→clear→emit
// order so a re-entrant followup()/cancel() from a discard listener throws
// `disposed` (or finds an empty inbox) instead of leaking or double-
// discarding an id. `followup()` emits enqueue unconditionally, so the discard
// is unconditional too (even on an unpublished rollback) to keep every
// enqueued id matched.
const discarded = this.#inbox.pending()
this.#inbox.clear()
this._status = 'disposed'
this.resolveDisposed()
if (discarded.length > 0) {
const items = discarded.map(({ message, steering }) => agentMessage(message, steering))
agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items)
}
// Release whenIdle waiters BEFORE the (guarded) event emit — they are
// internal state that must settle even if a listener throws below. Each
// waiter chains `done`, so it resolves only once the loop actually exits.

View File

@@ -1,54 +1,91 @@
/**
* Per-agent message inbox: queued and steering FIFOs. Purely an in-memory
* mechanism of the loop driver — the public surface is `Agent.send()` and
* `Agent.steer()`.
* mechanism of the loop driver — callers use `Agent`'s intent-named delivery
* methods instead.
*
* @module dsh-agent-loop/inbox
*/
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { HookContext } from '@deepseek-ai/dsh-agent'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import type { AgentMessage, AgentMessageId, HookContext } from '@deepseek-ai/dsh-agent'
/** One message waiting in an agent's inbox. */
/** One message waiting in an agent's inbox; `id` is the value its accepting delivery method returned. */
export interface InboxMessage {
id: AgentMessageId
content: ContentBlock[]
source: MessageSource
contexts: HookContext[]
/** Whether the item is marked to wake the driver or force a continuation. */
wakeup: boolean
/** Opaque durable JSON state retained on the durable message but hidden from the model. */
meta?: JsonValue
}
/**
* Build the `agent/inbox/*` event payload for one inbox item.
* @param message - the accepted inbox record.
* @param steering - whether the item is in the steering FIFO (`next-step`).
* @returns the live-event message for enqueue/dequeue/discard.
*/
export function agentMessage(message: InboxMessage, steering: boolean): AgentMessage {
// Frozen: the fused emitter passes this exact object to every listener in
// turn, so one listener must not be able to mutate a field (`id`, `steering`,
// `content`, …) a later listener then observes. `message` is already a frozen
// inbox record, so its nested fields need no re-clone.
return Object.freeze({
id: message.id, content: message.content, source: message.source,
contexts: message.contexts, steering, wakeup: message.wakeup,
})
}
/**
* Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO
* (drained between steps of a running turn). Purely an in-memory mechanism of
* the loop — the public surface is `Agent.send()` / `Agent.steer()`.
* the loop — the public surface is `Agent`'s intent-named delivery methods.
*/
export class Inbox {
private queuedMessages: InboxMessage[] = []
private steeringMessages: InboxMessage[] = []
private wakeup: (() => void) | undefined
/** True while queued messages are pending — read by the idle wait's fast path and the loop's turn-start checks. */
/** True while any queued message is pending — read by cancellation's discard snapshot and the turn-start dequeue guard. */
get hasQueued(): boolean {
return this.queuedMessages.length > 0
}
/**
* True while a queued message wants to wake the driver — the "should the loop
* run" signal read by the idle wait's fast path, the loop's idle-publish
* check, and `whenIdle`. A `wakeup:false` (quiet) item alone leaves this
* false, so the driver stays parked until a waking follow-up (or a waking item
* ahead of it in FIFO order) drives the loop; the quiet item then rides along.
*/
get hasWakingQueued(): boolean {
return this.queuedMessages.some(message => message.wakeup)
}
/** True while steering messages are pending — read by cancellation and the loop's stop-override check. */
get hasSteering(): boolean {
return this.steeringMessages.length > 0
}
/**
* Add a message to the queued FIFO and wake a parked {@link waitForQueued}.
* Add a message to the queued FIFO, waking a parked {@link waitForQueued}
* unless the item opted out. A non-waking item still runs once any woken
* item or later wakeup drives the parked loop.
* @param message - the message to queue for the next turn start.
* @param wake - whether to wake a parked idle wait (default true).
*/
enqueue(message: InboxMessage): void {
enqueue(message: InboxMessage, wake = true): void {
this.queuedMessages.push(message)
this.wakeup?.()
if (wake) this.wakeup?.()
}
/**
* Add a message to the steering FIFO. Deliberately no wakeup: steering is
* drained between steps of a running turn, never by the idle wait —
* `Agent.steer()` on an idle agent falls back to `send()` instead.
* `Agent.steer()` on an idle agent falls back to a waking ordinary turn instead.
* @param message - the message to inject between steps of the running turn.
*/
steer(message: InboxMessage): void {
@@ -71,6 +108,18 @@ export class Inbox {
return this.steeringMessages.splice(0)
}
/**
* Snapshot the pending items (queued then steering, FIFO order) without
* removing them — the discard notification's payload source.
* @returns the pending items paired with whether each is steering.
*/
pending(): { message: InboxMessage; steering: boolean }[] {
return [
...this.queuedMessages.map(message => ({ message, steering: false })),
...this.steeringMessages.map(message => ({ message, steering: true })),
]
}
/**
* Discard all pending messages (queued + steering) without delivering them —
* used by `cancel()`, which drops un-started work rather than draining it into
@@ -88,7 +137,7 @@ export class Inbox {
* loop can exit).
*/
waitForQueued(cancel: Promise<void>): Promise<void> {
if (this.hasQueued) return Promise.resolve()
if (this.hasWakingQueued) return Promise.resolve()
const { promise, resolve } = Promise.withResolvers<void>()
this.wakeup = resolve
void cancel.then(resolve)

View File

@@ -5,11 +5,12 @@
* @module dsh-agent-loop/loop
*/
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm'
import { isDeepStrictEqual } from 'node:util'
import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent'
import { agentEvents, agentInterruptReasonOf, assembleContextFor, AgentMessageId } from '@deepseek-ai/dsh-agent'
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
import { canonicalHeader } from '@deepseek-ai/dsh-session'
import type { PromptMessageData, Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
@@ -19,7 +20,7 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import { executeToolCalls } from './tool-calls.ts'
import type { Inbox } from './inbox.ts'
import { agentMessage, type Inbox, type InboxMessage } from './inbox.ts'
import type { TurnCancellation } from './cancellation.ts'
/** Normalize thrown values while preserving an existing error code. */
@@ -201,9 +202,11 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
while (!handle.isDisposed()) {
// An idle listener can enqueue and cancel replacement work before the next
// wait is installed. Consume that empty marker before parking the driver.
// A quiet (`wakeup:false`) item alone must not un-park the loop, so gate on
// hasWakingQueued, not hasQueued.
if (handle.isPreRunCancelled()) {
handle.clearPreRunCancel()
if (!handle.inbox.hasQueued) {
if (!handle.inbox.hasWakingQueued) {
handle.settleIdle()
handle.setStatus('idle')
continue
@@ -217,7 +220,7 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
// a replacement prompt still runs before the eventual idle transition.
if (handle.isPreRunCancelled()) {
handle.clearPreRunCancel()
if (!handle.inbox.hasQueued) {
if (!handle.inbox.hasWakingQueued) {
// Settle before publishing idle: the already-idle path has no status
// transition, while an idle listener can register waiters for new work.
handle.settleIdle()
@@ -234,10 +237,11 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
}
// A synchronous `running` listener can cancel before `runTurn`; balance the
// status only when no replacement prompt was queued by that listener.
// status only when no waking replacement prompt was queued by that listener
// (a lone quiet item parks at idle rather than driving a turn).
if (cancellation.signal.aborted) {
handle.clearTurnCancellation(cancellation)
if (!handle.inbox.hasQueued) {
if (!handle.inbox.hasWakingQueued) {
handle.setStatus('idle')
continue
}
@@ -260,12 +264,22 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
handle.clearTurnCancellation(cancellation)
}
// Late steering becomes queued input unless terminal policy stopped the turn.
for (const message of handle.inbox.drainSteering()) {
if (!terminalStopped) handle.inbox.enqueue(message)
// Late steering (arriving after runTurn returns, e.g. during the post-turn
// flush) becomes queued input — unless terminal policy stopped the turn, in
// which case it is dropped and must publish a discard so its enqueue is
// still matched (the invariant only catches a NEGATIVE count, not a leak).
const lateSteering = handle.inbox.drainSteering()
if (terminalStopped) {
if (lateSteering.length > 0) {
events.emit('agent/inbox/discard', lateSteering.map(message => agentMessage(message, true)))
}
} else {
for (const message of lateSteering) handle.inbox.enqueue(message)
}
if (!handle.inbox.hasQueued) handle.setStatus('idle')
// Park at idle unless a waking item still wants the model to run; a lone
// quiet (`wakeup:false`) item stays queued but does not keep the loop busy.
if (!handle.inbox.hasWakingQueued) handle.setStatus('idle')
}
}
@@ -279,10 +293,14 @@ async function runTurn(
const drainSteering = (): boolean => {
const messages = handle.inbox.drainSteering()
for (const message of messages) {
events.emit('agent/inbox/dequeue', agentMessage(message, true))
const prepared = preparePromptMessage(message.content, message.source, message.contexts)
session.append('steering/message', { turn, ...prepared.data }, { surfaceOp: 'append' })
session.append('steering/message', {
turn, ...prepared.data,
...message.meta === undefined ? {} : { meta: message.meta },
}, { surfaceOp: 'append' })
for (const context of prepared.separateContexts) {
session.append('context/message', {
session.append('user/message', {
content: context.content,
source: context.source,
...context.meta === undefined ? {} : { meta: context.meta },
@@ -296,6 +314,7 @@ async function runTurn(
const message = handle.inbox.dequeueQueued()
/* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
if (!message) throw new Error('runTurn invariant violated: no queued message at turn start')
events.emit('agent/inbox/dequeue', agentMessage(message, false))
const trigger: TurnTrigger = { kind: 'message', source: message.source }
let reason: TurnEndReason = { kind: 'completed' }
@@ -361,7 +380,10 @@ async function runTurn(
// `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
const content = promptDecision.content ?? message.content
const prepared = preparePromptMessage(content, message.source, promptDecision.additionalContexts ?? [])
session.append('user/message', prepared.data, { surfaceOp: 'append' })
session.append('user/message', {
...prepared.data,
...message.meta === undefined ? {} : { meta: message.meta },
}, { surfaceOp: 'append' })
// Separate contexts still enter THIS turn through inject(). Prefix
// contexts are already baked into the user/message with their durable
// display envelope, so appending them again would duplicate model input.
@@ -536,9 +558,21 @@ async function runTurn(
break
}
// A continuation reason becomes next-step steering.
// A continuation reason becomes next-step steering. Publish the same
// enqueue event a public steer would, so the inbox ledger stays balanced
// (every FIFO entry has a matching enqueue before its dequeue/discard).
if (decision.action === 'continue' && decision.reason) {
handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source, contexts: [] })
// Detach and freeze the listener-owned reason like a public steer, so an
// enqueue listener or the producer cannot mutate the durable/model-visible
// steering message before it drains.
const item: InboxMessage = deepFreeze({
id: AgentMessageId(randomUUID()),
content: structuredClone(decision.reason.content),
source: structuredClone(decision.reason.source),
contexts: [], wakeup: true,
})
handle.inbox.steer(item)
events.emit('agent/inbox/enqueue', agentMessage(item, true))
}
let shouldContinue = decision.action === 'continue'
@@ -562,7 +596,13 @@ async function runTurn(
if (terminalStop) {
terminalStopped = true
// Terminal stop discards steering but preserves ordinary queued prompts.
handle.inbox.drainSteering()
// Publish a discard for every dropped steering item so the enqueue ⇒
// dequeue-or-discard ledger stays balanced (the outstanding-count
// invariant and correlation consumers must not be left with dangling ids).
const dropped = handle.inbox.drainSteering()
if (dropped.length > 0) {
events.emit('agent/inbox/discard', dropped.map(item => agentMessage(item, true)))
}
shouldContinue = false
}

View File

@@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function send(agent: Agent, text: string): void {
agent.send([{ type: 'text', text }])
agent.followup([{ type: 'text', text }])
}
/** Adapter that holds both drivers at the same awaited continuation. */

View File

@@ -48,7 +48,7 @@ function waitForStatus(ctx: Context, agent: Agent, expected: Agent['status']): P
}
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
agent.followup([{ type: 'text', text }])
}
describe('Agent', () => {
@@ -83,7 +83,41 @@ describe('Agent', () => {
await ctx.fiber.dispose()
})
it('send() throws after disposal', async () => {
it('send exposes the fully resolved delivery path without applying helper defaults', async () => {
const adapter = new MockAdapter([textResponse('accepted')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const enqueued = Promise.withResolvers<{ id: string; source: unknown; wakeup: boolean }>()
ctx.on('agent/inbox/enqueue', (subject, message) => {
if (subject === agent) enqueued.resolve(message)
})
const id = agent.send({
content: [{ type: 'text', text: 'advanced input' }],
source: { kind: 'plugin', plugin: 'advanced-caller' },
contexts: [],
meta: { caller: 'advanced' },
target: 'next-turn',
wakeup: true,
})
await waitForIdle(ctx, agent)
expect(await enqueued.promise).toMatchObject({
id,
source: { kind: 'plugin', plugin: 'advanced-caller' },
wakeup: true,
})
expect(agent.session.events.find(event => event.type === 'user/message'))
.toMatchObject({
data: {
source: { kind: 'plugin', plugin: 'advanced-caller' },
meta: { caller: 'advanced' },
},
})
await ctx.fiber.dispose()
})
it('followup() throws after disposal', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: Agent
@@ -95,7 +129,28 @@ describe('Agent', () => {
await fiber.dispose()
await driverDone(agent)
expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
expect(() => { agent.followup([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
})
it('disposal discards still-pending inbox items so every id gets a terminal event', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: Agent
const discarded: string[] = []
ctx.on('agent/inbox/discard', (subject, messages) => {
if (subject === agent) discarded.push(...messages.map(m => m.id))
})
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
// A quiet (non-waking) item stays parked in the inbox; disposal must drop it
// WITH a discard so its enqueued id is not left dangling forever.
const id = agent.queue([{ type: 'text', text: 'never runs' }])
await fiber.dispose()
await driverDone(agent)
expect(discarded).toEqual([id])
})
it('steer() throws after disposal', async () => {
@@ -139,7 +194,7 @@ describe('Agent', () => {
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
agent.inject([{ type: 'text', text: 'mid' }], { source: { kind: 'plugin', plugin: 'p' } })
expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.at(-1)!.type).toBe('context/message')
expect(agent.session.events.at(-1)!.type).toBe('user/message')
// Close the turn; now inject must wrap its own one-shot injection turn.
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -151,6 +206,16 @@ describe('Agent', () => {
expect(agent.session.events.at(-1)!.type).toBe('turn/end') // turn-enclosed
})
it('inject() defaults its source to an empty plugin, never user', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
agent.inject([{ type: 'text', text: 'no explicit source' }])
const injected = agent.session.events.at(-1)!
expect(injected.type === 'user/message' && injected.data.source).toEqual({ kind: 'plugin', plugin: '' })
})
it('idle inject() contains a failing flush (logs, does not throw into the caller)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
@@ -167,24 +232,54 @@ describe('Agent', () => {
warn.mockRestore()
})
it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => {
it('idle inject() validates its payload BEFORE opening a turn, so invalid input appends nothing', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// Non-serializable injected content makes Session.append throw AFTER
// turn/start was recorded. The turn/end must still be appended (finally),
// AND the durability checkpoint must still fire — the balanced turn is in
// memory and a crash before the next turn/dispose would otherwise lose it.
// Non-serializable injected content is rejected by the up-front snapshot
// BEFORE any append (the unified send contract: invalid input throws before
// mutating the log). No one-shot turn opens and no durability checkpoint fires.
expect(() => {
agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } })
}).toThrow(/non-JSON-serializable/)
const types = agent.session.events.map(e => e.type)
expect(types).toEqual(['turn/start', 'turn/end']) // balanced, no open turn
await new Promise(r => setTimeout(r, 10)) // let the fire-and-forget flush run
expect(flushes).toBe(1) // checkpoint fired despite the throw
}).toThrow(/losslessly JSON-serializable/)
expect(agent.session.events).toHaveLength(0)
await new Promise(r => setTimeout(r, 10)) // give any (erroneous) flush a chance
expect(flushes).toBe(0) // nothing was appended, so no checkpoint
})
it('idle inject() re-entered from a session/event listener is rejected pre-commit and opens no turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// Injecting from inside a session/event listener re-enters Session.append,
// which rejects pre-commit — so turn/start never commits. The finally sees
// no open turn (closes nothing) and no recorded turn (no checkpoint), and
// the reentrant throw is contained by Session's post-commit dispatch.
// Fire on turn/end: at that instant the outer one-shot turn is closed (no
// turn open), so the reentrant inject takes the idle one-shot-turn path and
// its turn/start append re-enters Session and is rejected pre-commit.
let reentered = false
ctx.on('session/event', (_s, event) => {
if (!reentered && event.type === 'turn/end') {
reentered = true
agent.inject([{ type: 'text', text: 'reentrant' }], { source: { kind: 'plugin', plugin: 'p' } })
}
})
agent.inject([{ type: 'text', text: 'outer' }], { source: { kind: 'plugin', plugin: 'p' } })
// The outer injection's own one-shot turn is balanced; the reentrant one
// opened no turn (its turn/start was rejected pre-commit).
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start')
expect(turnStarts).toHaveLength(1)
const injected = agent.session.events.filter(e => e.type === 'user/message')
expect(injected).toHaveLength(1) // the reentrant user/message never committed
await new Promise(r => setTimeout(r, 10))
expect(flushes).toBe(1) // only the outer accepted turn checkpointed
})
it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => {
@@ -202,7 +297,7 @@ describe('Agent', () => {
expect(() => { agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } }) }).not.toThrow()
const types = agent.session.events.map(e => e.type)
expect(types).toEqual(['turn/start', 'context/message', 'turn/end']) // balanced
expect(types).toEqual(['turn/start', 'user/message', 'turn/end']) // balanced
await new Promise(r => setTimeout(r, 10))
expect(flushes).toBe(1) // checkpoint fired despite the throwing turn/end listener
})
@@ -234,13 +329,11 @@ describe('Agent', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// A non-serializable source makes the turn/start append throw BEFORE the
// event is pushed (Session.append validates before push), so NO turn opens.
// The finally's isTurnOpen() guard sees no open turn and appends nothing —
// the log stays empty, not left with a dangling turn/start.
// A non-serializable source is rejected by the up-front snapshot BEFORE any
// append, so NO turn opens and the log stays empty.
expect(() => {
agent.inject([{ type: 'text', text: 'x' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
}).toThrow(/non-JSON-serializable/)
}).toThrow(/losslessly JSON-serializable/)
expect(agent.session.events).toHaveLength(0)
})
@@ -397,7 +490,7 @@ describe('Agent', () => {
const { agent } = prepared
prepared.markPublished()
const dispose = prepared.startDriver()
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')

View File

@@ -33,7 +33,7 @@ async function harness(adapter: MockAdapter) {
}
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
agent.followup([{ type: 'text', text }])
}
/** Resolve on the agent's next idle transition (event-based, not status poll). */
@@ -63,7 +63,7 @@ describe('Agent.cancel()', () => {
ctx.on('agent/cancel-requested', (subject, cause) => {
if (subject !== agent) return
seen.push(`first:${cause.kind}`)
subject.send([{ type: 'text', text: 'queued by cancel observer' }])
subject.followup([{ type: 'text', text: 'queued by cancel observer' }])
throw new Error('observer failed')
})
ctx.on('agent/cancel-requested', (subject, cause) => {
@@ -98,6 +98,57 @@ describe('Agent.cancel()', () => {
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
})
it('cancel({ keepInbox: true }) preserves queued work and emits no discard', async () => {
const adapter = new MockAdapter([textResponse('reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const discards: unknown[] = []
ctx.on('agent/inbox/discard', (subject, items) => { if (subject === agent) discards.push(items) })
// Queue a turn WITHOUT waking the driver, so it sits in the inbox.
agent.queue([{ type: 'text', text: 'preserved' }])
// keepInbox cancel: no active turn, work preserved, no discard event.
agent.cancel({ kind: 'user' }, { keepInbox: true })
expect(discards).toEqual([])
// The preserved item still runs once the driver is woken by a later send.
send(agent, 'wake it')
await waitForIdle(ctx, agent)
expect(userTexts(agent)).toEqual(['preserved', 'wake it'])
})
it('a lone queued message leaves the agent parked at idle', async () => {
const adapter = new MockAdapter([textResponse('reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// A quiet item alone must NOT wake the driver: no turn runs and whenIdle
// resolves (the agent is quiescent), leaving the item queued.
agent.queue([{ type: 'text', text: 'quiet' }])
await agent.whenIdle()
expect(agent.status).toBe('idle')
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
// A later waking send drives the loop, and the quiet item rides along first.
send(agent, 'wake')
await waitForIdle(ctx, agent)
expect(userTexts(agent)).toEqual(['quiet', 'wake'])
})
it('cancelling a parked quiet item settles a pending whenIdle() without a later send', async () => {
const adapter = new MockAdapter([textResponse('reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.queue([{ type: 'text', text: 'quiet' }])
const idle = agent.whenIdle()
// Cancel reaches quiescence with no status transition and no waking send;
// whenIdle must still resolve (previously it hung until the next send).
agent.cancel({ kind: 'user' })
await idle
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
})
it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)

View File

@@ -98,7 +98,7 @@ describe('config-driven session id', () => {
first = ctx.agents.get(SessionId('config-exact-reload'))
}
expect(first).toBeDefined()
first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
first!.followup([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
await waitForIdle(ctx, first!)
await firstLoop.dispose()
@@ -110,7 +110,7 @@ describe('config-driven session id', () => {
}
expect(second).toBeDefined()
expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me')
second!.send([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } })
second!.followup([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } })
await waitForIdle(ctx, second!)
await ctx.sessions.flush(second!.session)
const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload'))
@@ -335,7 +335,7 @@ describe('config-driven session id', () => {
expect(a1.id).toBe(a1.session.id)
expect(a1.session.id).toMatch(idPattern)
expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined()
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -354,7 +354,7 @@ describe('config-driven session id', () => {
expect(a2.id).toBe(a2.session.id)
expect(a2.session.id).toMatch(idPattern)
expect(a2.session.id).not.toBe(a1.session.id)
a2.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } })
a2.followup([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } })
await waitForIdle(ctx2, a2)
await ctx2.fiber.dispose()
})
@@ -375,7 +375,7 @@ describe('config-driven session id', () => {
await ctx1.plugin(SessionPersistenceJsonl, { root })
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent
a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
a1.followup([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()

View File

@@ -50,7 +50,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
agent.followup([{ type: 'text', text }])
}
describe('session log records what agent/step-result actually produced', () => {
@@ -275,7 +275,9 @@ describe('abort during tool execution ends the turn', () => {
order.push(`tool/result:${event.data.callId}:${outcome}`)
break
}
case 'context/message': order.push('context/message'); break
// Injected context is a plugin-sourced user/message; the direct human
// prompt (user source) is not tracked in this ordering.
case 'user/message': if (event.data.source.kind !== 'user') order.push('context/message'); break
case 'steering/message': order.push('steering/message'); break
case 'step/end': order.push('step/end'); break
case 'turn/end': {
@@ -354,13 +356,14 @@ describe('abort during tool execution ends the turn', () => {
await waitForIdle(ctx, agent)
const events = [...agent.session.events]
const isInjected = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user'
expect(events
.filter(event => event.type === 'tool/result' || event.type === 'context/message'
.filter(event => event.type === 'tool/result' || isInjected(event)
|| event.type === 'step/end' || event.type === 'turn/end')
.map(event => event.type))
.map(event => isInjected(event) ? 'context/message' : event.type))
.toEqual(['tool/result', 'context/message', 'context/message', 'step/end', 'turn/end'])
expect(events
.filter(event => event.type === 'context/message')
.filter(isInjected)
.map(event => event.data.content))
.toEqual([
[{ type: 'text', text: 'accepted before abort' }],
@@ -410,12 +413,13 @@ describe('abort during tool execution ends the turn', () => {
await waitForIdle(ctx, agent)
const events = [...agent.session.events]
const isInjected = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user'
expect(events
.filter(event => event.type === 'tool/result' || event.type === 'context/message'
.filter(event => event.type === 'tool/result' || isInjected(event)
|| event.type === 'step/end' || event.type === 'turn/end')
.map(event => event.type))
.map(event => isInjected(event) ? 'context/message' : event.type))
.toEqual(['tool/result', 'tool/result', 'context/message', 'step/end', 'turn/end'])
expect(events.find(event => event.type === 'context/message')?.data.content)
expect(events.find(isInjected)?.data.content)
.toEqual([{ type: 'text', text: 'accepted after first result' }])
})
@@ -456,7 +460,7 @@ describe('abort during tool execution ends the turn', () => {
await fiber.dispose()
expect(agent.session.events
.filter(event => event.type === 'context/message')
.filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user')
.map(event => event.data.content))
.toEqual([
[{ type: 'text', text: 'accepted before disposal' }],
@@ -507,7 +511,7 @@ describe('abort during tool execution ends the turn', () => {
send(agent, 'start a text-only turn')
await waitForIdle(ctx, agent)
expect(agent.session.events.find(event => event.type === 'context/message')?.data.content)
expect(agent.session.events.find((event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user')?.data.content)
.toEqual([{ type: 'text', text: 'new turn context' }])
expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('new turn context')
})
@@ -763,7 +767,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }])
})
it('agent/queued carries the resolved source; steering/message records its source', async () => {
it('agent/inbox/enqueue carries the resolved source; steering/message records its source', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -778,7 +782,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
}))
const queuedSources: { source: MessageSource; contexts: HookContext[]; steering: boolean }[] = []
ctx.on('agent/queued', (_agent, _content, info) => void queuedSources.push(info))
ctx.on('agent/inbox/enqueue', (_agent, info) => void queuedSources.push({ source: info.source, contexts: info.contexts, steering: info.steering }))
send(agent, 'go') // no explicit source → default {kind:'user'} must be visible
await waitForIdle(ctx, agent)
@@ -800,11 +804,11 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
let notifiedContent: ContentBlock[] | undefined
let notifiedSource: MessageSource | undefined
let notifiedContexts: HookContext[] | undefined
ctx.on('agent/queued', (subject, acceptedContent, info) => {
ctx.on('agent/inbox/enqueue', (subject, info) => {
if (subject !== agent || info.steering) return
// Retain the exact notification references: cloning here would test the
// listener's copy rather than the event/inbox ownership boundary.
notifiedContent = acceptedContent
notifiedContent = info.content
notifiedSource = info.source
notifiedContexts = info.contexts
})
@@ -814,7 +818,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
source: { kind: 'plugin', plugin: 'context-source' },
meta: { version: 1 },
}]
agent.send(content, { source, contexts })
agent.followup(content, { source, contexts })
content[0]!.text = 'caller-mutated-send'
source.plugin = 'caller-mutated-source'
contexts[0]!.content[0] = { type: 'text', text: 'caller-mutated-context' }
@@ -863,14 +867,14 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
let notifiedContent: ContentBlock[] | undefined
let notifiedSource: MessageSource | undefined
let notifiedContexts: HookContext[] | undefined
ctx.on('agent/queued', (subject, acceptedContent, info) => {
ctx.on('agent/inbox/enqueue', (subject, info) => {
if (subject !== agent || !info.steering) return
notifiedContent = acceptedContent
notifiedContent = info.content
notifiedSource = info.source
notifiedContexts = info.contexts
})
agent.send([{ type: 'text', text: 'start' }])
agent.followup([{ type: 'text', text: 'start' }])
await entered.promise
expect(agent.status).toBe('running')
const content = [{ type: 'text' as const, text: 'accepted-steer' }]
@@ -951,7 +955,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
expect(request).not.toContain('caller-mutated-steering-context-without-meta')
const steeringIndex = agent.session.events.findIndex(event => event.type === 'steering/message')
const contextIndex = agent.session.events.findIndex(event => event.type === 'context/message'
const contextIndex = agent.session.events.findIndex(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'steering-context')
expect(steeringIndex).toBeGreaterThanOrEqual(0)
expect(contextIndex).toBe(steeringIndex + 1)
@@ -987,7 +991,7 @@ describe('turn numbering continues across seeded sessions', () => {
const turns: number[] = []
ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
forked.send([{ type: 'text', text: 'continue' }])
forked.followup([{ type: 'text', text: 'continue' }])
await new Promise<void>((resolve) => {
ctx2.on('agent/status', (subject, status) => {
if (subject === forked && status === 'idle') resolve()

View File

@@ -38,7 +38,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
agent.followup([{ type: 'text', text }])
}
describe('inbox acceptance', () => {
@@ -47,13 +47,13 @@ describe('inbox acceptance', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let queued = 0
ctx.on('agent/queued', () => { queued += 1 })
ctx.on('agent/inbox/enqueue', () => { queued += 1 })
expect(() => {
agent.send([{ type: 'text', text: 'first', bad: 1n } as never])
agent.followup([{ type: 'text', text: 'first', bad: 1n } as never])
}).toThrow(/losslessly JSON-serializable/)
expect(() => {
agent.send([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
agent.followup([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
}).toThrow(/losslessly JSON-serializable/)
expect(queued).toBe(0)
expect(agent.session.events).toHaveLength(0)

View File

@@ -0,0 +1,155 @@
/**
* Regression: the dsh-agent FIFO-conservation invariant must stay balanced on
* the loop-authored continuation-reason steering path. A continue-with-reason
* decision enters the steering FIFO and later drains (or is discarded by
* cancel); both must be matched by an enqueue event so the invariant's
* outstanding count never goes negative.
* @module dsh-agent-loop/tests/inbox-invariant
*/
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter) {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(InvariantService)
await ctx.plugin(AgentInvariant)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() }
})
})
}
describe('inbox FIFO-conservation invariant', () => {
it('stays balanced when a continuation reason enters and drains the steering FIFO', async () => {
const adapter = new MockAdapter([textResponse('step 1'), textResponse('step 2')])
const ctx = await harness(adapter)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let forced = false
ctx.on('agent/turn-continuation', async (_agent, _turn, _default, _signal, next) => {
if (forced) return next()
forced = true
return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } }
})
agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(2)
// The continuation reason drained as a steering/message on the second step.
expect(agent.session.events.some(e => e.type === 'steering/message')).toBe(true)
// No invariant violation was logged.
expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false)
expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false)
})
it('stays balanced when cancel discards a pending continuation reason', async () => {
const adapter = new MockAdapter([textResponse('only step')])
const ctx = await harness(adapter)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// Force a continuation reason, then cancel from the same checkpoint so the
// reason sits in the steering FIFO when the inbox is discarded.
ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => {
if (subject !== agent) return next()
queueMicrotask(() => { agent.cancel({ kind: 'user' }) })
return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } }
})
agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false)
expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false)
})
it('stays balanced when a terminal stop discards pending steering', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const discards: number[] = []
ctx.on('agent/inbox/discard', (subject, messages) => { if (subject === agent) discards.push(messages.length) })
// A continuation reason enqueues a steering item; a terminal stop then drops
// it. The drop must emit a discard so the enqueue ⇒ dequeue-or-discard
// ledger stays balanced (no dangling outstanding id).
ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => {
if (subject !== agent) return next()
return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } }
})
let stopped = false
ctx.on('agent/turn-stop', (subject) => {
if (subject !== agent || stopped) return undefined
stopped = true
return { action: 'stop' as const }
})
agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(discards).toEqual([1]) // the dropped steering item was reported
expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false)
expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false)
})
it('stays balanced when late steering lands after a terminal stop (post-turn flush window)', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let enqueues = 0
const discards: number[] = []
ctx.on('agent/inbox/enqueue', (subject) => { if (subject === agent) enqueues += 1 })
ctx.on('agent/inbox/discard', (subject, messages) => { if (subject === agent) discards.push(messages.length) })
// Terminal-stop the turn, then steer during the post-turn flush window
// (status is still running). That late steer is drained by runLoop and
// dropped because the turn terminally stopped; it must still be discarded so
// its enqueue is matched (the drain sits on a different code path than the
// in-turn terminal-stop drop).
ctx.on('agent/turn-stop', subject => (subject === agent ? { action: 'stop' as const } : undefined))
let steered = false
ctx.on('session/flush', (session) => {
if (session !== agent.session || steered) return
steered = true
agent.steer([{ type: 'text', text: 'late' }], { source: { kind: 'plugin', plugin: 'late' } })
})
agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
// The prompt plus the late steer both enqueued; both are matched (the prompt
// dequeued, the late steer discarded) so no id is left outstanding.
expect(enqueues).toBe(2)
expect(discards).toEqual([1])
expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false)
expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false)
})
})

View File

@@ -1,10 +1,20 @@
import { describe, expect, it } from 'vitest'
import { Inbox } from '../src/inbox.ts'
import { AgentMessageId } from '@deepseek-ai/dsh-agent'
import { Inbox, agentMessage } from '../src/inbox.ts'
function message(text: string) {
return { content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [] }
return { id: AgentMessageId(text), content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true }
}
describe('agentMessage', () => {
it('returns a frozen payload so a listener cannot mutate it for later listeners', () => {
const payload = agentMessage(message('m'), false)
expect(Object.isFrozen(payload)).toBe(true)
expect(() => { (payload as { id: string }).id = 'mutated' }).toThrow()
expect(payload.id).toBe(AgentMessageId('m'))
})
})
function resolverPair() {
let r!: () => void
const p = new Promise<void>((resolve) => { r = resolve })
@@ -25,6 +35,32 @@ describe('Inbox', () => {
expect(inbox.dequeueQueued()).toBeUndefined()
})
it('enqueue(msg, false) queues without waking a parked waiter', async () => {
const inbox = new Inbox()
let woke = false
const waiter = inbox.waitForQueued(new Promise(() => {})).then(() => { woke = true })
inbox.enqueue(message('quiet'), false)
// The item is queued, but the parked waiter was not resolved by it.
expect(inbox.hasQueued).toBe(true)
await Promise.resolve()
expect(woke).toBe(false)
// A later waking enqueue resolves the same waiter.
inbox.enqueue(message('loud'))
await waiter
expect(woke).toBe(true)
})
it('pending() snapshots queued then steering without removing them', () => {
const inbox = new Inbox()
inbox.enqueue(message('q'))
inbox.steer(message('s'))
const pending = inbox.pending()
expect(pending.map(p => p.steering)).toEqual([false, true])
// Snapshot does not drain the FIFOs.
expect(inbox.hasQueued).toBe(true)
expect(inbox.hasSteering).toBe(true)
})
it('pushes and drains steering messages separately from queued', () => {
const inbox = new Inbox()
inbox.steer(message('steer'))

View File

@@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
agent.followup([{ type: 'text', text }])
}
function events(agent: Agent): SessionEvent[] {
@@ -87,7 +87,7 @@ describe('agent/prompt-submit', () => {
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original')
})
it('allow with additionalContexts injects separate context/message events into the turn', async () => {
it('allow with additionalContexts injects separate injected-context user messages into the turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -107,12 +107,12 @@ describe('agent/prompt-submit', () => {
await waitForIdle(ctx, agent)
const log = events(agent)
const userMsg = log.find(e => e.type === 'user/message')
const ctxMsg = log.find(e => e.type === 'context/message')
const userMsg = log.find(e => e.type === 'user/message' && e.data.source.kind === 'user')
const ctxMsg = log.find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
expect(userMsg).toBeDefined()
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }])
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.meta).toEqual(meta)
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }])
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.meta).toEqual(meta)
const sent = JSON.stringify(adapter.requests[0]!.messages)
expect(sent).toContain('extra ctx')
})
@@ -128,7 +128,7 @@ describe('agent/prompt-submit', () => {
? downstream
: { ...downstream, content: [{ type: 'text', text: 'rewritten request' }] }
})
agent.send([{ type: 'text', text: 'original request' }], {
agent.followup([{ type: 'text', text: 'original request' }], {
contexts: [{
content: [{ type: 'text', text: 'untrusted prefix' }],
source: { kind: 'plugin', plugin: 'prefix' },
@@ -155,7 +155,7 @@ describe('agent/prompt-submit', () => {
}],
},
})
expect(log.some(event => event.type === 'context/message')).toBe(false)
expect(log.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false)
expect(adapter.requests[0]?.messages.at(-1)).toEqual({
role: 'user',
content: [
@@ -203,7 +203,7 @@ describe('agent/prompt-submit', () => {
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
agent.send([{ type: 'text', text: 'do something' }], {
agent.followup([{ type: 'text', text: 'do something' }], {
contexts: [{ content: [{ type: 'text', text: 'must be dropped' }], source: { kind: 'plugin', plugin: 'test' } }],
})
await waitForIdle(ctx, agent)
@@ -215,7 +215,6 @@ describe('agent/prompt-submit', () => {
expect(log.some(e => e.type === 'turn/start')).toBe(true)
expect(log.some(e => e.type === 'turn/end')).toBe(true)
expect(log.some(e => e.type === 'user/message')).toBe(false)
expect(log.some(e => e.type === 'context/message')).toBe(false)
expect(log.some(e => e.type === 'step/start')).toBe(false)
// the veto is recorded durably as a prompt/blocked in the open turn
const blocked = log.find(e => e.type === 'prompt/blocked')
@@ -340,8 +339,8 @@ describe('agent/session-start', () => {
// the injected context reached the model on the first (only) request
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
// and is recorded with the plugin source, never mislabeled as a user prompt
const ctxMsg = events(agent).find(e => e.type === 'context/message')
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
const ctxMsg = events(agent).find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
})
it('a throwing session-start listener does not abort agent construction', async () => {
@@ -624,23 +623,22 @@ describe('tool additionalContexts buffering across a step', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
// Event order in the log: both tool/results, THEN both context/messages —
// Event order in the log: both tool/results, THEN both injected contexts —
// never interleaved (which would break tool-call/result adjacency).
const types = events(agent).map(e => e.type)
const firstResult = types.indexOf('tool/result')
const lastResult = types.lastIndexOf('tool/result')
const firstCtx = types.indexOf('context/message')
const injected = events(agent).filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
const seqs = events(agent)
const firstResult = seqs.findIndex(e => e.type === 'tool/result')
const lastResult = seqs.map(e => e.type).lastIndexOf('tool/result')
const firstCtx = seqs.findIndex(e => e === injected[0])
expect(firstResult).toBeGreaterThanOrEqual(0)
expect(lastResult).toBeGreaterThan(firstResult) // two results
expect(firstCtx).toBeGreaterThan(lastResult) // context only after ALL results
// both contexts present
const ctxTexts = events(agent)
.filter(e => e.type === 'context/message')
.flatMap(e => (e.type === 'context/message' ? e.data.content : []))
const ctxTexts = injected
.flatMap(e => (e.type === 'user/message' ? e.data.content : []))
.map(b => (b.type === 'text' ? b.text : ''))
expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2'])
const contextEvents = events(agent).filter(e => e.type === 'context/message')
expect(contextEvents.map(e => e.type === 'context/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }])
expect(injected.map(e => e.type === 'user/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }])
})
it('appends multiple contexts deferred by one composite tool after its outer result', async () => {
@@ -661,14 +659,14 @@ describe('tool additionalContexts buffering across a step', () => {
const log = events(agent)
const resultIndex = log.findIndex(event => event.type === 'tool/result')
const contextEvents = log.filter(event => event.type === 'context/message')
const contextEvents = log.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
expect(resultIndex).toBeGreaterThanOrEqual(0)
expect(log.findIndex(event => event === contextEvents[0])).toBeGreaterThan(resultIndex)
expect(contextEvents.map(event => event.type === 'context/message' && event.data.source)).toEqual([
expect(contextEvents.map(event => event.type === 'user/message' && event.data.source)).toEqual([
{ kind: 'plugin', plugin: 'a' },
{ kind: 'plugin', plugin: 'b' },
])
expect(contextEvents.map(event => event.type === 'context/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }])
expect(contextEvents.map(event => event.type === 'user/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }])
})
})
@@ -750,13 +748,13 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
const log = events(agent)
// session-start preamble injected
expect(log.some(e => e.type === 'context/message'
expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('policy active (started: startup)')))).toBe(true)
// prompt allowed → user/message recorded
expect(log.some(e => e.type === 'user/message')).toBe(true)
// prompt allowed → user-sourced user/message recorded
expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'user')).toBe(true)
// tool ran (echo allowed) and post-execute attached "audited" context
expect(log.some(e => e.type === 'tool/result' && !e.data.isError)).toBe(true)
expect(log.some(e => e.type === 'context/message'
expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin'
&& e.data.content.some(b => b.type === 'text' && b.text === 'audited'))).toBe(true)
// NO hook/* events — a native plugin needs none
expect(log.some(e => e.type.startsWith('hook/'))).toBe(false)

View File

@@ -42,7 +42,7 @@ describe('request-reconstruction invariant', () => {
it('uses the step boundary rather than content appended afterward', async () => {
const { ctx, session, boundary } = await requestSetup()
session.append('context/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' })
session.append('user/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' })
const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
expect(() => { dispatch(ctx, options) }).not.toThrow()
})

View File

@@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
agent.followup([{ type: 'text', text }])
}
describe('agent loop', () => {
@@ -391,7 +391,7 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } })
// The idle inject records a self-contained turn (turn/start → context/message
// The idle inject records a self-contained turn (turn/start → user/message
// → turn/end) so the event stays turn-enclosed, but does NOT run the model.
await new Promise(r => setTimeout(r, 20))
expect(agent.status).toBe('idle')
@@ -427,8 +427,8 @@ describe('agent loop', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
const contextEvent = agent.session.events.find(event => event.type === 'context/message')
expect(contextEvent?.type === 'context/message' && contextEvent.data).toMatchObject({ meta })
const contextEvent = agent.session.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
expect(contextEvent?.type === 'user/message' && contextEvent.data).toMatchObject({ meta })
const requestText = JSON.stringify(adapter.requests[0]!.messages)
expect(requestText).toContain('Additional instructions from: pkg/AGENTS.md')
expect(requestText).not.toContain('<context source=')
@@ -456,7 +456,7 @@ describe('agent loop', () => {
})
first.text = 'mutated after inject'
agent.inject([{ type: 'text', text: 'second notice' }], { source: { kind: 'plugin', plugin: 'x' } })
visibleDuringTool = agent.session.events.some(e => e.type === 'context/message')
visibleDuringTool = agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
return [{ type: 'text', text: 'ok' }]
},
}))
@@ -473,13 +473,13 @@ describe('agent loop', () => {
const ts0 = turnStarts[0]!
expect(ts0.type === 'turn/start' && ts0.data.trigger.kind).toBe('message')
const result = agent.session.events.find(e => e.type === 'tool/result')!
const contexts = agent.session.events.filter(e => e.type === 'context/message')
const contexts = agent.session.events.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
expect(contexts).toHaveLength(2)
expect(result.seq).toBeLessThan(contexts[0]!.seq)
expect(contexts[0]?.type === 'context/message' && contexts[0].data).toMatchObject({
expect(contexts[0]?.type === 'user/message' && contexts[0].data).toMatchObject({
meta,
})
expect(contexts.flatMap(event => event.type === 'context/message' ? event.data.content : []))
expect(contexts.flatMap(event => event.type === 'user/message' ? event.data.content : []))
.toEqual([
{ type: 'text', text: 'mid-turn notice' },
{ type: 'text', text: 'second notice' },
@@ -523,7 +523,29 @@ describe('agent loop', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(agent.session.events.some(event => event.type === 'context/message')).toBe(false)
expect(agent.session.events.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false)
})
it('preserves SendOptions.meta on the durable user/message and steering/message', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
const ctx = await harness(adapter)
ctx.tools.register(defineContentToolFixture({
name: 'noop', description: '', parameters: {},
async execute() {
// Running steer carries its own meta onto the durable steering/message.
agent.steer([{ type: 'text', text: 's' }], { source: { kind: 'plugin', plugin: 'p' }, meta: { steer: 1 } })
return []
},
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }], { meta: { prompt: 1 } })
await waitForIdle(ctx, agent)
const user = agent.session.events.find(e => e.type === 'user/message')
expect(user?.type === 'user/message' && user.data.meta).toEqual({ prompt: 1 })
const steering = agent.session.events.find(e => e.type === 'steering/message')
expect(steering?.type === 'steering/message' && steering.data.meta).toEqual({ steer: 1 })
})
it('agent/turn-continuation can force-continue (/loop pattern) and force-stop', async () => {
@@ -632,7 +654,7 @@ describe('agent loop', () => {
ctx.on('agent/pre-step', (subject) => {
if (subject === agent && !injected) {
injected = true
subject.session.append('context/message', {
subject.session.append('user/message', {
content: [{ type: 'text', text: 'INJECTED-IN-PRE-STEP' }],
source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: 'append' })
@@ -650,7 +672,7 @@ describe('agent loop', () => {
// And the injected event sits BEFORE the first step/start in the log —
// the seam fired outside the step.
const events = agent.session.events
const injectedSeq = events.find(e => e.type === 'context/message')!.seq
const injectedSeq = events.find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')!.seq
const firstStepStartSeq = events.find(e => e.type === 'step/start')!.seq
expect(injectedSeq).toBeLessThan(firstStepStartSeq)
})
@@ -1028,13 +1050,13 @@ describe('agent loop', () => {
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('turn-end listener message')
})
it('keeps a reentrant agent/queued send as the next independent turn', async () => {
it('keeps a reentrant agent/inbox/enqueue send as the next independent turn', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let nested = false
ctx.on('agent/queued', (subject) => {
ctx.on('agent/inbox/enqueue', (subject) => {
if (subject !== agent || nested) return
nested = true
send(agent, 'queued listener message')
@@ -1061,9 +1083,9 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'user message' }])
agent.followup([{ type: 'text', text: 'user message' }])
await Promise.resolve()
agent.send(
agent.followup(
[{ type: 'text', text: 'plugin message' }],
{ source: { kind: 'plugin', plugin: 'test' } },
)

View File

@@ -115,7 +115,7 @@ describe('agent loop scheduling properties', () => {
const { seen: trace } = recordStatus(ctx, agent)
const idle = nextIdle(ctx, agent)
// Send all in one synchronous tick: they queue before the loop wakes.
for (const text of texts) agent.send([{ type: 'text', text }])
for (const text of texts) agent.followup([{ type: 'text', text }])
await idle
// No message lost: every send appears as a user/message, in order.
@@ -142,7 +142,7 @@ describe('agent loop scheduling properties', () => {
const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
for (const text of texts) {
const idle = nextIdle(ctx, agent)
agent.send([{ type: 'text', text }])
agent.followup([{ type: 'text', text }])
await idle
}
// Each send was drained at a separate turn start: N turns, 1..N.
@@ -171,7 +171,7 @@ describe('agent loop scheduling properties', () => {
for (const step of steps) {
const idle = nextIdle(ctx, agent)
lastIdle = idle
agent.send([{ type: 'text', text: step.text }])
agent.followup([{ type: 'text', text: step.text }])
if (step.settle) await idle
}
await lastIdle

View File

@@ -73,10 +73,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits (
const agent = ctx.agentLoop.create(SessionId('cache-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
// Turn 1: forces a tool call → at least two steps (two model requests).
agent.send([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }])
agent.followup([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }])
await waitForIdle(ctx, agent)
// Turn 2: a follow-up over the same (longer) prefix.
agent.send([{ type: 'text', text: 'Thanks. Repeat that value one more time.' }])
agent.followup([{ type: 'text', text: 'Thanks. Repeat that value one more time.' }])
await waitForIdle(ctx, agent)
const usages = [...agent.session.events]

View File

@@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
agent.followup([{ type: 'text', text }])
}
/** Assert `previous` is a strict value-prefix of `current`. */
@@ -118,7 +118,7 @@ describe('request stability across the loop', () => {
preStep()
const session = agent.session
const nodes = session.surface.nodes
session.append('context/message', {
session.append('user/message', {
content: [{ type: 'text', text: '[summary of turn 1]' }],
source: { kind: 'plugin', plugin: 'test-compact' },
}, {
@@ -180,7 +180,7 @@ describe('request stability across the loop', () => {
const first = adapter.requests[0]!
// The inject landed in the log after the boundary: not in THIS request…
expect(first.messages.some(m => m.content.some(b => b.type === 'text' && b.text.includes('[late context]')))).toBe(false)
expect(agent.session.events.some(e => e.type === 'context/message')).toBe(true)
expect(agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')).toBe(true)
send(agent, 'second')
await waitForIdle(ctx, agent)

View File

@@ -114,7 +114,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function send(agent: Agent): void {
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
}
function contextError(message = 'context too large'): LlmError {
@@ -154,12 +154,15 @@ describe('agent post-step and request-error lifecycle', () => {
const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' })
const order: string[] = []
ctx.on('session/event', (_session, event) => {
// Injected context is a plugin-sourced user/message; the direct human
// prompt (user source) stays untracked as before.
const isInjected = event.type === 'user/message' && event.data.source.kind !== 'user'
if (
event.type === 'assistant/message' || event.type === 'tool/call'
|| event.type === 'tool/result' || event.type === 'context/message'
|| event.type === 'tool/result' || isInjected
|| event.type === 'steering/message' || event.type === 'step/end'
) {
if (!('step' in event.data) || event.data.step === 1) order.push(event.type)
if (!('step' in event.data) || event.data.step === 1) order.push(isInjected ? 'context/message' : event.type)
}
})
ctx.on('agent/post-step', (subject, turn, step, signal) => {
@@ -271,7 +274,7 @@ describe('agent post-step and request-error lifecycle', () => {
expect({ turn, step, code: error.code }).toEqual({ turn: 1, step: 1, code: CONTEXT_WINDOW_EXCEEDED_CODE })
expect(facts.code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE)
attempts.push(history.length)
subject.session.append('context/message', {
subject.session.append('user/message', {
content: [{ type: 'text', text: 'RECOVERY SURFACE MUTATION' }],
source: { kind: 'plugin', plugin: 'test-recovery' },
}, { surfaceOp: 'append' })
@@ -288,7 +291,7 @@ describe('agent post-step and request-error lifecycle', () => {
const ends = agent.session.events.filter(event => event.type === 'step/end')
expect(starts.map(event => event.data.step)).toEqual([1, 2])
expect(ends.map(event => event.data.step)).toEqual([1, 2])
const recovery = agent.session.events.find(event => event.type === 'context/message')!
const recovery = agent.session.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')!
expect(ends[0]!.seq).toBeLessThan(recovery.seq)
expect(recovery.seq).toBeLessThan(starts[1]!.seq)
})

View File

@@ -146,7 +146,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const adapter1 = new MockAdapter([textResponse('a')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -174,7 +174,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent
expect(sources1).toEqual(['startup'])
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -480,7 +480,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
// Let inject()'s fire-and-forget flush settle (NO explicit flush/dispose).
@@ -503,7 +503,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
await ctx1.sessions.flush(a1.session)
@@ -531,7 +531,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const adapter1 = new MockAdapter([textResponse('first answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent
a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
a1.followup([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
const events1 = [...a1.session.events]
const seqs1 = events1.map(e => e.seq)
@@ -558,7 +558,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages())
// …and a new turn continues numbering (turn 2) with contiguous seqs.
a2.send([{ type: 'text', text: 'second question' }], { source: { kind: 'user' } })
a2.followup([{ type: 'text', text: 'second question' }], { source: { kind: 'user' } })
await waitForIdle(ctx2, a2)
const allSeqs = a2.session.events.map(e => e.seq)
expect(allSeqs).toEqual(allSeqs.map((_, i) => i)) // 0..N contiguous, no duplicates

View File

@@ -203,11 +203,11 @@ describe('agent scope lifecycle', () => {
if (event.type === 'user/message') heard.push('a-sees:user-message')
})
b.send(text('for b'))
b.followup(text('for b'))
await waitForIdle(ctx, b)
expect(heard).toEqual([]) // nothing of b's leaked into a's scope
a.send(text('for a'))
a.followup(text('for a'))
await waitForIdle(ctx, a)
expect(heard).toContain('a-sees:a:running')
expect(heard).toContain('a-sees:user-message')
@@ -934,7 +934,7 @@ describe('agent scope lifecycle', () => {
if (event.type === 'turn/start') { off(); resolve() }
})
})
agent.send(text('work'))
agent.followup(text('work'))
await turnOpen
await owner.dispose()
expect(order).toEqual(['turn-end', 'disposed(listed=false)', 'session-still-stored=true'])

View File

@@ -105,7 +105,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 3)
expect(gated.started).toEqual(['1', '2', '3'])
gated.release('1'); gated.release('2'); gated.release('3')
@@ -133,7 +133,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] },
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(order).toEqual(['r-start-A1', 'r-end-A1', 'w-A2', 'r-start-A3', 'r-end-A3'])
@@ -169,7 +169,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
await until(() => replacement.started.length === 1)
await new Promise(r => setTimeout(r, 5))
expect(replacement.started).toEqual(['1'])
@@ -200,7 +200,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
})
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
await until(() => initial.started.length === 2)
initial.release('1')
await until(() => events(agent).some(event =>
@@ -226,7 +226,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2)
gated.release('2')
await new Promise(r => setTimeout(r, 5))
@@ -248,7 +248,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2)
gated.release('2'); gated.release('1')
await waitForIdle(ctx, agent)
@@ -294,7 +294,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2)
await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1', '2'])
@@ -323,7 +323,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 1)
await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1'])
@@ -349,7 +349,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 1)
await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1'])
@@ -376,7 +376,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { post.push(String(exec.callId)); return next() })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 3)
gated.release('3'); gated.release('2'); gated.release('1')
await waitForIdle(ctx, agent)
@@ -397,17 +397,17 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } }] }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2)
gated.release('2'); gated.release('1')
await waitForIdle(ctx, agent)
const log = events(agent)
const contextTexts = log.filter(e => e.type === 'context/message')
.map(e => (e.data.content[0] as { text: string }).text)
const contextTexts = log.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
.map(e => ((e.data as { content: { text: string }[] }).content[0]!).text)
expect(contextTexts).toEqual(['ctx-c1', 'ctx-c2'])
const lastResult = log.findLastIndex(e => e.type === 'tool/result')
const firstContext = log.findIndex(e => e.type === 'context/message')
const firstContext = log.findIndex(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
expect(lastResult).toBeLessThan(firstContext)
})
@@ -435,7 +435,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
})
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 1)
gated.release('1')
await waitForIdle(ctx, agent)
@@ -465,7 +465,7 @@ describe('tool-call scheduler: abort handling', () => {
}
})
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(gated.started).toEqual([])
@@ -497,7 +497,7 @@ describe('tool-call scheduler: abort handling', () => {
return next()
})
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(gated.started).toEqual([])
@@ -527,7 +527,7 @@ describe('tool-call scheduler: abort handling', () => {
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2)
agent.cancel({ kind: 'user' })
gated.release('1')
@@ -548,10 +548,11 @@ describe('tool-call scheduler: abort handling', () => {
{ callId: CallId('c3'), isError: true, errorInfo: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
{ callId: CallId('c4'), isError: true, errorInfo: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
])
const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message')
const settled = events(agent).filter(e => e.type === 'tool/result'
|| (e.type === 'user/message' && e.data.source.kind === 'plugin'))
expect(settled.map(e => e.type))
.toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result', 'context/message', 'context/message'])
expect(settled.filter(e => e.type === 'context/message')
.toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result', 'user/message', 'user/message'])
expect(settled.filter(e => e.type === 'user/message')
.map(e => (e.data.content[0] as { text: string }).text))
.toEqual(['ctx-c1', 'ctx-c2'])
})
@@ -577,7 +578,7 @@ describe('tool-call scheduler: abort handling', () => {
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2)
agent.cancel({ kind: 'user' })
gated.release('1')

View File

@@ -58,7 +58,7 @@ async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConf
const ctx = await harness(adapter, toolOrder)
for (const name of registrationOrder) registerNamed(ctx, name)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
return { ctx, agent, adapter }
}
@@ -100,7 +100,7 @@ describe('loop-level canonical tool order', () => {
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; known tools: alpha'])

View File

@@ -34,7 +34,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
}
function send(agent: Agent, text = 'go'): Promise<void> {
agent.send([{ type: 'text', text }])
agent.followup([{ type: 'text', text }])
return agent.whenIdle()
}
@@ -116,7 +116,7 @@ describe('agent/turn-stop', () => {
ctx.on('session/flush', (session) => {
if (session !== agent.session || queued) return
queued = true
agent.send([{ type: 'text', text: 'ordinary queued follow-up' }])
agent.followup([{ type: 'text', text: 'ordinary queued follow-up' }])
})
await send(agent)

View File

@@ -48,18 +48,20 @@ The lifecycle edges have two important local caveats. `agent/created` runs after
Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. The signal remains authoritative through terminal policy and is retired immediately before `turn/end` publication, so terminal observers and the following durability flush cannot cancel completed turn work. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
`PromptDecision.additionalContexts` is an array so every context keeps its own source, metadata, and placement. `SendOptions.contexts` binds the same shape to one queued message before prompt interception: the default allow decision carries it forward, while a blocked prompt records no context. Absent or `separate` placement writes an independent `context/message`; `prompt-prefix` writes the context, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`, whose model-hidden envelope retains the direct prompt and context descriptors for human replay. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative. A `ContinuationDecision` reason is narrower: it becomes a `steering/message` without attached context metadata.
`PromptDecision.additionalContexts` is an array so every context keeps its own source, metadata, and placement. `SendOptions.contexts` binds the same shape to one queued message before prompt interception: the default allow decision carries it forward, while a blocked prompt records no context. Absent or `separate` placement writes an independent injected `user/message` (plugin/goal source); `prompt-prefix` writes the context, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`, whose model-hidden envelope retains the direct prompt and context descriptors for human replay. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative. A `ContinuationDecision` reason is narrower: it becomes a `steering/message` without attached context metadata.
Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md).
### Agent interface (`types.ts`)
The handle every plugin programs against:
`Agent` is a structural interface. `followup()`, `queue()`, `steer()`, and `inject()` name common caller intents; `send(ResolvedAgentInput)` exposes the same acceptance path when a caller already has exact routing facts ([decision](../../../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). Every `ResolvedAgentInput` field is mandatory, and its discriminated union excludes attached contexts from non-waking next-step injection. FIFO acceptance returns an opaque `AgentMessageId` carried by that item's `agent/inbox/enqueue`/`dequeue`/`discard` events. The driver snapshots content, resolved source, attached contexts, and model-hidden metadata as one detached, deeply frozen lossless-JSON record before notification and enqueue; invalid data throws synchronously. The helpers apply defaults: omitting `options.source` on `followup()`, `queue()`, or `steer()` attests direct human input as `{ kind: 'user' }`, so every non-human producer labels its content.
- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Omitting `options.source` attests direct human input as `{ kind: 'user' }` and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content, resolved source, and `options.contexts` become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input. After admission, separate contexts become `context/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
- `agent.steer(content, options?)`while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to `send()`. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that steering event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it.
- `agent.inject(content, options?)`accept detached in-session context without running the model; the next request sees its `context/message` with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)).
- `agent.cancel(cause?)` — cancel ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; observers may synchronize state but cannot veto cancellation. The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
- `agent.followup(content, options?)` — queue one independent FIFO message as its own turn and wake the driver. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
- `agent.queue(content, options?)`queue the same ordinary message without waking an idle driver. A lone queued item leaves `whenIdle()` resolved and rides along before the next waking message.
- `agent.steer(content, options?)`while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; while idle, create a waking ordinary turn. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it.
- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `InjectOptions` deliberately has no attached contexts. `options.meta` persists opaque JSON state without rendering it. While a turn is open the injection joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event.
- `agent.send(input)` — accept a fully specified route without helper defaults. `next-turn` targets the ordinary FIFO; `next-step` with wakeup targets steering and falls back to a waking ordinary turn while idle; `next-step` without wakeup is injection and requires `contexts: []`. Callers provide `meta: undefined` explicitly when they have no metadata.
- `agent.cancel(cause?, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
- `agent.session`, `agent.status`, `agent.options`, `agent.id`
@@ -77,7 +79,7 @@ The handle every plugin programs against:
#### What the model sees
`send`, `steer`, and `inject` feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself.
The four intent helpers and fully resolved `send` path feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself.
#### Token effect
@@ -107,6 +109,6 @@ Prefix-stable while an agent's scoped registrations are unchanged. Setup or relo
- **Ambient identity may outlive liveness** — consumers still check `agent.status`, cancellation, and the owning capability contract before lifecycle-sensitive work.
- **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam.
- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead.
- **No public step-only abort** — `cancel()` clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer ([stop-surface Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
- **`cancel()` clears the inbox by default** — it aborts the in-flight turn plus queued and steering work; `cancel(cause, { keepInbox: true })` aborts only the turn and preserves pending items. There is still no step-only abort that keeps the in-flight turn running ([stop-surface Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
- **`HookContext` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable.
- **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`).

View File

@@ -24,6 +24,27 @@ const install: InvariantInstaller = (ctx, fail) => {
}
lastStatus.set(agent, status)
}, { global: true })
// Inbox FIFO conservation: an item leaves the inbox (dequeue) or is dropped
// (discard) only after it entered (enqueue), so the live outstanding count
// per agent can never go negative. Injection bypasses the FIFOs entirely and
// never appears on these events.
const outstanding = new WeakMap<Agent, number>()
ctx.on('agent/inbox/enqueue', (agent) => {
outstanding.set(agent, (outstanding.get(agent) ?? 0) + 1)
}, { global: true })
ctx.on('agent/inbox/dequeue', (agent) => {
const count = outstanding.get(agent) ?? 0
if (count <= 0) fail('agent/inbox/dequeue without a matching prior enqueue')
outstanding.set(agent, count - 1)
}, { global: true })
ctx.on('agent/inbox/discard', (agent, items) => {
const count = outstanding.get(agent) ?? 0
if (items.length > count) {
fail(`agent/inbox/discard dropped ${items.length} items but only ${count} were outstanding`)
}
outstanding.set(agent, count - items.length)
}, { global: true })
}
/**

View File

@@ -6,6 +6,7 @@
*/
import type { Context } from 'cordis'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session'
@@ -26,8 +27,9 @@ export interface AgentOptions {
}
/**
* Message options. An omitted source attests direct human input as `{ kind: 'user' }`
* and may authorize policy consumers, so non-human producers must label their content.
* Options for {@link Agent.followup}, {@link Agent.queue}, and {@link Agent.steer}.
* An omitted source attests direct human input as `{ kind: 'user' }` and may
* authorize policy consumers, so non-human producers must label their content.
*/
export interface SendOptions {
source?: MessageSource
@@ -37,19 +39,71 @@ export interface SendOptions {
* records them directly at its next checkpoint.
*/
contexts?: HookContext[]
/** Opaque JSON state retained on the durable message but hidden from the model. */
meta?: JsonValue
}
/** Options specific to durable synthetic context injection. */
export interface InjectOptions extends Omit<SendOptions, 'contexts'> {
/** Opaque JSON state retained in the session event but hidden from the model. */
export interface InjectOptions {
/** Defaults to `{ kind: 'plugin', plugin: '' }`; non-human producers should identify themselves. */
source?: MessageSource
/** Opaque JSON state retained on the durable message but hidden from the model. */
meta?: JsonValue
}
/**
* Opaque id assigned to one accepted agent input. FIFO inputs carry the same id
* on their `agent/inbox/*` events; injection bypasses those events.
*/
export type AgentMessageId = Branded<'AgentMessageId'>
/**
* Brand a string as an {@link AgentMessageId}.
* @param id - the generated message id.
* @returns the same string, branded; no validation is performed.
*/
export function AgentMessageId(id: string): AgentMessageId {
return id as AgentMessageId
}
/**
* One accepted FIFO message, carried by the `agent/inbox/*` live events. `id`
* is the value returned by the accepting helper or {@link Agent.send},
* stable across this message's enqueue, dequeue, and discard events. Source
* defaults, when applicable, are already applied, so these are the exact values
* the item was accepted with.
* `steering` is true for an item drained between steps; otherwise it is claimed
* at a turn boundary. `SendOptions.meta` is intentionally omitted: it is durable
* model-hidden state that lands on the eventual `user/message`/
* `steering/message`, not live-event routing data.
*/
export interface AgentMessage {
/** The id returned by the accepting helper or {@link Agent.send}. */
id: AgentMessageId
content: ContentBlock[]
source: MessageSource
contexts: HookContext[]
/** Whether the item joined the steering FIFO rather than the queued FIFO. */
steering: boolean
/** Whether the item wakes the driver or requests another step. */
wakeup: boolean
}
/** Options for {@link Agent.cancel}. */
export interface CancelOptions {
/**
* Preserve queued and steering inbox items instead of discarding them. The
* active turn is still aborted, but un-started and pending work survives for a
* later turn and no `agent/inbox/discard` fires.
*/
keepInbox?: boolean
}
/**
* An agent's lifecycle state, emitted on every transition as `agent/status`:
* `idle` (parked, waiting for queued work), `running` (the driver is draining
* work and may be closing or checkpointing a turn), `disposed` (terminal — no
* transition leaves it, and `send`/`steer`/`inject` throw).
* transition leaves it, and every delivery method throws).
*/
export type AgentStatus = 'idle' | 'running' | 'disposed'
@@ -58,8 +112,8 @@ export interface HookContext {
content: ContentBlock[]
source: MessageSource
/**
* Model placement. Absent or `separate` records an independent
* `context/message`; `prompt-prefix` prepends this context and a stable
* Model placement. Absent or `separate` records an independent injected
* `user/message`; `prompt-prefix` prepends this context and a stable
* request delimiter to the same user-role message as its attached prompt.
*/
placement?: 'separate' | 'prompt-prefix'
@@ -67,6 +121,22 @@ export interface HookContext {
meta?: JsonValue
}
/**
* Fully specified input for {@link Agent.send}. Unlike the intent-named
* helpers, this form applies no defaults: callers provide content, source,
* contexts, metadata (including explicit `undefined`), target, and wakeup.
* The union excludes attached contexts from non-waking next-step injection.
*/
export type ResolvedAgentInput = {
content: ContentBlock[]
source: MessageSource
meta: JsonValue | undefined
} & (
| { target: 'next-turn'; wakeup: boolean; contexts: HookContext[] }
| { target: 'next-step'; wakeup: true; contexts: HookContext[] }
| { target: 'next-step'; wakeup: false; contexts: [] }
)
/**
* Prompt interception result. `allow.content` replaces the prompt. Each
* `additionalContexts` entry follows its declared placement: separate context
@@ -113,54 +183,90 @@ export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed
export interface Agent {
/** The single identity shared with {@link session}. */
readonly id: SessionId
/** The provider route and model this agent's requests use. */
readonly options: AgentOptions
/** The live session this agent drives; its log is the durable source of truth. */
readonly session: Session
/** The current lifecycle state, mirrored on every `agent/status` transition. */
readonly status: AgentStatus
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
readonly ctx: Context
/**
* Queue one detached, frozen lossless-JSON item. If claimed, it is the sole
* ordinary message in its FIFO-ordered turn; the next claimed item waits for
* that turn's checkpoint.
* Attached contexts share the same snapshot and ownership boundary. Invalid
* input throws synchronously before notification or enqueue.
* Queue an ordinary message as its own FIFO-ordered turn and wake the driver.
* Content, resolved source, and attached contexts are detached, validated,
* and frozen together; invalid input throws synchronously before notification
* or enqueue.
* @param content - the prompt content blocks.
* @param options - source, attached contexts, and durable model-hidden meta.
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
*/
send(content: ContentBlock[], options?: SendOptions): void
followup(content: ContentBlock[], options?: SendOptions): AgentMessageId
/**
* Submit steering while the agent is `running`. An open turn records it at
* the next steering checkpoint before a request or continuation decision;
* policy may stop before another step. After turn close and its checkpoint,
* any remainder is queued for a later turn; terminal `agent/turn-stop`,
* cancellation, or disposal may discard it. Uses the same synchronous
* snapshot-and-validation boundary as {@link send}; when idle, delegates to it.
* Queue an ordinary message without waking an idle driver. The item retains
* FIFO order and is claimed only after another input wakes the driver. A lone
* queued item leaves `whenIdle()` resolved.
* @param content - the prompt content blocks.
* @param options - source, attached contexts, and durable model-hidden meta.
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
*/
steer(content: ContentBlock[], options?: SendOptions): void
queue(content: ContentBlock[], options?: SendOptions): AgentMessageId
/**
* Submit steering into the running turn and request another step. An open turn
* records it at the next steering checkpoint before a request or continuation
* decision; policy may stop before another step. After turn close and its
* checkpoint, any remainder is queued for a later turn; terminal
* `agent/turn-stop`, cancellation, or disposal may discard it. Idle steering
* becomes a waking ordinary turn.
* @param content - the steering content blocks.
* @param options - source, attached contexts, and durable model-hidden meta.
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
*/
steer(content: ContentBlock[], options?: SendOptions): AgentMessageId
/**
* Append detached model-facing context without running the model. An open-turn
* injection joins at the current log position unless the current tool batch is
* executing; then it waits FIFO until that batch settles and drains before turn
* close even when interrupted. Idle injection uses a one-shot turn and durability
* checkpoint. Disposal awaits idle checkpoints; flush failures report through `agent/error`.
* executing; then it waits FIFO until that batch settles and drains before
* turn close even when interrupted. Idle injection uses a one-shot turn and
* durability checkpoint. Disposal awaits idle checkpoints; flush failures
* report through `agent/error`. An omitted source defaults to
* `{ kind: 'plugin', plugin: '' }`.
* @param content - the injected context content blocks.
* @param options - source and durable model-hidden meta.
* @returns the accepted injection's {@link AgentMessageId}; injection emits no `agent/inbox/*` events.
*/
inject(content: ContentBlock[], options?: InjectOptions): void
inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId
/**
* Clear all queued and steering work, including items waiting to start, and
* abort the active turn. An effective call first emits
* `agent/cancel-requested` with the resolved typed cause. The first cause wins
* for the active turn, and `whenIdle()` resolves after cancellation reaches
* quiescence. Omission means `{ kind: 'user' }`. Idle cancellation is a no-op
* and does not arm later work. The active turn snapshots and freezes the cause.
* @param cause - the stable caller intent carried by the current turn signal.
* Accept one fully specified input through the same snapshot and routing path
* as the four intent-named helpers. `next-turn` targets the ordinary FIFO;
* `next-step`/wakeup targets steering (falling back to an ordinary waking turn
* while idle); and `next-step` without wakeup injects durable context without
* running the model. Every field is mandatory and no source or routing default
* is applied. Invalid input throws synchronously before notification, enqueue,
* or append.
* @param input - the resolved content, attribution, context, metadata, and routing facts.
* @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable.
*/
cancel(cause?: AgentCancelCause): void
send(input: ResolvedAgentInput): AgentMessageId
/**
* Clear queued and steering work — unless `keepInbox` — and abort the active
* turn. An effective call first emits `agent/cancel-requested` with the
* resolved typed cause. The first cause wins for the active turn, and
* `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause
* means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm
* later work. The active turn snapshots and freezes the cause.
* @param cause - the stable caller intent carried by the current turn signal.
* @param options - cancellation options; `keepInbox` preserves pending work.
*/
cancel(cause?: AgentCancelCause, options?: CancelOptions): void
/** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */
whenIdle(): Promise<void>
}
declare module 'cordis' {
@@ -187,8 +293,8 @@ declare module 'cordis' {
*/
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
/**
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does
* not enter `running` synchronously; drive lifecycle from this event.
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking
* delivery does not enter `running` synchronously; drive lifecycle from this event.
* @param agent - the agent whose status flipped.
* @param status - the status just entered (the transition's destination).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
@@ -196,15 +302,42 @@ declare module 'cordis' {
*/
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
/**
* Detached, frozen content entered the agent's inbox. Source defaults have
* already been applied, so these are the exact values retained for the log.
* @param agent - the agent whose inbox received the message.
* @param content - the accepted content blocks retained by the inbox.
* @param info - the accepted source, contexts, and whether it entered as steering.
* A detached, frozen item entered the agent's inbox (queued or steering
* FIFO). Source defaults are already applied, so `message` holds the exact
* accepted values. This is the enqueue-time live signal; the durable record
* is the eventual `user/message`/`steering/message`. Injection through
* `agent.inject()` or equivalent `send()` routing bypasses the FIFOs
* and does not emit this.
* @param agent - the agent whose inbox received the item.
* @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void
'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void
/**
* The driver claimed one item out of the inbox: a queued item at a turn
* boundary, or steering drained between steps. Fires after the item leaves
* its FIFO and before it becomes a durable message.
* @param agent - the agent whose inbox item was claimed.
* @param message - the claimed message (matching the `id` from its `agent/inbox/enqueue`).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/dequeue'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void
/**
* Pending inbox items were dropped without delivering them, so every
* enqueued id receives exactly one terminal `agent/inbox/dequeue` OR
* `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after
* `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop`
* dropping pending steering (in-turn and on the post-turn late-steering
* drain); and disposal of any still-pending items (before
* `agent/status('disposed')`). Fires once per drop with every dropped item.
* @param agent - the agent whose inbox items were dropped.
* @param messages - the discarded messages in FIFO order (queued then steering); never empty.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/discard'(this: Scoped<Agent>, agent: Agent, messages: AgentMessage[]): void
/**
* Effective broad cancellation was requested, before queued/steering work
* is cleared or the active turn is aborted. This observe-only notification

View File

@@ -3,13 +3,24 @@ import { Context, Service, symbols } from 'cordis'
import type { Events } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, {
AgentMessageId,
agentEvents,
agentInterruptReasonOf,
} from '@deepseek-ai/dsh-agent'
import type { Agent, AgentCancelCause, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
import type {
Agent,
AgentCancelCause,
AgentFactory,
ContinuationStop,
CreateAgentOptions,
InjectOptions,
ResolvedAgentInput,
ResumeAgentOptions,
SendOptions,
} from '@deepseek-ai/dsh-agent'
function stubAgent(rawId: string): Agent {
function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
const id = SessionId(rawId)
return {
id,
@@ -17,15 +28,34 @@ function stubAgent(rawId: string): Agent {
session: new Session(id),
status: 'idle',
ctx: new Context(),
send() {},
steer() {},
inject() {},
followup: () => AgentMessageId('stub'),
queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject: () => AgentMessageId('stub'),
send: () => AgentMessageId('stub'),
cancel() {},
whenIdle() { return Promise.resolve() },
...overrides,
}
}
describe('AgentRegistry', () => {
it('keeps helper options semantic and makes advanced input fully specified', () => {
type OptionalInputKey = {
[Key in keyof ResolvedAgentInput]-?: Record<never, never> extends Pick<ResolvedAgentInput, Key>
? Key
: never
}[keyof ResolvedAgentInput]
expectTypeOf<'target' extends keyof SendOptions ? true : false>().toEqualTypeOf<false>()
expectTypeOf<'wakeup' extends keyof SendOptions ? true : false>().toEqualTypeOf<false>()
expectTypeOf<'contexts' extends keyof InjectOptions ? true : false>().toEqualTypeOf<false>()
expectTypeOf<Parameters<Agent['send']>[0]>().toEqualTypeOf<ResolvedAgentInput>()
expectTypeOf<OptionalInputKey>().toEqualTypeOf<never>()
expectTypeOf<Extract<ResolvedAgentInput, { target: 'next-step'; wakeup: false }>['contexts']>()
.toEqualTypeOf<[]>()
})
it('allows terminal stop policy to cooperate asynchronously with turn cancellation', () => {
type TurnStopListener = Events['agent/turn-stop']
type AsyncTurnStopListener = () => Promise<ContinuationStop | undefined>
@@ -56,7 +86,7 @@ describe('AgentRegistry', () => {
it('rejects an agent whose registry and session identities differ', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const agent = { ...stubAgent('agent-id'), session: new Session(SessionId('session-id')) }
const agent = stubAgent('agent-id', { session: new Session(SessionId('session-id')) })
expect(() => ctx.agents.enter(agent, undefined))
.toThrow('agent id "agent-id" does not match session id "session-id"')

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import InvariantService from '@deepseek-ai/dsh-invariants'
@@ -56,3 +56,41 @@ describe('agent status invariants', () => {
expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow()
})
})
describe('agent inbox invariants', () => {
const info = (steering: boolean) => ({ id: AgentMessageId('m'), content: [], source: { kind: 'user' as const }, contexts: [], steering, wakeup: true })
it('accepts a dequeue and a discard covered by prior enqueues', async () => {
const ctx = await setup()
const agent = mockAgent('i1')
const at = scopeTarget(agent, agent)
expect(() => {
ctx.emit(at, 'agent/inbox/enqueue', agent, info(false))
ctx.emit(at, 'agent/inbox/enqueue', agent, info(true))
ctx.emit(at, 'agent/inbox/dequeue', agent, info(false))
ctx.emit(at, 'agent/inbox/discard', agent, [info(true)])
}).not.toThrow()
})
it('rejects a dequeue with no outstanding item', async () => {
const ctx = await setup()
const agent = mockAgent('i2')
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/dequeue', agent, info(false)) })
.toThrow(/without a matching prior enqueue/)
})
it('rejects a discard larger than the outstanding count', async () => {
const ctx = await setup()
const agent = mockAgent('i3')
const at = scopeTarget(agent, agent)
ctx.emit(at, 'agent/inbox/enqueue', agent, info(false))
expect(() => { ctx.emit(at, 'agent/inbox/discard', agent, [info(false), info(true)]) })
.toThrow(/dropped 2 items but only 1 were outstanding/)
})
it('accepts an empty discard against a fresh agent', async () => {
const ctx = await setup()
const agent = mockAgent('i4')
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/discard', agent, []) }).not.toThrow()
})
})

View File

@@ -12,10 +12,12 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu
'agent/created': args => args[0],
'agent/disposed': args => args[0],
'agent/error': args => args[0],
'agent/inbox/dequeue': args => args[0],
'agent/inbox/discard': args => args[0],
'agent/inbox/enqueue': args => args[0],
'agent/post-step': args => args[0],
'agent/pre-step': args => args[0],
'agent/prompt-submit': args => args[0],
'agent/queued': args => args[0],
'agent/request': args => args[0],
'agent/request-error': args => args[0],
'agent/session-prefix': args => args[0],

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Events } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import * as ScopeInvariant from '@deepseek-ai/dsh-scope/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
@@ -42,7 +42,9 @@ describe('scoped-dispatch invariants', () => {
'agent/created': [agent],
'agent/disposed': [agent],
'agent/status': [agent, 'idle'],
'agent/queued': [agent, [], { source: { kind: 'user' }, contexts: [], steering: false }],
'agent/inbox/enqueue': [agent, { id: AgentMessageId('m'), content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }],
'agent/inbox/dequeue': [agent, { id: AgentMessageId('m'), content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }],
'agent/inbox/discard': [agent, []],
'agent/cancel-requested': [agent, { kind: 'user' }],
'agent/session-start': [agent, 'startup'],
'agent/pre-step': [agent, 1, 1, signal],

View File

@@ -64,7 +64,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. `messagePrefix` remains separate from derived history. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
`context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. A `user/message` or `steering/message` with prompt-prefix context keeps the exact combined model bytes in `content` and stores a model-hidden `envelope` containing the direct `displayContent` and prefix context source/metadata descriptors. `displayPromptContent()` selects the human-facing prompt without changing derived history.
A `user/message` renders its `content` verbatim as a user-role message whether it is a direct human prompt (`user` source), a synthetic injection (`plugin`/`goal` source), or an admitted goal round — `source` is the only channel that tells them apart. It may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. A `user/message` or `steering/message` with prompt-prefix context keeps the exact combined model bytes in `content` and stores a model-hidden `envelope` containing the direct `displayContent` and prefix context source/metadata descriptors. `displayPromptContent()` selects the human-facing prompt without changing derived history.
`tool/result` persists the model-facing content, optional internal failure identity, and optional presentation metadata. A tool's successful canonical `value` and human-readable canonical failure message remain execution-local; rendered error content is the replay-authoritative message. This preserves the existing event shape and does not change `SESSION_FORMAT_VERSION`.
@@ -99,7 +99,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
#### What the model sees
The model receives projections of `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` surface entries verbatim: each is a user- or assistant-role message carrying its content blocks unchanged. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
The model receives projections of `user/message`, `assistant/message`, `tool/result`, and `steering/message` surface entries verbatim: each is a user- or assistant-role message carrying its content blocks unchanged. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
#### Token effect

View File

@@ -129,8 +129,10 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
if (record.id !== id) {
throw new Error(`session header id "${String(record.id)}" does not match session id "${id}"`)
}
if (typeof record.createdAt !== 'number' || !Number.isFinite(record.createdAt)) {
throw new Error('session header createdAt must be a finite number')
if (typeof record.createdAt !== 'number'
|| !Number.isSafeInteger(record.createdAt)
|| record.createdAt < 0) {
throw new Error('session header createdAt must be a non-negative safe integer')
}
if (record.cwd !== undefined) {
if (typeof record.cwd !== 'string') throw new Error('session header cwd must be a string')
@@ -532,10 +534,10 @@ export class Session {
// trace/replay data.
switch (event.type) {
// Injected context, ordinary prompts, and mid-turn steering project
// Ordinary prompts, injected context, and mid-turn steering project
// identically in user role: the event's model-facing content stays
// verbatim. A prompt envelope is model-hidden display metadata; its
// prefix bytes are already present in content. context's `source`/`meta`
// prefix bytes are already present in content. The message's `source`/`meta`
// and steering's `turn` are also log-only. Do NOT
// re-add per-type framing (e.g. `<context>`/`<steering>`) here: framing is
// caller-owned — a producer bakes it into `content`, as workspace-context
@@ -544,7 +546,6 @@ export class Session {
// verbatim pass-through. See the deferred design note in
// ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md
case 'user/message':
case 'context/message':
case 'steering/message': {
return { role: 'user', content: event.data.content }
}

View File

@@ -15,14 +15,13 @@ const SURFACE_EVENT_TYPES = new Set<string>([
'user/message',
'assistant/message',
'tool/result',
'context/message',
'steering/message',
])
/**
* Whether an event type can join the model-visible surface.
* @param type - event type to test.
* @returns true for one of the five message-producing event types.
* @returns true for one of the four message-producing event types.
*/
export function isSurfaceEligibleType(type: string): boolean {
return SURFACE_EVENT_TYPES.has(type)

View File

@@ -36,7 +36,7 @@ export interface SessionHeader {
readonly version: number
/** The session's id (mirrors the {@link Session}'s id). */
readonly id: SessionId
/** Unix epoch milliseconds when the session was created. */
/** Non-negative safe-integer Unix epoch milliseconds when the session was created. */
readonly createdAt: number
/** Absolute working directory the session was created in (if any). */
readonly cwd?: string
@@ -84,11 +84,12 @@ export interface TurnTriggerMap {
message: { kind: 'message'; source: MessageSource }
/**
* An out-of-band context injection (`agent.inject()`) made while the agent
* was idle. The loop wraps the injected `context/message` in a one-shot turn
* (`turn/start` → `context/message` → `turn/end`) so every event in the log
* stays turn-enclosed — the durability/replay boundary is the turn, and a
* bare event between turns would otherwise be indistinguishable from a crash
* tail on reload.
* was idle. The loop wraps the injected `user/message` (a non-`user` source,
* plugin by default) in a one-shot turn (`turn/start` → `user/message` →
* `turn/end`) so every event in the log stays turn-enclosed — the
* durability/replay boundary is the turn, and a bare event between turns would
* otherwise be indistinguishable from a crash tail on reload. The trigger's
* `source` mirrors that message's producer.
*/
injection: { kind: 'injection'; source: MessageSource }
}
@@ -201,7 +202,13 @@ export interface PromptMessageEnvelope {
prefixContexts: PromptPrefixContext[]
}
/** Shared payload for ordinary and steering prompt messages. */
/**
* Shared payload for user, injected-context, and steering prompt messages. A
* direct human prompt, a synthetic `agent.inject()` context, and mid-turn
* steering all project into the model transcript as verbatim user-role content;
* they are told apart by `source` (a non-`user` kind marks injected context),
* not by event type. `meta` carries durable model-hidden producer state.
*/
export interface PromptMessageData {
/** Exact model-facing blocks, including any baked prompt-prefix contexts. */
content: ContentBlock[]
@@ -209,6 +216,15 @@ export interface PromptMessageData {
source: MessageSource
/** Present only when prompt-prefix contexts were baked into `content`. */
envelope?: PromptMessageEnvelope
/**
* Opaque durable JSON state retained on the event but hidden from the model
* projection. It is the intended channel for a future framing directive (a
* producer declares the frame, a dedicated renderer applies it — see the
* deferred note in
* ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md),
* so the surface keeps projecting `content` verbatim rather than wrapping it.
*/
meta?: JsonValue
}
/**
@@ -236,29 +252,21 @@ export interface SessionEventMap {
'step/start': { turn: number; step: number }
/** Closes step `step` of turn `turn`. */
'step/end': { turn: number; step: number }
/** A user-visible prompt (the queued message claimed for this turn). */
/**
* A user-role message on the model-visible surface: a direct human prompt
* (the queued message claimed for this turn), a synthetic `agent.inject()`
* context (file-change notices, subdir AGENTS.md, skill content, cron
* notifications, …), or an admitted goal continuation round. All three
* project their `content` verbatim; `source` (with a non-`user` kind marking
* injected context) is the only channel that tells them apart. An idle
* injection wraps this event in a one-shot turn so the log stays turn-enclosed.
*/
'user/message': PromptMessageData
/**
* Durable record of a prompt veto and its reason. It is log-only: the blocked
* prompt never enters the model-visible surface, and its turn runs zero steps.
*/
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
/**
* In-session context injection (file-change notices, subdir AGENTS.md,
* skill content, cron notifications, …). Rendered into the derived history
* as a synthetic user-role message carrying `content` verbatim — NOT a
* user prompt. `meta` is durable JSON state omitted from the model
* projection; it is also the intended channel for any future framing
* directive (a producer declares the frame, a dedicated renderer applies it —
* see the deferred note in
* ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md),
* so the surface keeps projecting `content` verbatim rather than wrapping it.
*/
'context/message': {
content: ContentBlock[]
source: MessageSource
meta?: JsonValue
}
/** Raw stream chunk — token-level replay fidelity. */
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
/**
@@ -331,7 +339,6 @@ export type SurfaceEventType =
| 'user/message'
| 'assistant/message'
| 'tool/result'
| 'context/message'
| 'steering/message'
/**
@@ -349,7 +356,7 @@ export type SurfaceEvent = SessionEvent<SurfaceEventType> & { surfaceOp: Surface
* How a session event entered the ordered surface. Only valid on
* {@link SurfaceEventType} events.
*
* - `'append'`: added to the tail — normal path for user/assistant/tool/context
* - `'append'`: added to the tail — normal path for user/assistant/tool/steering
* messages.
* - `{ op: 'replace', start, end }`: replaces surface nodes from `start`
* (inclusive) through `end` (inclusive) with this node. Both must exist as
@@ -384,7 +391,7 @@ export interface SurfaceIntent {
*
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
* they only exist on {@link SurfaceEventType} variants (`user/message`,
* `assistant/message`, `tool/result`, `context/message`, `steering/message`).
* `assistant/message`, `tool/result`, `steering/message`).
* Non-surface events (boundary markers, chunks, usage, errors) never carry
* surface metadata — the compiler enforces this at `Session.append()`
* call sites.

View File

@@ -38,7 +38,7 @@ describe('derived-message cache', () => {
expect(beforeReplace).toHaveLength(2)
const nodes = session.surface.nodes
session.append('context/message', {
session.append('user/message', {
content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })

View File

@@ -62,7 +62,7 @@ describe('Session', () => {
turn: 1,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } },
})
session.append('context/message', {
session.append('user/message', {
content: [{ type: 'text', text: 'before' }],
source: { kind: 'plugin', plugin: 'before' },
}, { surfaceOp: 'append' })
@@ -82,7 +82,7 @@ describe('Session', () => {
turn: 3,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } },
})
session.append('context/message', {
session.append('user/message', {
content: [{ type: 'text', text: 'after' }],
source: { kind: 'plugin', plugin: 'after' },
}, { surfaceOp: 'append' })
@@ -117,9 +117,9 @@ describe('Session', () => {
.toThrow('seed turn/end at index 1 uses unsupported reason-bearing aborted format')
})
it('renders context and steering messages as plain user content', () => {
it('renders injected-context and steering messages as plain user content', () => {
const session = new Session(SessionId('s2'))
session.append('context/message', {
session.append('user/message', {
content: [{ type: 'text', text: 'file changed: a.ts' }],
source: { kind: 'plugin', plugin: 'watcher' },
}, { surfaceOp: 'append' })
@@ -172,7 +172,7 @@ describe('Session', () => {
version: 1,
changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 'abc123' }],
}
session.append('context/message', {
session.append('user/message', {
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
source: { kind: 'plugin', plugin: 'workspace-context' },
meta,
@@ -183,7 +183,7 @@ describe('Session', () => {
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
}])
const event = session.events[0]
expect(event?.type === 'context/message' && event.data.meta).toEqual(meta)
expect(event?.type === 'user/message' && event.data.meta).toEqual(meta)
})
it('replays identically from a seeded event log', () => {
@@ -757,7 +757,7 @@ describe('Session', () => {
{ header: 1, error: /not a plain JSON record/ },
{ header: null, error: /not a plain JSON record/ },
{ header: { ...base, version: 1 }, error: /header version/ },
{ header: { ...base, createdAt: '123' }, error: /createdAt must be a finite number/ },
{ header: { ...base, createdAt: '123' }, error: /createdAt must be a non-negative safe integer/ },
{ header: { ...base, cwd: 1 }, error: /header cwd must be a string/ },
{ header: { ...base, cwd: 'relative' }, error: /header cwd must be an absolute path/ },
{ header: { ...base, parentSession: 1 }, error: /header parentSession must be a string/ },
@@ -962,7 +962,7 @@ describe('SessionStore', () => {
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('plain'))
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'plain' })
expect(typeof session.header.createdAt).toBe('number')
expect(Number.isSafeInteger(session.header.createdAt)).toBe(true)
expect(session.header.cwd).toBeUndefined()
expect(session.header.parentSession).toBeUndefined()
})
@@ -1001,7 +1001,10 @@ describe('SessionStore', () => {
{ meta: { parentSession: 1n }, error: /header is not losslessly JSON-serializable/ },
{ meta: { cwd: 1 }, error: /header cwd must be a string/ },
{ meta: { parentSession: 1 }, error: /header parentSession must be a string/ },
{ meta: { createdAt: '123' }, error: /header createdAt must be a finite number/ },
{ meta: { createdAt: '123' }, error: /header createdAt must be a non-negative safe integer/ },
{ meta: { createdAt: 1.5 }, error: /header createdAt must be a non-negative safe integer/ },
{ meta: { createdAt: -1 }, error: /header createdAt must be a non-negative safe integer/ },
{ meta: { createdAt: Number.MAX_SAFE_INTEGER + 1 }, error: /header createdAt must be a non-negative safe integer/ },
{ meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },

View File

@@ -444,9 +444,9 @@ describe('deriveMessages with surface', () => {
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'compacted' })
})
it('context/message and steering/message appear on surface', () => {
it('injected-context and steering/message appear on surface', () => {
const s = new Session(SessionId('ctx'))
s.append('context/message', { content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' } }, { surfaceOp: 'append' })
s.append('user/message', { content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' } }, { surfaceOp: 'append' })
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'focus' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const messages = s.deriveMessages()
expect(messages).toHaveLength(2)
@@ -524,7 +524,6 @@ describe('surface type guards', () => {
expect(isSurfaceEligibleType('user/message')).toBe(true)
expect(isSurfaceEligibleType('assistant/message')).toBe(true)
expect(isSurfaceEligibleType('tool/result')).toBe(true)
expect(isSurfaceEligibleType('context/message')).toBe(true)
expect(isSurfaceEligibleType('steering/message')).toBe(true)
expect(isSurfaceEligibleType('turn/start')).toBe(false)
expect(isSurfaceEligibleType('assistant/chunk')).toBe(false)
@@ -568,7 +567,7 @@ describe('SurfaceManager.replaceGeneration', () => {
expect(s.surface.replaceGeneration).toBe(0)
const nodes = s.surface.nodes
s.append('context/message', {
s.append('user/message', {
content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
expect(s.surface.replaceGeneration).toBe(1)

View File

@@ -235,7 +235,7 @@ describe('dsh-agent-spine-demo bundle', () => {
agentOptions: { provider: 'mock', model: 'mock' },
})
handle.agent.send([{ type: 'text', text: 'recover' }])
handle.agent.followup([{ type: 'text', text: 'recover' }])
await waitForIdle(ctx, handle.agent)
expect(adapter.requests).toBe(2)
@@ -335,7 +335,7 @@ describe('dsh-agent-spine-demo bundle', () => {
})
const agent = handle.agent
agent.send([{ type: 'text', text: 'hi' }])
agent.followup([{ type: 'text', text: 'hi' }])
await waitForIdle(ctx, agent)
const sentText = adapter.requests[0]?.messages.map(messageText).join('\n')
@@ -364,7 +364,7 @@ describe('dsh-agent-spine-demo bundle', () => {
agentOptions: { provider: 'mock', model: 'mock' },
})
handle.agent.send([{ type: 'text', text: 'hi' }])
handle.agent.followup([{ type: 'text', text: 'hi' }])
await waitForIdle(ctx, handle.agent)
expect(adapter.requests[0]?.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }])
@@ -454,7 +454,7 @@ describe('dsh-agent-spine-demo bundle', () => {
agentOptions: { provider: 'mock', model: 'mock' },
})
handle.agent.send([{ type: 'text', text: 'hi' }])
handle.agent.followup([{ type: 'text', text: 'hi' }])
await waitForIdle(ctx, handle.agent)
expect(messageText(adapter.requests[0]?.messages[0])).toContain('workspace rule before skills')

View File

@@ -290,7 +290,7 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
try {
/* v8 ignore next -- skips send only when cancellation wins the listener-registration race above */
if (!settled) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
agent.send([{ type: 'text', text: options.task }])
agent.followup([{ type: 'text', text: options.task }])
}
await turnEnded
} finally {

View File

@@ -369,7 +369,7 @@ describe('runOneShot and executeCli', () => {
const { ctx, agent } = await harness([textResponse('streamed')])
const other = ctx.sessions.create(SessionId('unrelated'))
let injected = false
ctx.on('agent/queued', (subject) => {
ctx.on('agent/inbox/enqueue', (subject) => {
if (subject !== agent || injected) return
injected = true
agent.inject([{ type: 'text', text: 'startup injection' }], { source: { kind: 'plugin', plugin: 'test' } })
@@ -383,7 +383,7 @@ describe('runOneShot and executeCli', () => {
expect(events[0]).toMatchObject({ type: 'turn/start', data: { turn: 2, trigger: { kind: 'message' } } })
expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 2 } })
expect(lines.slice(0, -1).every(line => line['sessionId'] === agent.session.id)).toBe(true)
expect(events.some(event => event.type === 'context/message')).toBe(false)
expect(events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false)
})
it('emits partial data and a diagnostic for non-completed turns', async () => {
@@ -471,7 +471,7 @@ describe('runOneShot and executeCli', () => {
startup.ctx.on('session/event', (session, event) => {
if (session === startup.agent.session && event.type === 'assistant/chunk') started()
})
startup.agent.send([{ type: 'text', text: 'first' }])
startup.agent.followup([{ type: 'text', text: 'first' }])
await running
const startupAbort = new AbortController()
const waiting = runOneShot(startup.ctx, { task: 'second', signal: startupAbort.signal })
@@ -481,7 +481,7 @@ describe('runOneShot and executeCli', () => {
const queued = await harness([textResponse('unused')])
const queuedAbort = new AbortController()
queued.ctx.on('agent/queued', (agent) => {
queued.ctx.on('agent/inbox/enqueue', (agent) => {
if (agent === queued.agent) queuedAbort.abort('cancel queued')
})
await expect(runOneShot(queued.ctx, { task: 'task', signal: queuedAbort.signal })).rejects.toThrow('cancel queued')

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { join } from 'node:path'
import type { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
@@ -60,7 +61,7 @@ describe('dsh-tui-demo app', () => {
])
expect(calls[0]?.config).toBeUndefined()
expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' })
expect(calls[4]?.config).toEqual({ path: '/tmp/tui-sessions/session-query.db' })
expect(calls[4]?.config).toEqual({ path: join('/tmp/tui-sessions', 'session-query.db') })
expect(calls[5]?.config).toEqual({
maxReferences: 2,
candidateLimit: 7,

View File

@@ -36,7 +36,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () =>
// (config.cwd = workdir) is the workspace.
const agent = ctx.agentLoop.create(SessionId('fs-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
agent.send([{ type: 'text', text:
agent.followup([{ type: 'text', text:
'Create a file named note.txt containing exactly the line: status: draft. '
+ 'Then read it back, then edit it to replace the literal word draft with final. '
+ 'Tell me when done.' }])
@@ -68,7 +68,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () =>
meta: { cwd: sessionDir },
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
})
handle.agent.send([{ type: 'text', text:
handle.agent.followup([{ type: 'text', text:
'Use the write tool to create a file named where.txt containing exactly the line: here. Tell me when done.' }])
await waitForIdle(ctx, handle.agent)

View File

@@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, sep } from 'node:path'
import { join, resolve, sep } from 'node:path'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
@@ -729,13 +729,13 @@ describe('sandbox escalation surface (write/edit)', () => {
it('a plain write stamps the default mode with the calling session root', async () => {
const { ctx, fs } = await setupConfining()
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: '/session-project' }])
expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: resolve('/session-project') }])
})
it('a standing session override folds onto the stamp', async () => {
const { ctx, fs } = await setupConfining()
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent([{ type: 'sandbox/mode', data: { mode: 'read-only' } }]))
expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: '/session-project' }])
expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: resolve('/session-project') }])
})
it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => {
@@ -768,7 +768,7 @@ describe('sandbox escalation surface (write/edit)', () => {
agent: escalationAgent() as never,
signal: new AbortController().signal,
})
expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: '/session-project' }])
expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: resolve('/session-project') }])
})
it('a rejected escalation fails closed with its own text and never mutates', async () => {

View File

@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import GoalService from '@deepseek-ai/dsh-goal'
@@ -27,10 +27,10 @@ function nextTurn(session: Session): number {
/** Append one idle injection using the public Agent contract's balanced shape. */
function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void {
const source: MessageSource = options?.source ?? { kind: 'user' }
const source: MessageSource = options?.source ?? { kind: 'plugin', plugin: '' }
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('context/message', {
session.append('user/message', {
content,
source,
...options?.meta === undefined ? {} : { meta: options.meta },
@@ -48,9 +48,11 @@ function stubAgent(id: string): { agent: Agent; session: Session } {
session,
ctx: new Context(),
get status() { return status },
send() {},
steer() {},
inject(content, options) { appendInjection(session, content, options) },
followup: () => AgentMessageId('stub'),
queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject(content, options) { appendInjection(session, content, options); return AgentMessageId('stub') },
send: () => AgentMessageId('stub'),
cancel() { status = 'idle' },
whenIdle() { return Promise.resolve() },
}
@@ -125,7 +127,7 @@ describe('/goal human command', () => {
expect(created.text).toContain('Rounds: 0/256')
expect(created.text).toContain('Activation: armed')
expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release')
expect(test.session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end'])
expect(test.session.events.map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end'])
const count = test.session.events.length
await expect(run(test, ' replacement')).resolves.toEqual({

View File

@@ -219,7 +219,7 @@ export function apply(ctx: Context): void {
}
state.attempt = reservation
try {
agent.send(content, {
agent.followup(content, {
source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round },
})
} catch (error: unknown) {
@@ -306,10 +306,10 @@ export function apply(ctx: Context): void {
requestDrive(state)
}
})
ctx.on('agent/queued', (agent, content, info) => {
ctx.on('agent/inbox/enqueue', (agent, info) => {
const state = stateFor(agent)
const attempt = state.attempt
if (attempt !== undefined && sameQueued(content, info.source, attempt)) return
if (attempt !== undefined && sameQueued(info.content, info.source, attempt)) return
state.competingQueued = true
if (attempt?.phase === 'queued') attempt.stale = true
})

View File

@@ -7,7 +7,7 @@ import type { GoalView } from '@deepseek-ai/dsh-goal'
* Render the complete goal-round instruction retained in session history.
* @param goal - exact active goal revision being admitted.
* @param round - next positive round number.
* @returns a fresh one-block prompt for `Agent.send()`.
* @returns a fresh one-block prompt for `Agent.followup()`.
*/
export function renderGoalRoundPrompt(goal: GoalView, round: number): ContentBlock[] {
return [{

View File

@@ -207,7 +207,9 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(2)
const rounds: number[] = []
for (const event of test.agent.session.events) {
if (event.type === 'user/message' && event.data.source.kind === 'goal') {
// Round zero is a durable goal state change; positive rounds are the
// admitted continuation prompts this test counts.
if (event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0) {
rounds.push(event.data.source.round)
}
}
@@ -274,7 +276,7 @@ describe('same-session goal driving', () => {
? Promise.resolve({ kind: 'block', reason: 'stop this round' })
: next())
test.ctx.on('goal/changed', (agent, change) => {
if (change.operation === 'block') agent.send([{ type: 'text', text: 'inspect the blocker' }])
if (change.operation === 'block') agent.followup([{ type: 'text', text: 'inspect the blocker' }])
})
test.ctx.goals.create(test.agent, { objective: 'stop and inspect' })
@@ -287,7 +289,7 @@ describe('same-session goal driving', () => {
it('pauses and drops a reserved round when cancellation lands before admission', async () => {
const test = await harness([])
const cancel = test.ctx.on('agent/queued', (agent, _content, info) => {
const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent === test.agent && info.source.kind === 'goal') {
cancel()
agent.cancel({ kind: 'user' })
@@ -299,8 +301,10 @@ describe('same-session goal driving', () => {
expect(goal).toMatchObject({ roundsStarted: 0, activation: 'disarmed' })
expect(test.adapter.requests).toHaveLength(0)
// No admitted continuation round (positive round); goal state changes
// (round zero) are expected in the log.
expect(test.agent.session.events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'goal')).toBe(false)
&& event.data.source.kind === 'goal' && event.data.source.round > 0)).toBe(false)
})
it('pauses an admitted round when cancellation aborts an active step', async () => {
@@ -319,7 +323,7 @@ describe('same-session goal driving', () => {
it('lets already-queued human work finish before reserving the next round', async () => {
const test = await harness([textResponse('human answer'), textResponse('goal answer')])
test.ctx.goals.create(test.agent, { objective: 'continue after the human', maxGoalRounds: 1 })
test.agent.send([{ type: 'text', text: 'human goes first' }])
test.agent.followup([{ type: 'text', text: 'human goes first' }])
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
@@ -334,7 +338,7 @@ describe('same-session goal driving', () => {
const warnings: string[] = []
test.ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof test.ctx.logger.warn
let inserted = false
test.ctx.on('agent/queued', (agent, _content, info) => {
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return
inserted = true
const lastStart = agent.session.events.findLast(event => event.type === 'turn/start')
@@ -357,10 +361,10 @@ describe('same-session goal driving', () => {
it('makes a reserved round stale when a listener queues human work behind it', async () => {
const test = await harness([textResponse('human batch'), textResponse('later goal')])
let inserted = false
test.ctx.on('agent/queued', (agent, _content, info) => {
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return
inserted = true
agent.send([{ type: 'text', text: 'human joined the pending batch' }])
agent.followup([{ type: 'text', text: 'human joined the pending batch' }])
})
test.ctx.goals.create(test.agent, { objective: 'yield to nested human input', maxGoalRounds: 1 })
@@ -375,7 +379,7 @@ describe('same-session goal driving', () => {
it('blocks a queued reservation made stale by a goal edit and continues the new revision', async () => {
const test = await harness([textResponse('new revision')])
let edited = false
test.ctx.on('agent/queued', (agent, _content, info) => {
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent !== test.agent || info.source.kind !== 'goal' || edited) return
edited = true
const current = test.ctx.goals.get(agent)
@@ -391,7 +395,7 @@ describe('same-session goal driving', () => {
expect(blocked?.type === 'prompt/blocked' ? blocked.data.reason : undefined)
.toBe('stale goal-round reservation')
const admitted = test.agent.session.events.find(event => event.type === 'user/message'
&& event.data.source.kind === 'goal')
&& event.data.source.kind === 'goal' && event.data.source.round > 0)
expect(admitted?.type === 'user/message' && admitted.data.source.kind === 'goal'
? admitted.data.source.revision
: undefined).toBe(2)
@@ -475,10 +479,16 @@ describe('same-session goal driving', () => {
expect(injectedTurn).toBeGreaterThan(goalTurn)
})
it('blocks the goal when a custom agent rejects the otherwise valid send', async () => {
it('blocks the goal when a custom agent rejects the otherwise valid follow-up', async () => {
const test = await harness([])
vi.spyOn(test.agent, 'send').mockImplementationOnce(() => {
throw new Error('queue rejected')
// Reject only the goal-sourced round follow-up, not the state-change injection
// that precedes it.
const realFollowup = test.agent.followup.bind(test.agent)
vi.spyOn(test.agent, 'followup').mockImplementation((content, options) => {
if (options?.source?.kind === 'goal') {
throw new Error('queue rejected')
}
return realFollowup(content, options)
})
test.ctx.goals.create(test.agent, { objective: 'handle queue failure' })
@@ -492,11 +502,15 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(0)
})
it('preserves a custom agent side effect when send disarms before throwing', async () => {
it('preserves a custom agent side effect when followup disarms before throwing', async () => {
const test = await harness([])
vi.spyOn(test.agent, 'send').mockImplementationOnce(() => {
test.ctx.goals.disarm(test.agent)
throw new Error('queue rejected after disarm')
const realFollowup = test.agent.followup.bind(test.agent)
vi.spyOn(test.agent, 'followup').mockImplementation((content, options) => {
if (options?.source?.kind === 'goal') {
test.ctx.goals.disarm(test.agent)
throw new Error('queue rejected after disarm')
}
return realFollowup(content, options)
})
test.ctx.goals.create(test.agent, { objective: 'preserve the newer activation state' })
@@ -554,7 +568,7 @@ describe('same-session goal driving', () => {
it('fails a pre-admission read closed even when the first disarm attempt throws', async () => {
const test = await harness([textResponse('retry after containment')])
let armed = true
test.ctx.on('agent/queued', (agent, _content, info) => {
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent !== test.agent || info.source.kind !== 'goal' || !armed) return
armed = false
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => {
@@ -595,7 +609,7 @@ describe('same-session goal driving', () => {
it('blocks forged goal attribution without touching an absent reservation', async () => {
const test = await harness([])
test.agent.send([{ type: 'text', text: 'forged automatic work' }], {
test.agent.followup([{ type: 'text', text: 'forged automatic work' }], {
source: { kind: 'goal', goalId: GoalId('forged-goal'), revision: 1, round: 1 },
})
await test.agent.whenIdle()
@@ -607,7 +621,7 @@ describe('same-session goal driving', () => {
it('does not invent goal state when ordinary queued work is cancelled', async () => {
const test = await harness([])
test.agent.send([{ type: 'text', text: 'cancel ordinary work' }])
test.agent.followup([{ type: 'text', text: 'cancel ordinary work' }])
test.agent.cancel({ kind: 'user' })
await test.agent.whenIdle()
@@ -617,7 +631,7 @@ describe('same-session goal driving', () => {
it('disarms without durably pausing when cancellation belongs to unrelated human work', async () => {
const test = await harness(['hang'])
test.agent.send([{ type: 'text', text: 'inspect something first' }])
test.agent.followup([{ type: 'text', text: 'inspect something first' }])
await waitForRequests(test.adapter, 1)
const created = test.ctx.goals.create(test.agent, { objective: 'continue after inspection' })
@@ -635,7 +649,7 @@ describe('same-session goal driving', () => {
it('falls back to disarming when a cancelled reservation cannot be paused', async () => {
const test = await harness([])
const cancel = test.ctx.on('agent/queued', (agent, _content, info) => {
const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent !== test.agent || info.source.kind !== 'goal') return
cancel()
vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => {
@@ -689,7 +703,7 @@ describe('same-session goal driving', () => {
it('cancels an accepted queued round and awaits its driver task during teardown', async () => {
const test = await harness([])
let unloading: Promise<void> | undefined
test.ctx.on('agent/queued', (agent, _content, info) => {
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent === test.agent && info.source.kind === 'goal' && unloading === undefined) {
unloading = Promise.resolve(test.driver.dispose())
}

View File

@@ -40,7 +40,7 @@ function view(roundsStarted: number): GoalView {
function appendChange(session: Session): void {
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
session.append('context/message', {
session.append('user/message', {
content: renderGoalChange(change),
source: changeSource,
meta: change as never,
@@ -126,7 +126,7 @@ describe('goal-session prompt invariants', () => {
it('attributes an invalid durable prefix during late loading', async () => {
const { ctx, session } = await mount(true)
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
session.append('context/message', {
session.append('user/message', {
content: [{ type: 'text', text: 'counterfeit goal state' }],
source: changeSource,
meta: change as never,

Some files were not shown because too many files have changed in this diff Show More