refactor(agent): complete inbox lifecycle migration

This commit is contained in:
_Kerman
2026-08-03 12:25:33 +08:00
parent dc1d542092
commit 49e90695cc
214 changed files with 6019 additions and 4235 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/acp/acp/README.md
README.md: 162e88265762652b5629c0e04786311c5b62583f
README.zh.md: 3fd914ecc5bf7b0e7069ba933b09db0a4a3cdfcd
README.md: 4632c00398d0870d3682d8c6241e26de006ad233
README.zh.md: 1bfd16def64f7973ae4e517dfc96e32478b49aa2

View File

@@ -3,7 +3,31 @@
* @module @deepseek-ai/dsh-acp/codec
*/
import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk'
import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientprotocol/sdk'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
/**
* Map a harness turn ending to ACP's terminal reason vocabulary.
* @param reason - harness turn outcome.
* @returns the closest legal ACP stop reason.
*/
export function turnEndToStopReason(reason: TurnEndReason): StopReason {
switch (reason.kind) {
case 'completed':
return 'end_turn'
case 'max-tokens':
return 'max_tokens'
case 'aborted':
case 'interrupted':
return 'cancelled'
case 'blocked':
case 'error':
return 'end_turn'
/* v8 ignore next 2 -- TurnEndReason is closed and every member is handled above */
default:
return 'end_turn'
}
}
/**
* Flatten an ACP prompt's baseline blocks to text. Text blocks concatenate

View File

@@ -14,7 +14,7 @@ import { randomUUID } from 'node:crypto'
import { isAbsolute } from 'node:path'
import { Readable, Writable } from 'node:stream'
import Schema from 'schemastery'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
import {
AgentSideConnection,
ndJsonStream,
@@ -30,18 +30,32 @@ import {
type PromptRequest,
type PromptResponse,
type SessionNotification,
type StopReason,
type Stream,
} from '@agentclientprotocol/sdk'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
// Side-effect type import: declaration-merges the approval waterfall answered below.
import type {} from '@deepseek-ai/dsh-user-approval'
import { acpPromptToText, promptHasUnsupportedContent } from './codec.ts'
import { acpPromptToText, promptHasUnsupportedContent, turnEndToStopReason } from './codec.ts'
export const name = 'acp'
/** The bridge creates and owns agents; every other concern is carried by the agent composition. */
export const inject = ['agents']
/**
* The single continuable-subagent teardown the bridge needs. Declared
* structurally so this package does not depend on the subagent seam for one
* shutdown hook; an absent service means nothing continuable was materialized.
*/
interface ContinuableDrain {
/**
* Close admission below exact host-owned parents, then dispose only their
* continuable descendants child-first.
*/
drainContinuableDescendants(parents: readonly Agent[]): Promise<void>
}
/** Preserve invalid-parameter detail in the SDK wire error message. */
function invalidParams(detail: string): RequestError {
return RequestError.invalidParams(undefined, detail)
@@ -74,7 +88,10 @@ interface SessionRecord {
dispose: () => Promise<void>
/** In-flight prompt and its captured turn number for exact settlement. */
inflight: {
cancelled: boolean
resolve: (reason: StopReason) => void
reject: (error: Error) => void
messageId: string
turn: number | undefined
} | undefined
}
@@ -116,10 +133,18 @@ export function apply(ctx: Context, config: AcpConfig): void {
})
}
const cancelPrompt = (record: SessionRecord): void => {
const settlePrompt = (record: SessionRecord, reason: StopReason): void => {
const inflight = record.inflight
if (inflight === undefined) return
inflight.cancelled = true
record.inflight = undefined
inflight.resolve(reason)
}
const rejectFromError = (
inflight: NonNullable<SessionRecord['inflight']>,
reason: Extract<TurnEndReason, { kind: 'error' }>,
): void => {
inflight.reject(internalError(`turn failed: ${errorChain(reason.error)}`))
}
// Emit only committed assistant text. Raw chunks, reasoning, tools, plans,
@@ -128,21 +153,48 @@ export function apply(ctx: Context, config: AcpConfig): void {
ctx.on('session/event', (session, event: SessionEvent) => {
const record = sessions.get(session.header.id)
if (record === undefined || record.agent.session !== session) return
if (record.inflight !== undefined && event.type === 'assistant/message') {
for (const block of event.data.message.content) {
if (block.type === 'text' && block.text.length > 0) {
notify({
sessionId: record.agent.session.id,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: block.text },
},
})
try {
if (event.type === 'assistant/message') {
for (const block of event.data.message.content) {
if (block.type === 'text' && block.text.length > 0) {
notify({
sessionId: record.agent.session.id,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: block.text },
},
})
}
}
}
} finally {
const inflight = record.inflight
if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) {
if (event.data.reason.kind === 'error') {
record.inflight = undefined
rejectFromError(inflight, event.data.reason)
} else {
record.inflight = undefined
inflight.resolve(turnEndToStopReason(event.data.reason))
}
}
}
})
ctx.on('agent/inbox/claimed', (agent, { message, turn }) => {
const record = ownedRecord(agent)
const inflight = record?.inflight
if (inflight !== undefined && inflight.messageId === message.id) inflight.turn = turn
})
ctx.on('agent/error', (agent, turn, _step, error) => {
const record = ownedRecord(agent)
const inflight = record?.inflight
if (record === undefined || inflight === undefined || inflight.turn === turn) return
record.inflight = undefined
inflight.reject(internalError(`turn failed: ${errorChain(error)}`))
})
// Permission requests are a machine policy channel for ACP clients such as
// dsh-subagent-acp. The bridge offers one-shot choices only and never infers a
// durable grant from an unknown client response.
@@ -223,22 +275,44 @@ export function apply(ctx: Context, config: AcpConfig): void {
if (ctx.agents.get(record.agent.id) !== record.agent) {
throw internalError('prompt was not queued: the agent was disposed outside the bridge')
}
const inflight: NonNullable<SessionRecord['inflight']> = { cancelled: false }
record.inflight = inflight
try {
record.agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
await record.agent.whenIdle()
return { stopReason: inflight.cancelled ? 'cancelled' : 'end_turn' }
} finally {
record.inflight = undefined
}
const message = createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
const stopReason = await new Promise<StopReason>((resolve, reject) => {
// Arm the slot before followup() so a listener-driven synchronous
// turn cannot slip past correlation; a synchronous followup()
// failure (invalid input) must free the slot again or the session
// would reject every later prompt as already in flight.
const inflight: NonNullable<SessionRecord['inflight']> = {
resolve, reject, messageId: message.id, turn: undefined,
}
record.inflight = inflight
try {
record.agent.followup(message)
// The machine's send() contains listener failures and accepts
// any typed input; this guards a future synchronous throw so the
// slot cannot wedge.
/* v8 ignore start -- future-proofing guard, see above */
} catch (error: unknown) {
record.inflight = undefined
const detail = error instanceof Error ? error.message : String(error)
throw internalError(`prompt was not queued: ${detail}`)
}
/* v8 ignore stop */
// A turnless slot settles only at quiescence: admission discarded
// the prompt before it could open a turn.
void record.agent.whenIdle().then(() => {
if (record.inflight !== inflight) return
record.inflight = undefined
inflight.resolve('cancelled')
})
})
return { stopReason }
},
cancel(params: CancelNotification): Promise<void> {
const record = sessions.get(SessionId(params.sessionId))
if (record === undefined) return Promise.resolve()
cancelPrompt(record)
record.agent.cancel({ kind: 'user' })
settlePrompt(record, 'cancelled')
return Promise.resolve()
},
}
@@ -257,10 +331,45 @@ export function apply(ctx: Context, config: AcpConfig): void {
closed = true
const records = [...sessions.values()]
sessions.clear()
quiescing = Promise.all(records.map(async (record) => {
cancelPrompt(record)
await record.dispose()
})).then(() => {})
// Stop the bridge's own work before any await: a descendant drain can block
// on persistence or scoped cleanup, and the top-level agents must not keep
// running model and tool calls for its whole duration.
for (const record of records) {
record.agent.cancel({ kind: 'user' })
settlePrompt(record, 'cancelled')
}
quiescing = (async () => {
// Continuable subagents outlive the turn that started them, and their
// Activations own descendant teardown. Drain only these sessions' forests
// child-first BEFORE disposing the top-level agents, so no descendant is
// left holding a runtime its owner already released and another frontend
// sharing this Context remains live.
// Read the one teardown method structurally: the bridge needs no other
// part of the subagent seam, so it does not depend on that package.
const subagents = ctx.get('subagents') as ContinuableDrain | undefined
if (subagents !== undefined) {
try {
await subagents.drainContinuableDescendants(records.map(record => record.agent))
} catch (error: unknown) {
logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`)
}
}
const disposals = await Promise.allSettled(records.map(record => record.dispose()))
const failures: unknown[] = []
for (const result of disposals) {
if (result.status === 'rejected') failures.push(result.reason as unknown)
}
if (failures.length > 0) {
// The production consumer logs this AggregateError through `String`,
// which renders only its message. Embed every per-session diagnostic,
// including nested causes and aggregate members, in that message.
const detail = failures.map(failure => errorChain(failure)).join('; ')
throw new AggregateError(
failures,
`ACP agent teardown failed for ${failures.length} session(s): ${detail}`,
)
}
})()
return quiescing
}

View File

@@ -1,20 +1,24 @@
import { describe, expect, it } from 'vitest'
import { acpPromptToText, promptHasUnsupportedContent } from '../src/codec.ts'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
import { acpPromptToText, turnEndToStopReason } from '../src/codec.ts'
describe('ACP automation codec', () => {
it('flattens baseline blocks and rejects everything richer', () => {
expect(acpPromptToText([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe('ab')
expect(acpPromptToText([
{ type: 'text', text: 'see' },
{ type: 'resource_link', name: 'x', uri: 'file:///x' },
])).toBe('see\n[resource_link name="x" uri="file:///x"]\n')
expect(acpPromptToText([{ type: 'image', data: '', mimeType: 'image/png' }])).toBe('')
expect(promptHasUnsupportedContent([
{ type: 'text', text: 'ok' },
{ type: 'resource_link', name: 'x', uri: 'file:///x' },
])).toBe(false)
expect(promptHasUnsupportedContent([
{ type: 'image', data: '', mimeType: 'image/png' },
])).toBe(true)
describe('ACP codec', () => {
it.each([
[{ kind: 'completed' }, 'end_turn'],
[{ kind: 'max-tokens' }, 'max_tokens'],
[{ kind: 'aborted', reason: { kind: 'user' } }, 'cancelled'],
[{ kind: 'interrupted' }, 'cancelled'],
[{ kind: 'blocked' }, 'end_turn'],
[{ kind: 'error', error: 'failed' }, 'end_turn'],
] satisfies Array<[TurnEndReason, string]>)('maps %o to %s', (reason, expected) => {
expect(turnEndToStopReason(reason)).toBe(expected)
})
it('drops unsupported blocks from baseline text conversion', () => {
expect(acpPromptToText([{
type: 'image',
data: '',
mimeType: 'image/png',
}])).toBe('')
})
})

View File

@@ -67,7 +67,15 @@ describe('ACP connection ownership', () => {
const agent = harness.ctx.agents.get(SessionId(sessionId))!
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
await vi.waitFor(() => { expect(agent.status).toBe('running') })
harness.ctx.on('agent/cancel-requested', () => { order.push('parent cancelled') })
const cancel = agent.cancel.bind(agent)
let cancelObserved = false
vi.spyOn(agent, 'cancel').mockImplementation((...args) => {
if (!cancelObserved) {
cancelObserved = true
order.push('parent cancelled')
}
cancel(...args)
})
const disposal = harness.acpFiber.dispose()
// A drain can block on persistence, so the bridge's own turn must already be

View File

@@ -31,28 +31,28 @@ describe('ACP prompt lifecycle', () => {
harness = undefined
})
it('settles after a max-token turn without losing its committed text', async () => {
it('maps a max-token turn without losing its committed text', async () => {
harness = await makeBridgeHarness({ script: [maxTokensResponse('cut off')] })
const sessionId = await newSession(harness)
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
expect(result.stopReason).toBe('end_turn')
expect(result.stopReason).toBe('max_tokens')
await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') })
})
it('settles after a failed turn and never publishes its partial chunks', async () => {
it('rejects a failed turn and never publishes its partial chunks', async () => {
harness = await makeBridgeHarness({ script: [errorResponse('provider boom')] })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
.rejects.toThrow(/turn failed: provider boom/)
expect(messageText(harness)).toBe('')
})
it('settles after an ordinary plugin failure', async () => {
it('rejects an ordinary plugin failure through the same prompt boundary', async () => {
harness = await makeBridgeHarness({ script: [textResponse('must not run')] })
harness.ctx.on('agent/pre-step', () => { throw new Error('plugin pre-step failed') })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
.rejects.toThrow(/turn failed: plugin pre-step failed/)
})
it('settles even when an earlier turn observer throws', async () => {
@@ -221,13 +221,13 @@ describe('ACP prompt lifecycle', () => {
await vi.waitFor(() => { expect(messageText(harness!)).toBe('recovered') })
})
it('a failed turn with no retry settles at quiescence', async () => {
it('a failed turn with no retry still rejects', async () => {
harness = await makeBridgeHarness({ script: [errorResponse('terminal boom')] })
let offered = 0
harness.ctx.on('agent/request-error', async () => { offered += 1 })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
.rejects.toThrow(/turn failed: terminal boom/)
expect(offered).toBe(1)
})
@@ -238,7 +238,7 @@ describe('ACP prompt lifecycle', () => {
}))
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
.resolves.toEqual({ stopReason: 'cancelled' })
// The rejected prompt opened no turn and streamed nothing.
expect(messageText(harness)).toBe('')
})
@@ -249,6 +249,6 @@ describe('ACP prompt lifecycle', () => {
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
.rejects.toThrow(/turn failed: pre-step exploded/)
})
})

View File

@@ -12,7 +12,7 @@ export type {
WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, QueueAction, SessionModels,
ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,

View File

@@ -17,7 +17,8 @@ export type {
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
MessageId, ModelReasoningEffort, ModelTarget, QueueAction, SessionModels,
MessageId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,

View File

@@ -4,11 +4,12 @@
// string here (narrow to real brands when convenient).
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
MessageId, RpcError, SessionId, ToolCallView, ToolResultView,
RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
export type { TodoItem }
@@ -104,6 +105,8 @@ export interface AssistantMessageNode {
/** A steering message injected mid-turn. */
export interface SteeringMessageNode {
kind: 'steering'
/** Stable identity shared with its pre-admission inbox occurrence. */
messageId: MessageId
seq: number
/** Unix epoch ms from the source session event. */
time: number
@@ -122,15 +125,15 @@ export interface ContextMessageNode {
source: unknown
}
/** Durable notice that a failed model request is waiting for another attempt. */
/** Durable notice that a closed failed step is waiting for a model-request retry. */
export type ModelRetryNode = LlmRetryEventData & {
kind: 'model-retry'
seq: number
/** Unix epoch ms from the llm/retry session event. */
time: number
/**
* Client-derived lifecycle: scheduled until another attempt emits retry or
* chunk evidence, started once it does, or cancelled if the turn aborts first.
* Client-derived lifecycle: scheduled until a retry turn starts, started
* once it does, or cancelled when the failed turn aborts first.
*/
retryState: 'scheduled' | 'started' | 'cancelled'
}
@@ -271,9 +274,15 @@ export interface RunningToolCall {
}
/** One independently addressable row from the transient queue snapshot. */
/** One transient inbox occurrence from the authoritative `session/queue` snapshot. */
export interface QueuedMessage {
readonly id: MessageId
/** Stable message identity used for transient-to-durable steering handoff. */
readonly messageId: MessageId
/** Agent-resolved placement; only queued rows accept queue mutations. */
readonly placement: 'queued' | 'steering'
/** Complete content used to render pending steering before it becomes durable. */
readonly content: readonly ContentBlock[]
readonly preview: string
/** Complete editable text; null when the message contains non-text blocks. */
readonly text: string | null
@@ -332,9 +341,14 @@ export interface ConversationSnapshot {
*/
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
pending: readonly PendingInteraction[]
/** Authoritative transient inbox snapshot, replaced after every host-side change. */
/** Authoritative transient inbox snapshot, including queued and steering placements. */
queue: readonly QueuedMessage[]
running: boolean
/**
* Catalog-discovered continuation address. Its parent availability controls
* human input; null means ordinary session transport.
*/
subagent: { address: SubagentAddress; parentAvailable: boolean } | null
/** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */
composerPhase: ComposerPhase
/** Set after host/session-removed; the UI grays out and disables input. */

View File

@@ -6,7 +6,7 @@ import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError,
RpcId, RpcResult, SessionId, ToolEventView,
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -34,6 +34,10 @@ const MAX_RETRY_DELAY_MS = 2_147_483_647
/** Manager-owned observers of a Session object's local state edges. */
export interface SessionOptions {
/** Catalog-discovered address selecting non-activating subagent transport. */
address?: SubagentAddress
/** Whether the exact direct parent Agent was live at the latest catalog read. */
parentAvailable?: boolean
/**
* First ACCEPTED prompt on a blank session (fires at most once, on the
* prompt RPC's success response): the manager mirrors the blank→false flip
@@ -119,6 +123,8 @@ export class Session implements SessionFace {
private dispatchesRev = 0
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
private running = false
private address: SubagentAddress | undefined
private parentAvailable = false
/**
* Sticky send marker, private input of the composerPhase derivation: set
* synchronously before prompt()'s first await, never reset — the blank →
@@ -174,6 +180,8 @@ export class Session implements SessionFace {
private readonly options: SessionOptions = {},
) {
this.projections = options.projections ?? new ProjectionValueStore()
this.address = options.address
this.parentAvailable = options.parentAvailable ?? false
this.snapshotCache = this.buildSnapshot()
}
@@ -213,7 +221,21 @@ export class Session implements SessionFace {
this.notifier.markDirty()
let result: RpcResult<{ accepted: true }>
try {
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
if (this.address === undefined) {
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
} else if (this.address.mode === 'one-shot') {
result = {
ok: false,
error: {
code: 'subagent-not-resumable',
message: 'one-shot subagent conversations are read-only',
details: { childSessionId: this.address.childSessionId },
},
}
} else {
const routed = (await this.api.subagents.prompt({ ...this.address, content })).result
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
}
} catch (error) {
result = transportError(error)
}
@@ -253,6 +275,19 @@ export class Session implements SessionFace {
* @returns the cancel result.
*/
async cancel(): Promise<RpcResult<{ accepted: true }>> {
if (this.address !== undefined) {
const result: RpcResult<{ accepted: true }> = {
ok: false,
error: {
code: 'subagent-delivery-unavailable',
message: 'subagent activation cancellation is unavailable',
details: { childSessionId: this.address.childSessionId },
},
}
this.promptError = { op: 'stop', error: result.error }
this.notifier.markDirty()
return result
}
let result: RpcResult<{ accepted: true }>
try {
result = (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
@@ -318,9 +353,7 @@ export class Session implements SessionFace {
this.loadingOlder = true
this.notifier.markDirty()
try {
const { result } = await this.api.sessions.history({
sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES,
})
const { result } = await this.history({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES })
if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
const older = result.value.events
if (older.length === 0) {
@@ -413,8 +446,11 @@ export class Session implements SessionFace {
case 'session/queue': {
this.queued = frame.items.map(item => ({
id: item.id,
preview: queuePreviewOf(item.content),
text: queueTextOf(item.content),
messageId: item.message.id,
placement: item.placement,
content: item.message.content,
preview: queuePreviewOf(item.message.content),
text: queueTextOf(item.message.content),
}))
this.queueRev++
this.notifier.markDirty()
@@ -479,6 +515,32 @@ export class Session implements SessionFace {
this.notifier.markDirty()
}
/**
* Install or clear the catalog-discovered transport address. A changed
* address rebuilds an already-open window through its new history route.
* @param address - direct parent/child address, or undefined for ordinary transport.
* @param parentAvailable - latest exact-parent availability hint.
*/
configureSubagent(address: SubagentAddress | undefined, parentAvailable = false): void {
const same = this.address?.parentSessionId === address?.parentSessionId
&& this.address?.childSessionId === address?.childSessionId
&& this.address?.mode === address?.mode
this.address = address
this.parentAvailable = parentAvailable
if (!same && this.openState !== 'cold') void this.resync()
else this.notifier.markDirty()
}
/**
* Update only the parent availability hint from a catalog refresh.
* @param available - whether the exact direct parent is live.
*/
handleSubagentParentAvailable(available: boolean): void {
if (this.parentAvailable === available) return
this.parentAvailable = available
this.notifier.markDirty()
}
/**
* Blank-bit relay from the authoritative summary source (list baseline and
* the session-added frame). Monotone: once any signal (local first send,
@@ -533,7 +595,7 @@ export class Session implements SessionFace {
this.openError = null
this.notifier.markDirty()
try {
let { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
let { result } = await this.history({ maxMessages: PAGE_MESSAGES })
if (generation !== this.openGeneration) return
if (!result.ok) {
this.openState = 'error'
@@ -544,7 +606,7 @@ export class Session implements SessionFace {
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
const tailSeq = this.windowTailSeq()
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
result = (await this.history({ maxMessages: PAGE_MESSAGES })).result
if (generation !== this.openGeneration) return
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
}
@@ -588,9 +650,20 @@ export class Session implements SessionFace {
this.events.push(event)
this.views.push(view)
this.transcript.append(event, view)
this.handoffPendingSteering(event)
this.applyEventSideEffects(event, view)
}
/** Retire the first matching live steering occurrence when its durable event takes over. */
private handoffPendingSteering(event: SessionEvent): void {
if (event.type !== 'steering/message') return
const index = this.queued.findIndex(item =>
item.placement === 'steering' && item.messageId === event.data.message.id)
if (index === -1) return
this.queued = this.queued.filter((_item, candidate) => candidate !== index)
this.queueRev++
}
/** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop;
* a seq gap -> buffer + tail-page repull instead of appending a hole (audit S3: a gap is an
* expected reconnect-window artifact, repaired by refetch). The window stays one contiguous
@@ -621,7 +694,7 @@ export class Session implements SessionFace {
this.stitching = true
const generation = this.openGeneration
try {
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
const { result } = await this.history({ maxMessages: PAGE_MESSAGES })
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
@@ -646,7 +719,6 @@ export class Session implements SessionFace {
if (this.partial !== null && this.partial.turn === data.turn && this.partial.step === data.step) {
this.partial = null
}
this.settleScheduledRetry('started', data.turn)
this.derivedNodes.push({
kind: 'model-retry',
seq: event.seq,
@@ -720,8 +792,8 @@ export class Session implements SessionFace {
case 'turn/start':
return
case 'assistant/chunk': {
this.settleScheduledRetry('started', event.data.turn)
const { turn, step, chunk } = event.data
this.settleScheduledRetry('started', turn)
if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
this.partial = new PartialAccumulator(turn, step)
}
@@ -748,9 +820,7 @@ export class Session implements SessionFace {
return
}
case 'turn/end': {
if (event.data.reason.kind === 'error') {
this.settleScheduledRetry('started', event.data.turn)
} else if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'interrupted') {
if (event.data.reason.kind === 'aborted') {
this.settleScheduledRetry('cancelled', event.data.turn)
}
if (
@@ -758,22 +828,20 @@ export class Session implements SessionFace {
&& !this.derivedNodes.some(node => node.kind === 'model-retry' && node.turn === event.data.turn)
) {
const failure = event.data.reason.error
const failedTurn = event.data.turn
const code = failure !== null && typeof failure === 'object'
&& typeof (failure as { code?: unknown }).code === 'string'
? (failure as { code: string }).code
: undefined
const code = failure !== null && typeof failure === 'object' && 'code' in failure
&& typeof failure.code === 'string' ? failure.code : undefined
this.derivedNodes.push({
kind: 'turn-error',
seq: event.seq,
time: event.time,
turn: failedTurn,
turn: event.data.turn,
step: event.data.step,
message: displayFailureMessage(failure),
...code === undefined ? {} : { code },
...(code === undefined ? {} : { code }),
})
this.derivedRev++
}
if (event.data.reason.kind === 'error') this.settleScheduledRetry('started', event.data.turn)
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
// Shared by live and window-replay paths, so a refresh reconstructs the same frozen node
@@ -895,6 +963,9 @@ export class Session implements SessionFace {
codeDispatches: this.dispatchesCache.value,
queue: this.queueCache.value,
running: this.running,
subagent: this.address === undefined
? null
: { address: this.address, parentAvailable: this.parentAvailable },
composerPhase: derivePhase(
// Command lifecycle nodes are not conversation: running /permission
// or /plan on a fresh session keeps the hero (the client mirror of
@@ -912,6 +983,17 @@ export class Session implements SessionFace {
lastAgentError: this.lastAgentError,
}
}
/** Select ordinary or addressed history transport from the stored browser fact. */
private history(payload: { beforeSeq?: number; maxMessages?: number }): Promise<RpcResponse<{
events: HistoryEntry[]
hasMore: boolean
projections?: ProjectionsBaseline
}>> {
return this.address === undefined
? this.api.sessions.history({ sessionId: this.sessionId, ...payload })
: this.api.subagents.history({ ...this.address, ...payload })
}
}
/** Validate the plugin-owned payload at the session-event wire boundary. */

View File

@@ -4,11 +4,10 @@
* projection, and snapshot reference stability.
*/
import { describe, expect, it } from 'vitest'
import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type {
MessageId, MuxFrame, RpcId, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient } from './fake-api.ts'
@@ -16,12 +15,14 @@ import { FakeApiClient } from './fake-api.ts'
const SID = 'fk-q1' as SessionId
const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }]
const rid = (id: string): RpcId => id as RpcId
const mid = (id: string): MessageId => id as MessageId
const iid = (id: string): MessageId => id as MessageId
interface QueueFixture {
id: string
body: string
content?: ContentBlock[]
placement?: 'queued' | 'steering'
message?: UserMessage
}
/** Build one authoritative queue snapshot. */
@@ -29,12 +30,13 @@ function queueFrame(items: QueueFixture[]): MuxFrame {
return {
type: 'session/queue',
sessionId: SID,
items: items.map(item => freezeMessage({
...createUserMessage({
items: items.map(item => ({
id: iid(item.id),
placement: item.placement ?? 'queued',
message: item.message ?? createUserMessage({
content: item.content ?? text(item.body),
source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never,
}),
id: mid(item.id),
})),
}
}
@@ -49,8 +51,14 @@ describe('queue snapshot intake', () => {
session.handleMuxEnvelope(rid('env-1'), queueFrame([
{ id: 'q-1', body: '第一条 排队\n消息' },
]))
expect(session.getSnapshot().queue).toEqual([
{ id: 'q-1', preview: '第一条 排队 消息', text: '第一条 排队\n消息' },
const queue = session.getSnapshot().queue
expect(typeof queue[0]?.messageId).toBe('string')
expect(queue).toMatchObject([
{
id: 'q-1', placement: 'queued',
content: [{ type: 'text', text: '第一条 排队\n消息' }],
preview: '第一条 排队 消息', text: '第一条 排队\n消息',
},
])
})
@@ -61,8 +69,14 @@ describe('queue snapshot intake', () => {
body: '',
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
}]))
expect(session.getSnapshot().queue).toEqual([
{ id: 'q-image', preview: 'hi [image]', text: null },
const queue = session.getSnapshot().queue
expect(typeof queue[0]?.messageId).toBe('string')
expect(queue).toMatchObject([
{
id: 'q-image', placement: 'queued',
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }],
preview: 'hi [image]', text: null,
},
])
})
@@ -85,8 +99,14 @@ describe('queue snapshot intake', () => {
session.handleMuxEnvelope(rid('env-5'), queueFrame([
{ id: 'q-2', body: 'two edited' },
]))
expect(session.getSnapshot().queue).toEqual([
{ id: 'q-2', preview: 'two edited', text: 'two edited' },
const queue = session.getSnapshot().queue
expect(typeof queue[0]?.messageId).toBe('string')
expect(queue).toMatchObject([
{
id: 'q-2', placement: 'queued',
content: [{ type: 'text', text: 'two edited' }],
preview: 'two edited', text: 'two edited',
},
])
session.handleMuxEnvelope(rid('env-6'), queueFrame([]))
expect(session.getSnapshot().queue).toEqual([])
@@ -99,6 +119,55 @@ describe('queue snapshot intake', () => {
session.handleAgentError('unrelated')
expect(session.getSnapshot().queue).toBe(before)
})
it('retains steering placement and complete content in the same authoritative snapshot', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-steering'), queueFrame([
{ id: 'q-next', body: 'later' },
{ id: 's-now', body: 'interrupt now', placement: 'steering' },
]))
expect(session.getSnapshot().queue.map(item => ({
id: item.id, placement: item.placement, content: item.content,
}))).toEqual([
{ id: 'q-next', placement: 'queued', content: text('later') },
{ id: 's-now', placement: 'steering', content: text('interrupt now') },
])
})
it('hands off exactly one current occurrence when live steering becomes durable', async () => {
const session = makeSession()
await session.open()
const message = createUserMessage({
content: text('same message'),
source: { kind: 'user' },
})
session.handleMuxEnvelope(rid('env-same-id'), queueFrame([
{ id: 's-first', body: '', placement: 'steering', message },
{ id: 's-second', body: '', placement: 'steering', message },
]))
const durable = {
seq: 0,
time: 1_700_000_000_000,
type: 'steering/message',
surfaceOp: 'append',
data: { turn: 1, message },
} as SessionEvent
session.handleMuxEnvelope(rid('env-durable'), {
type: 'session/event', sessionId: SID, event: durable,
})
expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second'])
expect(session.getSnapshot().nodes.filter(node => node.kind === 'steering')).toHaveLength(1)
session.handleMuxEnvelope(rid('env-reused-id'), queueFrame([
{ id: 's-later', body: '', placement: 'steering', message },
]))
session.handleMuxEnvelope(rid('env-replayed-durable'), {
type: 'session/event', sessionId: SID, event: durable,
})
expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later'])
})
})
describe('queue operation transport', () => {
@@ -108,13 +177,22 @@ describe('queue operation transport', () => {
session.handleMuxEnvelope(rid('env-op'), queueFrame([{ id: 'q-op', body: 'pending' }]))
const before = session.getSnapshot().queue
await expect(session.updateQueue(mid('q-op'), { kind: 'edit', content: text('next') }))
await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') }))
.resolves.toEqual({ ok: true, value: { accepted: true } })
expect(api.callsOf('session.updateQueue')).toEqual([{
sessionId: SID,
itemId: 'q-op',
action: { kind: 'edit', content: text('next') },
}])
await expect(session.updateQueue(iid('q-op'), { kind: 'steer' }))
.resolves.toEqual({ ok: true, value: { accepted: true } })
expect(api.callsOf('session.updateQueue')).toEqual([
{
sessionId: SID,
itemId: 'q-op',
action: { kind: 'edit', content: text('next') },
},
{
sessionId: SID,
itemId: 'q-op',
action: { kind: 'steer' },
},
])
expect(session.getSnapshot().queue).toBe(before)
})
})

View File

@@ -11,6 +11,7 @@ import type {
ReferenceInsert, SubmitOutcome, TokenSpan,
} from '@deepseek-ai/dsh-client-ui-slash/client'
import type { QueueRow } from '../contract/queue.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
/**
* The scoped-event application verbs: the hub's bail listeners call these,
@@ -28,8 +29,11 @@ export interface InputTarget {
export interface SessionInput extends InputTarget {
/** Single write path for draft text (all mutation rides machine events). */
setDraft(text: string): void
/** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */
submit(): void
/**
* THE complexity sink: enter adjudication, submit transaction, and the default sink live inside.
* @param mode - delivery intent retained through asynchronous adjudication and serialization.
*/
submit(mode?: InputSubmitMode): void
/**
* Surface a notice outside the machine's own effect stream: detached
* command results and business notifications render through here.
@@ -82,8 +86,8 @@ export interface ComposerKeyboard {
readonly snapshot: InputState
/** Draft write with the DOM-observed edit shape (narrows occurrence math). */
setDraft(text: string, editRange?: EditRange): void
/** Newline at the selection as a machine transaction (Ctrl+Enter path). */
newline(selection: EditSelection): void
/** Submit with an explicit delivery mode resolved by the keyboard policy. */
submit(mode: InputSubmitMode): void
undo(): void
redo(): void
/** Paste over the selection (sync components ride the same transaction). */
@@ -191,7 +195,7 @@ export interface InputState {
readonly occurrences: readonly Occurrence[]
/** Live paste-match attempt (absent when no paste is matchable). */
readonly paste?: PasteAttemptState
/** Read-only queue projection from the reconnect baseline and durable inbox events. */
/** Read-only transient inbox projection (`session/queue`, including pending steering). */
readonly queue: readonly QueuedMessage[]
}
@@ -206,6 +210,8 @@ export interface SubmitAttempt {
readonly signal: AbortSignal
/** Draft at enter time; rollback restores it only while the live draft still equals it. */
readonly draftSnapshot: string
/** Default-message delivery intent retained while slash adjudication is pending. */
readonly mode: InputSubmitMode
}
/**
@@ -217,8 +223,6 @@ export interface SubmitAttempt {
export type InputEvent =
/** Full next draft from the textarea; editRange narrows the occurrence math (absent → diff scan). */
| { readonly type: 'draft-changed'; readonly draft: string; readonly editRange?: EditRange }
/** Insert '\n' replacing the selection (F1: the execCommand newline path moved into the machine). */
| { readonly type: 'newline'; readonly selection: EditSelection }
| { readonly type: 'begin-command'; readonly claim: CommandClaim; readonly span: TokenSpan }
/** Place one U+FFFC at the span and mint the occurrence (scoped insert-reference event payload). */
| { readonly type: 'insert-ref'; readonly reference: ReferenceInsert; readonly span: TokenSpan }
@@ -239,7 +243,7 @@ export type InputEvent =
| { readonly type: 'paste-upgrade'; readonly attemptId: number; readonly span: TokenSpan; readonly reference: ReferenceInsert }
/** Shell-observed attempt killers the machine cannot see itself (caret/selection ops, Slash interaction updates). */
| { readonly type: 'invalidate-paste' }
| { readonly type: 'enter' }
| { readonly type: 'enter'; readonly mode: InputSubmitMode }
| { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
| { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
| { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string }
@@ -258,5 +262,5 @@ export type InputEvent =
export type InputEffect =
| { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string }
| { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string }
| { readonly type: 'default-sink'; readonly draft: string }
| { readonly type: 'default-sink'; readonly draft: string; readonly mode: InputSubmitMode }
| { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }

View File

@@ -94,106 +94,109 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
</button>
)}
<ul id={listId} className={css.list} hidden={!listVisible}>
{listVisible && queue.map(row => (
<li key={row.id} className={css.row}>
{editing?.id === row.id
? (
<input
autoFocus
className={css.editor}
aria-label={t('queue.edit')}
value={editing.text}
onChange={(event) => { setEditing({ id: row.id, text: event.currentTarget.value }) }}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setEditing(null)
return
}
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
void saveEdit()
}
}}
/>
)
: <span className={css.preview}>{row.preview}</span>}
{queueMutable && <div className={css.actions}>
{editing?.id === row.id
{listVisible && queue.map((row) => {
const rowEditing = editing?.id === row.id ? editing : null
return (
<li key={row.id} className={css.row}>
{rowEditing !== null
? (
<>
<button
type="button"
className={css.action}
aria-label={t('queue.save')}
title={t('queue.save')}
disabled={busy !== null || editing.text.trim() === ''}
onClick={() => { void saveEdit() }}
>
<IconCheckOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label={t('queue.cancelEdit')}
title={t('queue.cancelEdit')}
disabled={busy !== null}
onClick={() => { setEditing(null) }}
>
<IconCloseOutline16 size={14} />
</button>
</>
<input
autoFocus
className={css.editor}
aria-label={t('queue.edit')}
value={rowEditing.text}
onChange={(event) => { setEditing({ id: row.id, text: event.currentTarget.value }) }}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setEditing(null)
return
}
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
void saveEdit()
}
}}
/>
)
: (
<>
<button
type="button"
className={css.action}
aria-label={t('queue.edit')}
title={row.text === null ? t('queue.edit.unsupported') : t('queue.edit')}
disabled={busy !== null || row.text === null}
onClick={() => {
if (row.text !== null) setEditing({ id: row.id, text: row.text })
}}
>
<IconEditOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label={t('queue.remove')}
title={t('queue.remove')}
disabled={busy !== null}
onClick={() => {
void applyAction(
row.id,
{ kind: 'remove' },
t('queue.removeFailed'),
)
}}
>
<IconTrashOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label={t('queue.steer')}
title={running ? t('queue.steer') : t('queue.steer.unavailable')}
disabled={busy !== null || !running}
onClick={() => {
void applyAction(
row.id,
{ kind: 'steer' },
t('queue.steerFailed'),
)
}}
>
<IconSendOutline16 size={14} />
</button>
</>
)}
</div>}
</li>
))}
: <span className={css.preview}>{row.preview}</span>}
{queueMutable && <div className={css.actions}>
{rowEditing !== null
? (
<>
<button
type="button"
className={css.action}
aria-label={t('queue.save')}
title={t('queue.save')}
disabled={busy !== null || rowEditing.text.trim() === ''}
onClick={() => { void saveEdit() }}
>
<IconCheckOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label={t('queue.cancelEdit')}
title={t('queue.cancelEdit')}
disabled={busy !== null}
onClick={() => { setEditing(null) }}
>
<IconCloseOutline16 size={14} />
</button>
</>
)
: (
<>
<button
type="button"
className={css.action}
aria-label={t('queue.edit')}
title={row.text === null ? t('queue.edit.unsupported') : t('queue.edit')}
disabled={busy !== null || row.text === null}
onClick={() => {
if (row.text !== null) setEditing({ id: row.id, text: row.text })
}}
>
<IconEditOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label={t('queue.remove')}
title={t('queue.remove')}
disabled={busy !== null}
onClick={() => {
void applyAction(
row.id,
{ kind: 'remove' },
t('queue.removeFailed'),
)
}}
>
<IconTrashOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label={t('queue.steer')}
title={running ? t('queue.steer') : t('queue.steer.unavailable')}
disabled={busy !== null || !running}
onClick={() => {
void applyAction(
row.id,
{ kind: 'steer' },
t('queue.steerFailed'),
)
}}
>
<IconSendOutline16 size={14} />
</button>
</>
)}
</div>}
</li>
)
})}
</ul>
</div>
</div>

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md
README.md: 5df2df3a4d0814b55066a096263a9ff382498083
README.zh.md: 6ab7ff00ff2682324f78249b9865a913a622139e
README.md: 2512594bddc0cfbd89e9b7ba47d98c9c470b1618
README.zh.md: f05b5ea17056e62046581138dfa41b31a1bc3d19

View File

@@ -175,6 +175,7 @@ describe('/compact human command', () => {
it.each([
['busy', 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.'],
['cancelled', 'Compaction cancelled.'],
['changed', 'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.'],
['summary', 'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.'],
['commit', 'Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.'],

View File

@@ -390,7 +390,9 @@ export class BasicCompactService extends CompactService {
{
owner: null,
stability: 'selected-span',
flush: () => this.ctx.sessions.flush(agent.session),
flush: async () => {
await this.ctx.sessions.flush(agent.session)
},
},
operationSignal,
)

View File

@@ -141,11 +141,14 @@ function derivedText(session: Session): string[] {
}
/** Await one classified manual-compaction rejection. */
async function rejection(operation: Promise<unknown>): Promise<ManualCompactionError> {
const caught: unknown = await operation.then(
(value: unknown) => { throw new Error(`expected a rejection, resolved with ${String(value)}`) },
(error: unknown) => error,
)
async function rejection(operation: Promise<unknown> | (() => Promise<unknown>)): Promise<ManualCompactionError> {
let caught: unknown
try {
const value = await (typeof operation === 'function' ? operation() : operation)
throw new Error(`expected a rejection, resolved with ${String(value)}`)
} catch (error: unknown) {
caught = error
}
if (!(caught instanceof ManualCompactionError)) {
throw new Error(`expected a ManualCompactionError, got ${String(caught)}`)
}
@@ -195,15 +198,20 @@ function closedConversation(turns = 2, lastTurnNumber = turns): Session {
return session
}
/** A fake idle agent whose admission reservation is scripted per test. */
/** A fake idle agent whose maintenance claim is scripted per test. */
function fakeAgent(
session: Session,
reserve: () => (() => void) | undefined,
maintenanceSignal = new AbortController().signal,
): Agent {
return {
session,
options: { provider: MODEL, model: MODEL },
reserveTurnAdmission: reserve,
runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> {
const release = reserve()
if (release === undefined) throw new Error('agent already has active work')
return task(maintenanceSignal).finally(release)
},
} as unknown as Agent
}
@@ -270,7 +278,7 @@ describe('compactNow through the real loop', () => {
expect(second.some(text => text.includes(PROMPT))).toBe(false)
})
it('keeps context injected during summarization between the markers and after the checkpoint', async () => {
it('keeps context injected during summarization pending for the next step', async () => {
const harness = await loopHarness()
const { agent, compact } = harness
await seedHistory(harness)
@@ -285,18 +293,22 @@ describe('compactNow through the real loop', () => {
expect(result).not.toBeNull()
const start = agent.session.events.findLast(event => event.type === 'compact/start')
const injected = agent.session.events.findLast(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'test')
const injected = agent.inbox.nextStep.find(message =>
message.source.kind === 'plugin' && message.source.plugin === 'test')
const end = agent.session.events.findLast(event => event.type === 'compact/end')
expect(start).toBeDefined()
expect(injected).toBeDefined()
expect(end).toBeDefined()
expect(start!.seq).toBeLessThan(injected!.seq)
expect(injected!.seq).toBeLessThan(end!.seq)
expect(result?.shadowedSeqs).not.toContain(injected?.seq)
expect(agent.session.events.some(event => event.type === 'user/message'
&& event.data.id === injected?.id)).toBe(false)
agent.followup(createUserMessage({
content: [{ type: 'text', text: 'after compaction' }],
source: { kind: 'user' },
}))
await agent.whenIdle()
const messages = derivedText(agent.session)
expect(messages[0]).toContain('checkpoint')
expect(messages.at(-1)).toContain('INJECTED CONTEXT')
expect(messages.filter(text => text.includes('INJECTED CONTEXT'))).toHaveLength(1)
})
@@ -334,7 +346,7 @@ describe('compactNow through the real loop', () => {
content: [{ type: 'text', text: 'first in line' }],
source: { kind: 'user' },
}))
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
expect((await rejection(() => compact.compactNow(agent, SIGNAL))).code).toBe('busy')
expect(compact.calls).toHaveLength(0)
await agent.whenIdle()
@@ -400,7 +412,7 @@ describe('compactNow transaction and failure classification', () => {
session.append('compact/start', { turn: null })
const agent = fakeAgent(session, () => () => undefined)
const error = await rejection(compact.compactNow(agent, SIGNAL))
const error = await rejection(() => compact.compactNow(agent, SIGNAL))
expect(error.code).toBe('busy')
expect(error.message).toContain('compaction lock is already active')
expect(compact.calls).toHaveLength(0)
@@ -448,7 +460,7 @@ describe('compactNow transaction and failure classification', () => {
const { compact } = detachedService()
const agent = fakeAgent(closedConversation(2), () => undefined)
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
expect((await rejection(() => compact.compactNow(agent, SIGNAL))).code).toBe('busy')
expect(compact.calls).toHaveLength(0)
})
@@ -685,7 +697,13 @@ describe('compactNow transaction and failure classification', () => {
const controller = new AbortController()
controller.abort(reason)
await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason)
let thrown: unknown
try {
void compact.compactNow(agent, controller.signal)
} catch (error: unknown) {
thrown = error
}
expect(thrown).toBe(reason)
expect(reserve).not.toHaveBeenCalled()
expect(measure).not.toHaveBeenCalled()
expect(compact.calls).toHaveLength(0)
@@ -713,6 +731,21 @@ describe('compactNow transaction and failure classification', () => {
.toContain('summarizer aborted')
})
it('classifies agent cancellation during maintenance as an expected cancellation', async () => {
const { compact } = detachedService()
const controller = new AbortController()
const reason = new Error('agent cancelled maintenance')
const session = closedConversation(2)
const agent = fakeAgent(session, () => () => undefined, controller.signal)
compact.duringSummary = () => { controller.abort(reason) }
compact.error = new Error('summarizer observed cancellation')
const error = await rejection(compact.compactNow(agent, SIGNAL))
expect(error.code).toBe('cancelled')
expect(error.cause).toBe(reason)
})
it('aborts before committing when cancellation lands after summarization', async () => {
const { compact } = detachedService()
const controller = new AbortController()

View File

@@ -110,7 +110,7 @@ describe('CompactService seam', () => {
const signal = new AbortController().signal
expect(await svc.compactNow({
...stubAgent(session),
reserveTurnAdmission: () => () => undefined,
runMaintenance: task => task(new AbortController().signal),
}, signal)).toBeNull()
expect(svc.lastSignal).toBe(signal)
})

View File

@@ -48,6 +48,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
steer: () => {},
inject: () => { throw new Error('time-context must append directly to the open step') },
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
}

View File

@@ -104,6 +104,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
steer: () => {},
inject: () => { throw new Error('tmux-context must append directly to the open step') },
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
}

View File

@@ -201,12 +201,8 @@ export function apply(ctx: Context, config: Config): void {
}
const waitForProjections = async (agent: Agent): Promise<void> => {
while (true) {
const projection = projectionTails.get(agent)
if (projection === undefined) return
await projection
if (projectionTails.get(agent) === projection) return
}
let projection: Promise<void> | undefined
while ((projection = projectionTails.get(agent)) !== undefined) await projection
}
ctx.on('agent/pre-step', async (

View File

@@ -182,6 +182,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
steer: () => {},
inject: () => { throw new Error('workspace-context must append directly to the open step') },
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
}
@@ -2640,7 +2641,13 @@ describe('dynamic nested workspace context injection', () => {
warmCache.set(agent.session, new Map(loaded.versions))
const options = {
authorityMessages,
scopeMessages: [],
scopeMessages: [createUserMessage({
content: [{ type: 'text', text: 'pending baseline duplicate' }],
source: {
kind: 'workspace-instructions',
changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
},
})],
touchedPaths: [],
includeBaselineScopes: false,
signal: testToolSignal,
@@ -3497,6 +3504,14 @@ describe('dynamic nested workspace context injection', () => {
signal: testToolSignal,
callId: CallId('null-arguments'), name: 'read', arguments: null, agent,
}), plainResult)
ctx.emit('tools/result', stubToolExecution({
signal: testToolSignal,
callId: CallId('missing-path'), name: 'read', arguments: {}, agent,
}), plainResult)
ctx.emit('tools/result', stubToolExecution({
signal: testToolSignal,
callId: CallId('non-string-path'), name: 'read', arguments: { file_path: 1 }, agent,
}), plainResult)
ctx.emit('tools/result', stubToolExecution({
signal: testToolSignal,
callId: CallId('blank-path'), name: 'read', arguments: { file_path: ' ' }, agent,
@@ -3514,6 +3529,35 @@ describe('dynamic nested workspace context injection', () => {
}
})
it('warns when an asynchronous file-result projection fails', async () => {
const ctx = new Context()
try {
await ctx.plugin(RecordingFileSystem)
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
const fs = ctx.fs as RecordingFileSystem
const agent = stubAgent('/')
const failure = new Error('projection failed')
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
fs.entries.set('/.git', { type: 'directory' })
fs.entries.set('/AGENTS.md', { type: 'file', content: 'workspace rule' })
vi.spyOn(agent.inbox, 'prepend').mockImplementationOnce(() => { throw failure })
ctx.emit('tools/result', stubToolExecution({
signal: testToolSignal,
callId: CallId('projection-failure'),
name: 'read',
arguments: { file_path: 'file.txt' },
agent,
}), { content: [], isError: false, value: null })
await vi.waitFor(() => {
expect(warn).toHaveBeenCalledWith('workspace instruction refresh failed: %o', failure)
})
} finally {
await ctx.fiber.dispose()
}
})
it('does not attach nested instructions when the byte budget is disabled', async () => {
const root = await tempRepo()
const home = await tempRepo()

View File

@@ -264,7 +264,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): Promise<CompactionResult | null>',
jsDoc: '/**\n * Explicitly compact useful history even below automatic pressure thresholds.\n * Implementations reserve idle turn admission synchronously before any\n * asynchronous work, select a useful range without writing on a no-op, then\n * append a standalone `compact/start` before summarization. That durable\n * marker is the compaction lock until one `compact/end` attempt. Later waking\n * prompts remain accepted in FIFO order and start only after the optional\n * durability checkpoint and admission release. Context injected while the\n * summary runs may sit between the marker pair; only the selected span must\n * remain stable.\n *\n * @param agent - idle agent whose durable history should be compacted.\n * @param signal - command-owned cancellation forwarded to summarization.\n * @returns the compaction result, or `null` when no safe useful range exists.\n * @throws {@link ManualCompactionError} for expected busy, changed-span,\n * summarization/shrink, commit-stage, or persistence failures, and the exact\n * abort reason when cancelled. Failed attempts remain visible in the log.\n */',
jsDoc: '/**\n * Explicitly compact useful history even below automatic pressure thresholds.\n * Implementations synchronously start an idle task before any asynchronous\n * work, select a useful range without writing on a no-op, then\n * append a standalone `compact/start` before summarization. That durable\n * marker is the compaction lock until one `compact/end` attempt. Later waking\n * prompts remain accepted in FIFO order and start only after the optional\n * durability checkpoint and idle-task settlement. Context injected while the\n * summary runs may sit between the marker pair; only the selected span must\n * remain stable.\n *\n * @param agent - idle agent whose durable history should be compacted.\n * @param signal - cancellation scoped to this compaction request.\n * @returns the compaction result, or `null` when no safe useful range exists.\n * @throws {@link ManualCompactionError} for expected busy, agent-cancellation,\n * changed-span, summarization/shrink, commit-stage, or persistence failures;\n * an aborted request preserves its exact abort reason. Failed attempts remain\n * visible in the log.\n */',
},
{
signature: 'abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>',
@@ -747,8 +747,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
jsDoc: '/** Emit `session/created` exactly once for an {@link enter}ed session (with\n * the carrier {@link enter} captured). Separate from {@link enter} so the\n * caller can yield the detach disposer first (rollback safety — see\n * {@link enter}).\n * @param session - the entered session to announce to listeners.\n * @throws if the session is not live or its announcement already began,\n * including a reentrant call from a creation listener. */',
},
{
signature: 'async flush(session: Session): Promise<void>',
jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the loop\'s turn-end checkpoint, idle\n * injection, teardown drains) must come through here rather than dispatch a\n * raw `ctx.parallel(\'session/flush\', …)` — one owner, one spelling, and the\n * scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns resolves when every flush listener has settled; after all settle,\n * rejects with the first registered listener failure if any listener failed.\n */',
signature: 'async flush(session: Session): Promise<boolean>',
jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the loop\'s turn-end checkpoint, idle\n * injection, teardown drains) must come through here rather than dispatch a\n * raw `ctx.parallel(\'session/flush\', …)` — one owner, one spelling, and the\n * scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns whether at least one durability listener participated, after every\n * listener has settled successfully.\n * @throws the first registered listener failure after every listener settles.\n */',
},
{
signature: 'get(id: SessionId): Session | undefined',
@@ -886,8 +886,32 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
key: 'subagents',
summary: 'Named provider registry and capability-checked start surface.',
summary: 'Named provider registry with one-shot runs, durable discovery, and continuable-child operations.',
methods: [
{
signature: 'async startContinuable(spec: ContinuableStartSpec): Promise<ContinuableStart>',
jsDoc: '/**\n * Establish one durable continuable child and deliver its initial prompt.\n * Resolves when the child\'s inbox accepts that prompt, without waiting for the\n * turn to start or for the message to reach the Session log; any earlier\n * failure rejects with no ids and rolls back the child entirely.\n * @param spec - provider, delegation request, and caller cancellation.\n * @returns the durable child id and the accepted prompt\'s message id.\n * @throws when continuation services are unavailable or materialization fails.\n */',
},
{
signature: 'async followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise<MessageId>',
jsDoc: '/**\n * Deliver one later message to a continuable child as its next FIFO turn. A\n * resident child\'s Agent inbox accepts it directly (waking a `waiting`\n * Activation), while an absent one is cold-resumed from its persisted\n * Session. The Agent inbox is the only queue, so every accepted message has\n * one observable order.\n * @param parent - the exact live direct parent authorizing this delivery.\n * @param childId - durable child session id.\n * @param content - user-role content to deliver.\n * @param options - durable provenance and caller cancellation, which stops the\n * operation only before inbox acceptance.\n * @returns the accepted message\'s inbox id.\n * @throws when continuation services are unavailable, parent authority is\n * rejected, or the message was not admitted.\n */',
},
{
signature: 'async reportFrom( child: Agent, content: ContentBlock[], options: SubagentReportOptions, ): Promise<MessageId>',
jsDoc: '/**\n * Deliver selected content from one live continuable child to its durable\n * direct parent. The child is the authority credential; callers cannot name a\n * recipient. Reporting does not conclude the child\'s turn or Activation.\n * @param child - exact live reporting child.\n * @param content - selected model-facing content.\n * @param options - parent scheduling and pre-acceptance cancellation.\n * @returns the stable identity of the parent-accepted message.\n * @throws when continuation services are unavailable, sender authorization\n * fails, or the direct parent is not live.\n */',
},
{
signature: 'registerContinuableSetup(contribution: ContinuableSetupContribution): () => void',
jsDoc: '/**\n * Compose one deployment capability into every continuable child\'s\n * unpublished creation context on fresh creation and cold resume. Grants wait\n * for the next Activation; removing the contribution revokes every resident\n * installation immediately.\n * @param contribution - synchronous child-scope installer.\n * @returns the exact Cordis effect disposer.\n */',
},
{
signature: 'async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>',
jsDoc: '/**\n * Close continuable admission below exact live parent Agents, stop only their\n * visible descendant Activations synchronously, then await admitted scoped\n * materializations and release those forests child-first. The scoped cutoff\n * lasts until each exact parent leaves the registry; unrelated parent trees\n * remain live.\n * @param parents - exact host-owned parent Agents entering teardown.\n * @returns once every retained descendant Activation released its `AgentHandle`.\n * @throws an aggregate error after all branches settle when any failed.\n */',
},
{
signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>',
jsDoc: '/**\n * Enumerate the parent\'s direct session-backed subagents from the\n * live-preferred session corpus without loading or resuming an Agent. Session\n * query supplies lineage, candidate order, event reads, and live state; this\n * service interprets descriptor mode, activity, and per-child diagnostics\n * without consulting Agent registrations, Activations, or providers.\n *\n * The trace and exact descriptor read receive `signal`; the full event-list\n * read has no signal parameter, so the scan rechecks cancellation around\n * every await and between candidates. Query rejections that settle after an\n * abort become a stable `SubagentError` with code `CANCELLED`.\n * @param parentSessionId - parent session whose direct children are listed.\n * @param signal - caller-owned cancellation forwarded where supported and\n * observed around every query await.\n * @returns children and per-child diagnostics in stable trace order.\n * @throws {@link SubagentError} when session query is unavailable or the\n * caller cancels the scan.\n */',
},
{
signature: 'registerProvider(provider: SubagentProvider): () => void',
jsDoc: '/**\n * Register a provider under its name. Registration is effect-scoped and HMR\n * safe; removing a provider blocks new starts but does not revoke runs that\n * were already returned to their holders.\n * @param provider - the trusted provider implementation.\n * @returns the exact Cordis effect disposer.\n */',
@@ -902,7 +926,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>',
jsDoc: '/**\n * Establish a ready child on the named provider. Capability and semantic\n * checks run before delegation. Provider ownership lasts until its promise\n * fulfills; a rejection therefore has no run for the caller to dispose and\n * emits no run lifecycle events.\n * @param name - the provider to use.\n * @param request - child prompt, parent, signal, and optional capabilities.\n * @returns the ready holder-owned run.\n */',
jsDoc: '/**\n * Establish a published child on the named provider. Capability and semantic\n * checks run before delegation. Provider ownership lasts until its promise\n * fulfills; a rejection therefore has no run for the caller to dispose and\n * emits no run lifecycle events. Post-publication turn and infrastructure\n * failures settle through the returned run.\n * @param name - the provider to use.\n * @param request - child label, prompt, parent, signal, and optional capabilities.\n * @returns the published holder-owned run.\n */',
},
],
},
@@ -1374,7 +1398,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'session/flush',
mode: 'parallel',
signature: '\'session/flush\'(this: Scoped<Session>, session: Session): Promise<void> | void',
jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Dispatch through\n * {@link SessionStore.flush}. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */',
jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */',
summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.',
},
{
@@ -1402,8 +1426,8 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'subagent/end',
mode: 'emit',
signature: '\'subagent/end\'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void',
jsDoc: '/**\n * A ready child settled. Scope-filtered dispatch uses the same delegating\n * parent carrier as `subagent/start`, so the lifecycle pair reaches the\n * same scoped audience.\n * @param info - the run identity and terminal outcome.\n * @dshScopeScan unsupported\n * @mode emit\n */',
summary: 'A ready child settled.',
jsDoc: '/**\n * A published child settled. Scope-filtered dispatch uses the same delegating\n * parent carrier as `subagent/start`, so the lifecycle pair reaches the\n * same scoped audience.\n * @param info - the run identity and terminal outcome.\n * @dshScopeScan unsupported\n * @mode emit\n */',
summary: 'A published child settled.',
},
{
name: 'subagent/provider-added',
@@ -1423,8 +1447,8 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'subagent/start',
mode: 'emit',
signature: '\'subagent/start\'(this: Scoped<SubagentService>, info: SubagentRunInfo): void',
jsDoc: '/**\n * A provider established a ready child. For in-process providers,\n * `ctx.agents.get(info.id)` resolves during this notification.\n * Scope-filtered dispatch keys the carrier by the delegating parent, so a\n * parent-scoped listener observes only its own delegations. Paired with\n * `subagent/end`.\n * @param info - the provider and ready child identity.\n * @dshScopeScan unsupported\n * @mode emit\n */',
summary: 'A provider established a ready child.',
jsDoc: '/**\n * A provider established a published child. For in-process providers,\n * `ctx.agents.get(info.id)` resolves during this notification.\n * Scope-filtered dispatch keys the carrier by the delegating parent, so a\n * parent-scoped listener observes only its own delegations. Paired with\n * `subagent/end`.\n * @param info - the provider and published child identity.\n * @dshScopeScan unsupported\n * @mode emit\n */',
summary: 'A provider established a published child.',
},
{
name: 'system-prompt/assemble',
@@ -1500,8 +1524,8 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'workflow/agent-start',
mode: 'emit',
signature: '\'workflow/agent-start\'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void',
jsDoc: '/**\n * One `agent()` call established a ready child run. Paired with\n * {@link Events[\'workflow/agent-end\']} by `agent.seq`. A call that never\n * receives a ready run from the provider emits neither\n * event in this pair.\n * @param info - the run\'s identity snapshot.\n * @param agent - the call\'s sequence number, label, phase, and child id.\n * @mode emit\n */',
summary: 'One `agent()` call established a ready child run.',
jsDoc: '/**\n * One `agent()` call established a published child run. Paired with\n * {@link Events[\'workflow/agent-end\']} by `agent.seq`. A call that never\n * receives a published run from the provider emits neither\n * event in this pair.\n * @param info - the run\'s identity snapshot.\n * @param agent - the call\'s sequence number, label, phase, and child id.\n * @mode emit\n */',
summary: 'One `agent()` call established a published child run.',
},
{
name: 'workflow/end',
@@ -1541,7 +1565,7 @@ 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 inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}',
declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}',
},
{
name: 'AgentCancelCause',
@@ -1559,6 +1583,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'AgentOptions',
declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n}',
},
{
name: 'AgentSetup',
declaration: 'export type AgentSetup = (agentCtx: Context) => AgentSetupCommit | Promise<AgentSetupCommit | void> | void;',
},
{
name: 'AgentSetupCommit',
declaration: 'export interface AgentSetupCommit {\n commit(): void;\n}',
},
{
name: 'AgentStatus',
declaration: 'export type AgentStatus = \'idle\' | \'running\';',
@@ -1759,9 +1791,33 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ContentBlockType',
declaration: 'export type ContentBlockType = keyof ContentBlockMap;',
},
{
name: 'ContinuableCreateRequest',
declaration: 'export interface ContinuableCreateRequest {\n readonly sessionId: SessionId;\n readonly parent: Agent;\n readonly signal: AbortSignal;\n}',
},
{
name: 'ContinuableCreateSpec',
declaration: 'export interface ContinuableCreateSpec {\n readonly seed?: readonly SessionEvent[];\n}',
},
{
name: 'ContinuableSetupContribution',
declaration: 'export type ContinuableSetupContribution = (childCtx: Context) => () => void;',
},
{
name: 'ContinuableStart',
declaration: 'export interface ContinuableStart {\n readonly childId: SessionId;\n readonly messageId: MessageId;\n}',
},
{
name: 'ContinuableStartSpec',
declaration: 'export interface ContinuableStartSpec {\n readonly provider: string;\n readonly label: string;\n readonly request: Omit<SubagentStartRequest, \'label\' | \'signal\' | \'outputSchema\'>;\n readonly signal: AbortSignal;\n}',
},
{
name: 'ContinuableSubagentDescriptorData',
declaration: 'export interface ContinuableSubagentDescriptorData extends SubagentDescriptorBase {\n readonly mode: \'continuable\';\n readonly label: string;\n readonly agentProvider?: string;\n readonly agentModel?: string;\n readonly persona?: string;\n readonly toolFilter?: ToolRestriction;\n}',
},
{
name: 'CreateAgentOptions',
declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}',
},
{
name: 'CreateGoalRequest',
@@ -1769,7 +1825,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'CreateSessionOptions',
declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n}',
declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n}',
},
{
name: 'CredentialInfo',
@@ -2045,7 +2101,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ManualCompactAgentContext',
declaration: 'export interface ManualCompactAgentContext extends CompactAgentContext {\n reserveTurnAdmission(): (() => void) | undefined;\n}',
declaration: 'export interface ManualCompactAgentContext extends CompactAgentContext {\n runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>;\n}',
},
{
name: 'Message',
@@ -2071,6 +2127,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ObjectJsonSchema',
declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};',
},
{
name: 'OneShotSubagentDescriptorData',
declaration: 'export interface OneShotSubagentDescriptorData extends SubagentDescriptorBase {\n readonly mode: \'one-shot\';\n readonly label?: string;\n}',
},
{
name: 'PermissionSelect',
declaration: 'export interface PermissionSelect {\n options: PresetOption[];\n currentValue: string;\n}',
@@ -2255,9 +2315,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ResolvedRetryPolicy',
declaration: 'export type ResolvedRetryPolicy = ResolvedNormalRetryPolicy | ResolvedAlwaysRetryPolicy;',
},
{
name: 'ResolvedSubagentStartRequest',
declaration: 'export interface ResolvedSubagentStartRequest extends SubagentStartRequest {\n readonly descriptor: SubagentDescriptorData;\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}',
declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}',
},
{
name: 'SandboxEnforcement',
@@ -2385,7 +2449,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionHeader',
declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n}',
declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n}',
},
{
name: 'SessionId',
@@ -2619,9 +2683,29 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SubagentCapabilities',
declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}',
},
{
name: 'SubagentDescriptorData',
declaration: 'export type SubagentDescriptorData = OneShotSubagentDescriptorData | ContinuableSubagentDescriptorData;',
},
{
name: 'SubagentFollowupOptions',
declaration: 'export interface SubagentFollowupOptions {\n readonly source: MessageSource;\n readonly signal: AbortSignal;\n}',
},
{
name: 'SubagentListEntry',
declaration: 'export type SubagentListEntry = {\n readonly kind: \'child\';\n readonly id: SessionId;\n readonly activity: \'running\' | \'inactive\';\n readonly hasChildren: boolean;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n}) | {\n readonly kind: \'diagnostic\';\n readonly id: SessionId;\n readonly reason: \'corrupt\' | \'unsupported\' | \'unavailable\';\n};',
},
{
name: 'SubagentProvider',
declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentStartRequest): Promise<SubagentRun>;\n}',
declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: ResolvedSubagentStartRequest): Promise<SubagentRun>;\n prepareContinuable?(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec>;\n}',
},
{
name: 'SubagentReportDelivery',
declaration: 'export type SubagentReportDelivery = \'quiet\' | \'wakeup\';',
},
{
name: 'SubagentReportOptions',
declaration: 'export interface SubagentReportOptions {\n readonly delivery: SubagentReportDelivery;\n readonly signal: AbortSignal;\n}',
},
{
name: 'SubagentResult',
@@ -2629,11 +2713,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SubagentRun',
declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise<SubagentResult>;\n dispose(): Promise<void>;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise<SubagentRun>;\n}',
declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise<SubagentResult>;\n dispose(): Promise<void>;\n}',
},
{
name: 'SubagentStartRequest',
declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}',
declaration: 'export interface SubagentStartRequest {\n readonly label?: string;\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}',
},
{
name: 'SubagentStopReason',

View File

@@ -190,6 +190,7 @@ export class ReactLoopAgent implements Agent {
} catch (_error) {
// Reported failures and cancellation are contained at the driver boundary.
} finally {
/* v8 ignore next -- kick owns a running phase until this driver boundary */
if (this.phase.kind === 'running') {
this.setPhase({ kind: 'idle', lastTurn: this.phase.turn })
}
@@ -197,6 +198,7 @@ export class ReactLoopAgent implements Agent {
}
private async preStep(target: InboxTarget, position: { turn: number; step: number }): Promise<PreparedStep> {
/* v8 ignore next -- private callers establish the running phase before proposing a step */
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": pre-step outside running phase`)
const signal = this.phase.abort.signal
const claimed = this.inbox.claim(target)
@@ -294,6 +296,7 @@ export class ReactLoopAgent implements Agent {
}
private async step(assembly: PromptAssembly): Promise<StepEndReason | null> {
/* v8 ignore next -- private callers establish the running phase before executing a step */
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": step outside running phase`)
const { turn, step, abort: { signal } } = this.phase
signal.throwIfAborted()

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { createUserMessage, freezeMessage, CallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
import LlmService, { createUserMessage, CallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason, type UserMessage } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools'
@@ -59,152 +59,6 @@ function inboxText(message: UserMessage): string {
.join('')
}
describe('addressable inbox operations', () => {
it('edits in place and removes exactly one queued item', async () => {
const adapter = new MockAdapter([
textResponse('first reply'),
textResponse('edited reply'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('inbox-actions'), { provider: 'mock', model: 'mock' })
const preStep = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.on('agent/pre-step', async (_subject, messages, _signal, next) => {
if (messages[0]?.content[0]?.type === 'text' && messages[0].content[0].text === 'first') {
preStep.resolve(undefined)
await release.promise
}
return next()
})
send(agent, 'first')
await preStep.promise
send(agent, 'remove me')
send(agent, 'edit me')
const pending = agent.inbox.nextTurn
expect(pending.map(inboxText)).toEqual(['remove me', 'edit me'])
const remove = pending[0]!
const edit = pending[1]!
expect(agent.inbox.splice('next-turn', 1, 1, [freezeMessage({
...edit,
content: [{ type: 'text', text: 'edited' }],
})])).toEqual([edit])
expect(agent.inbox.splice('next-turn', 0, 1, [])).toEqual([remove])
const idle = waitForIdle(ctx, agent)
release.resolve(undefined)
await idle
expect(agent.session.events
.filter(event => event.type === 'user/message')
.map(event => event.type === 'user/message'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
: ''))
.toEqual(['first', 'edited'])
expect(agent.inbox.splice('next-turn', 0, 1, [])).toEqual([])
})
it('strictly transfers a queued occurrence into the open turn', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('queue-to-steer'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<{ kind: 'allow' }>()
ctx.on('agent/prompt-submit', async () => {
entered.resolve(undefined)
return decision.promise
})
const enqueued: InboxItem[] = []
const discarded: InboxItem[] = []
ctx.on('agent/inbox/enqueue', (subject, item) => {
if (subject === agent) enqueued.push(item)
})
ctx.on('agent/inbox/discard', (subject, items) => {
if (subject === agent) discarded.push(...items)
})
const idle = waitForIdle(ctx, agent)
send(agent, 'open the turn')
const receipt = agent.steer(createUserMessage({
content: [{ type: 'text', text: 'steer this message' }],
source: { kind: 'user' },
}))
await entered.promise
const queued = enqueued.find(item => inboxText(item) === 'steer this message')!
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied')
const steering = enqueued.find(item => item.placement === 'steering')!
expect(steering.id).not.toBe(queued.id)
expect(steering.message).toBe(queued.message)
expect(discarded).toEqual([queued])
decision.resolve({ kind: 'allow' })
await idle
expect(agent.session.events.flatMap(event =>
event.type === 'steering/message' ? [event.data.message] : [],
)).toEqual([queued.message])
expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 1, step: 1 })
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('not-found')
})
it('keeps a queued occurrence when the next-step window is closed', () => {
const ctx = new Context()
const session = new Session(SessionId('queue-to-steer-closed'))
const agent = new ReactLoopAgent(ctx, session.id, {}, session)
const enqueued: InboxItem[] = []
const discarded: InboxItem[] = []
ctx.on('agent/inbox/enqueue', (_subject, item) => { enqueued.push(item) })
ctx.on('agent/inbox/discard', (_subject, items) => { discarded.push(...items) })
agent.send(
createUserMessage({ content: [{ type: 'text', text: 'stay queued' }], source: { kind: 'user' } }),
{ target: 'next-turn', wakeup: false },
)
const queued = enqueued[0]!
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('steer-unavailable')
expect(discarded).toEqual([])
expect(agent.updateInbox(queued.id, { kind: 'remove' })).toBe('applied')
})
it('accounts for both occurrences when steering enqueue cancels reentrantly', async () => {
const adapter = new MockAdapter([textResponse('unused')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('queue-to-steer-cancel'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<{ kind: 'allow' }>()
ctx.on('agent/prompt-submit', async () => {
entered.resolve(undefined)
return decision.promise
})
const enqueued: InboxItem[] = []
const discarded: InboxItem[] = []
ctx.on('agent/inbox/enqueue', (subject, item) => {
if (subject !== agent) return
enqueued.push(item)
if (item.placement === 'steering') agent.cancel({ kind: 'user' })
})
ctx.on('agent/inbox/discard', (subject, items) => {
if (subject === agent) discarded.push(...items)
})
const idle = waitForIdle(ctx, agent)
send(agent, 'open the turn')
await entered.promise
send(agent, 'cancel during conversion')
const queued = enqueued.find(item => inboxText(item) === 'cancel during conversion')!
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied')
const steering = enqueued.find(item => item.placement === 'steering')!
expect(discarded).toEqual([steering, queued])
decision.resolve({ kind: 'allow' })
await idle
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
})
})
describe('assistant replay provenance', () => {
it('records adapter replay state with the assembled assistant content', async () => {
const response = textResponse('unchanged')

View File

@@ -204,6 +204,33 @@ describe('agent/pre-step', () => {
expect(sent).toContain('extra ctx')
})
it('does not open another step when a completed turn rewrites pending input to empty', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('empty-completed-continuation'), {
provider: 'mock',
model: 'mock',
})
ctx.on('agent/turn-stopping', (subject) => {
subject.inject(createUserMessage({
content: [{ type: 'text', text: 'pending context' }],
source: { kind: 'plugin', plugin: 'test' },
}))
})
ctx.on('agent/pre-step', async (_subject, _messages, context, next) => {
const decision = await next()
return context.step === 1 || decision.kind === 'reject'
? decision
: { kind: 'enter', messages: [] }
})
send(agent, 'finish once')
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)
expect(events(agent).filter(event => event.type === 'step/start')).toHaveLength(1)
})
it('reject drops the claimed prompt before any turn or model call', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)

View File

@@ -73,6 +73,35 @@ describe('agent loop', () => {
expect(adapter.requests[0]?.maxTokens).toBe(256)
})
it('cancels queued wakeup work together with an active maintenance task', async () => {
const adapter = new MockAdapter([textResponse('unused')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('cancel-maintenance-wakeup'), {
provider: 'mock',
model: 'mock',
})
const started = Promise.withResolvers<undefined>()
const maintenance = agent.runMaintenance(async (signal) => {
started.resolve(undefined)
await new Promise<void>((_resolve, reject) => {
signal.addEventListener('abort', () => {
reject(new Error('maintenance aborted', { cause: signal.reason }))
}, { once: true })
})
})
await started.promise
send(agent, 'discard this wakeup')
agent.cancel({ kind: 'user' })
send(agent, 'park after cancellation')
await expect(maintenance).rejects.toThrow('maintenance aborted')
await agent.whenIdle()
expect(agent.inbox.nextTurn).toHaveLength(1)
expect(adapter.requests).toEqual([])
agent.cancel({ kind: 'user' })
})
it('runs a simple turn: queued message → model → idle, with ordered events', async () => {
const adapter = new MockAdapter([textResponse('hello there')])
const ctx = await harness(adapter)

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import { RuntimeContextProjection } from '../src/runtime-context.ts'
const SOURCE = '@deepseek-ai/dsh-system-prompt'
function contextMessage(text: string) {
return createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: SOURCE },
})
}
describe('RuntimeContextProjection', () => {
it('restores the latest visible owned snapshot and ignores other sessions', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('runtime-context-replay'))
const retained = session.append('user/message', contextMessage('retained'), { surfaceOp: 'append' })
const shadowed = session.append('user/message', contextMessage('shadowed'), { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary' }],
source: { kind: 'plugin', plugin: 'test-compaction' },
}), {
surfaceOp: { op: 'replace', start: shadowed.seq, end: shadowed.seq },
sourceEventSeqs: [shadowed.seq],
})
const projection = new RuntimeContextProjection(ctx, session)
expect(session.surface.nodes).toContain(retained.seq)
expect(projection.project('retained')).toBeUndefined()
const other = ctx.sessions.create(SessionId('runtime-context-other'))
other.append('user/message', contextMessage('other'), { surfaceOp: 'append' })
expect(projection.project('retained')).toBeUndefined()
})
})

View File

@@ -30,6 +30,7 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
return Object.assign(agent, overrides)
@@ -69,6 +70,25 @@ describe('Inbox', () => {
expect(inbox.nextTurn).toEqual([replacement])
})
it('normalizes splice coordinates, rejects duplicate identities, and reports missing removals', () => {
const session = new Session(SessionId('splice-inbox'))
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
const first = createUserMessage({
content: [{ type: 'text', text: 'first' }],
source: { kind: 'user' },
})
const second = createUserMessage({
content: [{ type: 'text', text: 'second' }],
source: { kind: 'user' },
})
inbox.splice('next-turn', Number.NaN, Number.NaN, [first, second])
expect(inbox.nextTurn).toEqual([first, second])
expect(inbox.splice('next-turn', -1, 1, [])).toEqual([second])
expect(inbox.remove('next-turn', second.id)).toBe(false)
expect(() => { inbox.append('next-step', first) }).toThrow(`message "${first.id}" is already pending`)
})
it('clears both pending lists as durable cancellations', () => {
const session = new Session(SessionId('clear-inbox'))
const discarded: UserMessage[] = []

View File

@@ -30,6 +30,17 @@ export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from '
export { foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
/**
* Find the latest completed model turn in an event sequence.
* @param events - session events, or an owned suffix, to inspect.
* @returns the latest turn end, or `undefined`.
*/
export function findLastMessageTurnEnd(
events: readonly SessionEvent[],
): SessionEvent<'turn/end'> | undefined {
return events.findLast(event => event.type === 'turn/end')
}
declare module 'cordis' {
interface Context {
sessions: SessionStore

View File

@@ -188,6 +188,12 @@ describe('session-log invariants', () => {
skipped.append('step/end', { turn: 1, step: 1 })
expect(() => skipped.append('step/start', { turn: 1, step: 3 }))
.toThrow(/expected step 2 in turn 1, got 3/)
expect(() => skipped.append('turn/end', {
turn: 1,
step: 0,
reason: { kind: 'completed' },
})).toThrow(/expected last step 1, got 0/)
})
it('requires step-scoped stream and tool events to name the open step', async () => {

View File

@@ -410,6 +410,27 @@ describe('runOneShot and executeCli', () => {
}))
await started
const followup = agent.followup.bind(agent)
let injectedBeforeReceipt = false
agent.followup = (input) => {
if (!injectedBeforeReceipt && input.source.kind === 'user') {
injectedBeforeReceipt = true
agent.inbox.append('next-step', createUserMessage({
content: [{ type: 'text', text: 'wrong receipt' }],
source: { kind: 'plugin', plugin: 'test-wrong-receipt' },
}))
other.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'unrelated session event' }],
source: { kind: 'plugin', plugin: 'test' },
}), { surfaceOp: 'append' })
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'uncorrelated main-session event' }],
source: { kind: 'plugin', plugin: 'test-before-receipt' },
}), { surfaceOp: 'append' })
}
followup(input)
}
let replacementQueued = false
ctx.on('agent/status', (subject, status) => {
if (subject !== agent || status !== 'idle' || replacementQueued) return
@@ -438,6 +459,9 @@ describe('runOneShot and executeCli', () => {
expect(events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'test')).toBe(false)
expect(events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'test-before-receipt')).toBe(false)
})
it('correlates a task whose step history is replaced', async () => {
@@ -511,6 +535,21 @@ describe('runOneShot and executeCli', () => {
} as unknown as AbortSignal
await expect(runOneShot(early.ctx, { task: 'task', signal: fakeSignal })).rejects.toThrow('interrupted')
const raced = await harness([textResponse('unused')])
let registrations = 0
const racedSignal = {
aborted: false,
reason: 'cancel before followup',
addEventListener: (_type: string, listener: () => void) => {
registrations += 1
if (registrations === 2) listener()
},
removeEventListener: () => {},
} as unknown as AbortSignal
await expect(runOneShot(raced.ctx, { task: 'task', signal: racedSignal }))
.rejects.toThrow('cancel before followup')
expect(raced.agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
const preBootAbort = new AbortController()
preBootAbort.abort('before boot completed')
const preBoot = await invoke(early.ctx, ['task'], { signal: preBootAbort.signal })

View File

@@ -41,6 +41,7 @@ function agent(ctx: Context, cwd: string): Agent {
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)

View File

@@ -34,6 +34,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session }
steer: () => {},
inject(input) { inbox.append('next-step', input) },
cancel() { status = 'idle' },
runMaintenance: task => task(new AbortController().signal),
whenIdle() { return Promise.resolve() },
}
return { agent, session }

View File

@@ -155,7 +155,6 @@ export function apply(ctx: Context): void {
const attempt = state.attempt
if (attempt !== undefined) {
if (attempt.phase === 'queued' || attempt.phase === 'claimed') return
state.attempt = undefined
state.needsCheckpoint = true
state.requested = true
@@ -346,10 +345,10 @@ export function apply(ctx: Context): void {
}
ctx.on('agent/pre-step', async (agent, messages, { signal }, next): Promise<PreStepDecision> => {
const submitted = messages.find(message => isGoalRoundSource(message.source))
const submitted = messages.find((message): message is UserMessage & { source: GoalMessageSource } =>
isGoalRoundSource(message.source))
if (submitted === undefined) return next()
const { content, source } = submitted
if (!isGoalRoundSource(source)) return next()
const state = stateFor(agent)
let valid = false
try {
@@ -377,11 +376,8 @@ export function apply(ctx: Context): void {
// returns to idle without a turn, so a still-queued reservation would
// starve every later drive pass. Clear it and let the driver
// reschedule the round.
const attempt = state.attempt
if (attempt !== undefined && sameRound(source, attempt) && attempt.phase === 'claimed') {
state.attempt = undefined
requestDrive(state)
}
state.attempt = undefined
requestDrive(state)
throw error
}
if (signal.aborted) {
@@ -389,8 +385,7 @@ export function apply(ctx: Context): void {
return decision
}
if (decision.kind === 'reject') {
const attempt = state.attempt
if (attempt !== undefined && sameRound(source, attempt)) state.attempt = undefined
state.attempt = undefined
const goal = currentGoal(state)
if (goal !== undefined && goal.id === source.goalId && goal.revision === source.revision
&& goal.phase === 'active' && goal.activation === 'armed') {
@@ -409,11 +404,7 @@ export function apply(ctx: Context): void {
valid = false
}
if (!valid) {
const attempt = state.attempt
if (attempt !== undefined && sameRound(source, attempt)) {
attempt.stale = true
state.attempt = undefined
}
state.attempt = undefined
restoreOtherClaimed(agent, decision.messages, submitted.id)
requestDrive(state)
return { kind: 'reject' }
@@ -438,8 +429,8 @@ export function apply(ctx: Context): void {
const attempt = state.attempt
if (attempt !== undefined) {
attempt.stale = true
if ((attempt.phase === 'claimed' || attempt.phase === 'admitted')
&& state.agent.status === 'running') {
/* v8 ignore next -- followup reserves the live agent before publishing a queued attempt */
if (state.agent.status === 'running') {
state.agent.cancel({ kind: 'parent' })
waits.push(state.agent.whenIdle())
}

View File

@@ -387,12 +387,35 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(1)
})
it('does not block a goal that downstream paused before rejecting its prompt', async () => {
const test = await harness([])
test.ctx.on('agent/pre-step', async (agent, messages, _context, next) => {
if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0)) {
return next()
}
const goal = test.ctx.goals.get(agent)
if (goal === undefined) throw new Error('missing goal before downstream pause')
test.ctx.goals.pause(agent, { id: goal.id, revision: goal.revision })
return { kind: 'reject' as const }
})
test.ctx.goals.create(test.agent, { objective: 'pause before rejection' })
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused')
expect(goal).toMatchObject({ phase: 'paused' })
expect(test.adapter.requests).toEqual([])
})
it('restores non-goal step context when a claimed reservation becomes stale', async () => {
const test = await harness([textResponse('side contexts'), textResponse('revised goal')])
const claimedContext = createUserMessage({
content: [{ type: 'text', text: 'claimed context to restore' }],
source: { kind: 'plugin', plugin: 'test' },
})
const roundZeroContext = createUserMessage({
content: [{ type: 'text', text: 'obsolete goal context' }],
source: { kind: 'goal', goalId: GoalId('old-goal'), revision: 1, round: 0 },
})
const queuedStepContext = createUserMessage({
content: [{ type: 'text', text: 'context already queued for the next step' }],
source: { kind: 'plugin', plugin: 'test' },
@@ -406,6 +429,7 @@ describe('same-session goal driving', () => {
if (message.source.kind !== 'goal' || message.source.round <= 0 || staged) return
staged = true
test.agent.inbox.prepend('next-step', claimedContext)
test.agent.inbox.prepend('next-step', roundZeroContext)
})
let edited = false
test.ctx.on('agent/pre-step', async (agent, messages, _context, next) => {
@@ -432,6 +456,7 @@ describe('same-session goal driving', () => {
expect(requestText(test.adapter.requests[0]!)).toContain('claimed context to restore')
expect(requestText(test.adapter.requests[0]!)).toContain('context already queued for the next step')
expect(requestText(test.adapter.requests[0]!)).toContain('context already queued for the next turn')
expect(requestText(test.adapter.requests[0]!)).not.toContain('obsolete goal context')
expect(requestText(test.adapter.requests[0]!)).not.toContain('<goal_round>')
expect(requestText(test.adapter.requests[1]!)).toContain('revised after claim')
expect(requestText(test.adapter.requests[1]!)).not.toContain('stale before admission')
@@ -722,6 +747,19 @@ describe('same-session goal driving', () => {
expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
})
it('leaves round-zero goal context to the ordinary pre-step chain', async () => {
const test = await harness([textResponse('accepted context')])
test.agent.followup(createUserMessage({
content: [{ type: 'text', text: 'goal context' }],
source: { kind: 'goal', goalId: GoalId('context-goal'), revision: 1, round: 0 },
}))
await test.agent.whenIdle()
expect(test.adapter.requests).toHaveLength(1)
expect(requestText(test.adapter.requests[0]!)).toContain('goal context')
})
it('does not invent goal state when ordinary queued work is cancelled', async () => {
const test = await harness([])
test.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'cancel ordinary work' }], source: { kind: 'user' } }))

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/goal/goal/README.md
README.md: caf01b3d2a088281749a73b78b839d60ac041316
README.zh.md: de92b8b9d7757f80511fe128644738ebe2458af1
README.md: fc2a672c11c68ad72437251b087a274e1c4388d3
README.zh.md: e84e8ea78039637a4b0bff7e97cef69ae607cda0

View File

@@ -39,7 +39,7 @@ Policy plugins call the service verbs and react to the scoped `goal/changed` eve
#### What the model sees
Goal mutations do not inject model context. Goal tools return the current state, and a continuation consumer may render the objective and round state when it schedules model work. A future always-visible goal context belongs in a separate context plugin rather than the persistence path.
Goal mutations do not inject model context. Tools such as `get_goal` return the current state, and a continuation consumer may render the objective and round state when it schedules model work. A future always-visible goal context belongs in a separate context plugin rather than the persistence path.
#### Token effect

View File

@@ -39,7 +39,7 @@
#### 模型看到的内容
Goal 变更不会注入模型上下文。Goal 工具返回当前状态;继续执行消费方可以在调度模型工作时渲染目标描述与 Round 状态。未来如果需要始终可见的 goal 上下文,应由独立上下文插件实现,而不是放在持久化路径中。
Goal 变更不会注入模型上下文。`get_goal`工具返回当前状态;继续执行消费方可以在调度模型工作时渲染目标描述与 Round 状态。未来如果需要始终可见的 goal 上下文,应由独立上下文插件实现,而不是放在持久化路径中。
#### Token 影响

View File

@@ -43,6 +43,7 @@ function stubAgentForSession(session: Session): StubAgent {
steer: () => {},
inject(input) { inbox.append('next-step', input) },
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle() { return Promise.resolve() },
}
return {

View File

@@ -46,6 +46,7 @@ function liveAgent(ctx: Context, session: Session): Agent {
inbox.append('next-step', input)
},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle() { return Promise.resolve() },
}
ctx.agents.register(agent)

View File

@@ -39,6 +39,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
this.inbox.append('next-step', input)
},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle() { return Promise.resolve() },
}
return { agent, session, setStatus(value) { status = value } }

View File

@@ -8,16 +8,16 @@ import { mkdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import type { Context } from 'cordis'
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type {
Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus,
} from '@deepseek-ai/dsh-agent'
import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus } from '@deepseek-ai/dsh-agent'
import { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
import { SubagentError } from '@deepseek-ai/dsh-subagent'
import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
import {
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
@@ -28,7 +28,8 @@ import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup,
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
SessionSummary, SettingsNamespaceView, ToolEventView, WorkspaceId, WorkspaceView,
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
WorkspaceId, WorkspaceView,
} from './api/index.ts'
import {
SESSION_SEARCH_RESULT_LIMIT,
@@ -270,6 +271,19 @@ function sessionBlank(session: Session): boolean {
return !session.events.some(event => event.type === 'turn/start')
}
/** Shared Session-header projection for list baselines and creation frames. */
function sessionListFields(header: SessionHeader): {
parentSessionId?: SessionId
origin?: 'subagent'
cwd?: string
} {
return {
...header.parentSession === undefined ? {} : { parentSessionId: header.parentSession },
...header.origin === undefined ? {} : { origin: header.origin },
...header.cwd === undefined ? {} : { cwd: header.cwd },
}
}
/** SessionSummary projection for attached (in-memory) sessions. */
function summarize(session: Session, running: boolean): SessionSummary {
return {
@@ -279,8 +293,7 @@ function summarize(session: Session, running: boolean): SessionSummary {
updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
running,
blank: sessionBlank(session),
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
...sessionListFields(session.header),
}
}
@@ -315,6 +328,7 @@ async function summarizeCold(
// cold session is served as not-blank (its log holds its conversation).
blank: false,
...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
...meta.origin === undefined ? {} : { origin: meta.origin },
/* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
filters those out (legacy logs are not served); the conditional mirrors
summarize() shape. */
@@ -462,6 +476,23 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name:
return undefined
}
/** Render one detached history page through the same presenter path as ordinary history. */
function historyPage(
ctx: Context,
events: readonly SessionEvent[],
beforeSeq: number | undefined,
maxMessages: number | undefined,
): { events: HistoryEntry[]; hasMore: boolean } {
const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
return {
events: page.events.map((event) => {
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
return { event, ...view === undefined ? {} : { view } }
}),
hasMore: page.hasMore,
}
}
/**
* The projection baseline for one history tail page: the registry's
* watermark-cache snapshot — one fully synchronous read (no await between the
@@ -471,10 +502,10 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name:
* registry). An absent registry means the deployment has no projection seam:
* the whole block is absent and clients treat every key as capability-absent.
*/
function projectionsFor(ctx: Context, agent: Agent): SessionProjectionsBlock | undefined {
function projectionsFor(ctx: Context, session: Session): SessionProjectionsBlock | undefined {
const registry = ctx.get('sessionProjections')
if (registry === undefined) return undefined
return registry.snapshot(agent.session)
return registry.snapshot(session)
}
/**
@@ -499,12 +530,120 @@ function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session
}
}
/** Projection baseline for a detached history tail without Agent activation. */
function detachedProjectionsFor(
ctx: Context,
events: readonly SessionEvent[],
): SessionProjectionsBlock | undefined {
const registry = ctx.get('sessionProjections')
if (registry === undefined) return undefined
return registry.restore({}, events, 0).snapshot
}
/** Map continuation admission failures without exposing provider details. */
function subagentPromptError(
request: RpcRequest<{ childSessionId: SessionId }>,
error: unknown,
signal: AbortSignal,
): RpcResponse<never> {
const childSessionId = request.payload.childSessionId
if (signal.aborted) {
return err(request, { code: 'cancelled', message: 'subagent prompt was cancelled', details: {} })
}
if (error instanceof SubagentError) {
switch (error.code) {
case 'NOT_RESUMABLE':
return err(request, {
code: 'subagent-not-resumable',
message: 'subagent cannot be resumed',
details: { childSessionId },
})
case 'UNAUTHORIZED':
return err(request, {
code: 'subagent-unauthorized',
message: 'subagent does not belong to this parent',
details: { childSessionId },
})
case 'DRAINING':
case 'ACTIVATION_CLOSING':
case 'CONTINUATION_UNAVAILABLE':
case 'PERSISTENCE_UNAVAILABLE':
return err(request, {
code: 'subagent-delivery-unavailable',
message: 'subagent follow-up is temporarily unavailable',
details: { childSessionId },
})
default:
break
}
}
return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} })
}
/** Verify one address and mode against the complete direct-child catalog. */
async function catalogChild(
ctx: Context,
address: SubagentAddress,
signal?: AbortSignal,
): Promise<{
entry?: Extract<CatalogSubagentListEntry, { kind: 'child' }>
error?: RpcError
}> {
const { parentSessionId, childSessionId, mode } = address
try {
const entries = await ctx.subagents.listChildren(parentSessionId, signal)
const entry = entries.find(candidate => candidate.id === childSessionId)
if (entry === undefined || (entry.kind === 'child' && entry.mode !== mode)) {
return {
error: {
code: 'subagent-not-found',
message: `session "${childSessionId}" is not a ${mode} direct child of "${parentSessionId}"`,
details: { parentSessionId, childSessionId },
},
}
}
if (entry.kind === 'diagnostic') {
return {
error: {
code: 'subagent-catalog-diagnostic',
message: `subagent "${childSessionId}" is ${entry.reason}`,
details: { parentSessionId, childSessionId, reason: entry.reason },
},
}
}
return { entry }
} catch (error: unknown) {
if (signal?.aborted
|| (error instanceof SubagentError && error.code === 'CANCELLED')
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } }
}
if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
return {
error: {
code: 'subagent-not-found',
message: `parent session "${parentSessionId}" was not found`,
details: { parentSessionId, childSessionId },
},
}
}
return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } }
}
}
/**
* Thrown by the cold-resume path when the id names no servable session
* (absent from the store, or a pre-project legacy log without a cwd).
*/
class SessionNotFound extends Error {}
/** Session identity whose lifecycle belongs to subagent routing, not generic Host resume. */
class SubagentSessionOwnership extends Error {
constructor(readonly sessionId: SessionId) {
super(`session "${sessionId}" is a subagent session; use subagent delivery`)
}
}
/** Requested identity already belongs to a session with another project cwd. */
class SessionCwdConflict extends Error {
constructor(
@@ -634,6 +773,30 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
})
/** Project both durable inbox lists, optionally including the splice currently being emitted. */
const queueItems = (
agent: Agent,
splice?: SessionEventMap['agent/inbox/spliced'],
): QueuedInboxItem[] => {
const project = (target: 'next-turn' | 'next-step'): readonly UserMessage[] => {
const messages = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep
return splice?.target === target
? messages.toSpliced(splice.start, splice.removedCount ?? 0, ...splice.inserted)
: messages
}
return [
...project('next-turn').map(message => ({ id: message.id, placement: 'queued' as const, message })),
...project('next-step').map(message => ({ id: message.id, placement: 'steering' as const, message })),
]
}
ctx.on('session/event', (session, event) => {
if (event.type !== 'agent/inbox/spliced') return
const agent = ctx.agents.get(session.id)
if (agent?.session !== session) return
broadcast({ type: 'session/queue', sessionId: session.id, items: queueItems(agent, event.data) })
})
/** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
pendingQuestions.delete(pending.rpcId)
@@ -768,28 +931,91 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
}
/** Whether the session's own suffix carries the durable subagent discriminator. */
function hasSubagentDescriptor(session: Pick<Session, 'events' | 'header'>): boolean {
const events = session.events
// Indexed scan from the own-suffix start: slicing copies the whole suffix
// on every Agent-bound RPC, including each `session.prompt` on long
// transcripts.
for (let index = session.header.seedLength ?? 0; index < events.length; index += 1) {
if (events[index]?.type === 'subagent/descriptor') return true
}
return false
}
/**
* Gate the cold path on the store: an id absent from it, or naming a legacy
* log without a cwd (pre-release stance: not served, no compatibility), is
* not-found before any resume is attempted. With the gate passed, a later
* resume failure is genuinely internal. No persistence configured skips the
* gate — resume itself then fails loud with its own diagnostic.
* Generic Host interaction cannot claim a durably classified subagent or an
* Agent created through its live parent. The runtime-owner arm also covers
* descriptor-less child publication windows and older stored headers.
*/
async function assertServable(sessionId: SessionId): Promise<void> {
function hasSubagentOwner(
session: Pick<Session, 'events' | 'header'>,
agent: Agent | undefined,
): boolean {
if (session.header.origin === 'subagent' || hasSubagentDescriptor(session)) return true
const parentId = session.header.parentSession
if (parentId === undefined || agent === undefined) return false
const parent = ctx.agents.get(parentId)
return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent)
}
/** Stable generic-Host error for an identity reserved to subagent routing. */
function subagentOwnershipError(sessionId: SessionId): RpcError {
return {
code: 'agent-busy',
message: `session "${sessionId}" is owned by subagent routing`,
details: { reason: 'use subagent delivery for this child session' },
}
}
/** Inspect one cold served session without repairing, resuming, or publishing it. */
async function inspectServable(sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const persistence = ctx.get('sessionPersistence')
if (persistence === undefined) return
if (persistence === undefined) {
throw new Error('session persistence is not configured (load a dsh-session-persistence backend)')
}
const meta = (await persistence.list()).find(m => m.id === sessionId)
if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
const inspected = await persistence.inspect(sessionId)
if (inspected.meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
return inspected
}
/**
* Resolve one live registered identity through the subagent-ownership
* fence: subagent-owned agents answer `agent-busy`, plain agents pass.
* Fences the live agent's own session rather than trusting a
* "registered ⇒ attached-store" invariant — a registered subagent whose
* session is ever absent from the attached store must still not be handed
* out through generic Host routing. `undefined` means no live agent.
*/
function fencedLiveAgent(sessionId: SessionId): { agent: Agent } | { error: RpcError } | undefined {
const live = ctx.agents.get(sessionId)
if (live === undefined) return undefined
if (hasSubagentOwner(live.session, live)) return { error: subagentOwnershipError(sessionId) }
return { agent: live }
}
async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> {
const live = ctx.agents.get(sessionId)
if (live !== undefined) return { agent: live }
const fenced = fencedLiveAgent(sessionId)
if (fenced !== undefined) return fenced
const attached = ctx.sessions.get(sessionId)
if (attached !== undefined && hasSubagentOwner(attached, undefined)) {
return { error: subagentOwnershipError(sessionId) }
}
let resume = resumes.get(sessionId)
if (resume === undefined) {
resume = (async () => {
try {
await assertServable(sessionId)
const inspected = await inspectServable(sessionId)
if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
throw new SubagentSessionOwnership(sessionId)
}
const publishedSession = ctx.sessions.get(sessionId)
const publishedAgent = ctx.agents.get(sessionId)
if (publishedSession !== undefined && hasSubagentOwner(publishedSession, publishedAgent)) {
throw new SubagentSessionOwnership(sessionId)
}
const handle = await ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions,
@@ -808,26 +1034,104 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
if (error instanceof SessionNotFound) {
return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
}
if (error instanceof SubagentSessionOwnership) {
return { error: subagentOwnershipError(error.sessionId) }
}
// A concurrent publish can win the identity between the pre-resume
// re-check and `ctx.agents.resume` publication; the ID-collision
// rejection falls through here. Mirror ensureSession's `.catch` in
// full: classify a subagent-owned winner into the stable ownership
// error, and hand a clean plain-agent winner straight back.
const fenced = fencedLiveAgent(sessionId)
if (fenced !== undefined) return fenced
const attached = ctx.sessions.get(sessionId)
if (attached !== undefined && hasSubagentOwner(attached, undefined)) {
return { error: subagentOwnershipError(sessionId) }
}
// The internal details slot is contractually {}; the reason rides the message.
return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } }
}
}
type SessionReadState = {
id: SessionId
header: SessionHeader
events: SessionEvent[]
}
/** Read one stable session prefix without acquiring an Agent owner. */
async function readSessionState(sessionId: SessionId): Promise<SessionReadState> {
const attached = ctx.sessions.get(sessionId)
if (attached !== undefined) {
return {
id: attached.id,
header: attached.header,
events: [...attached.events],
}
}
const inspected = await inspectServable(sessionId)
return { id: inspected.meta.id, header: inspected.meta, events: inspected.events }
}
/** Resolve the Workspace inherited by a fork without making ordinary loose lineage grouped. */
async function forkWorkspace(source: Pick<Session, 'id' | 'header'>): Promise<Workspace | undefined> {
const workspaces = ctx.workspace.list()
const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id))
if (direct !== undefined || source.header.origin !== 'subagent') return direct
const lineage = await ctx.sessionQuery.traceSession(source.id)
for (const ancestor of lineage.ancestors) {
const workspace = workspaces.find(candidate => candidate.sessionIds.includes(ancestor.header.id))
if (workspace !== undefined) return workspace
}
return undefined
}
/** Read one transcript cut and optional projection baseline without acquiring an Agent owner. */
async function historyStateFor(
sessionId: SessionId,
includeProjections: boolean,
): Promise<{ events: SessionEvent[]; projections?: SessionProjectionsBlock }> {
const attached = ctx.sessions.get(sessionId)
if (attached !== undefined) {
const events = [...attached.events]
const projections = includeProjections ? projectionsFor(ctx, attached) : undefined
return { events, ...projections === undefined ? {} : { projections } }
}
const inspected = await inspectServable(sessionId)
const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined
return {
events: inspected.events,
...projections === undefined ? {} : { projections },
}
}
/** Resolve one requested identity to a live agent, creating or resuming it once. */
async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise<Agent> {
let creation = sessionCreations.get(sessionId)
if (creation === undefined) {
creation = (async () => {
const attached = ctx.sessions.get(sessionId)
const live = ctx.agents.get(sessionId)
if (attached !== undefined && hasSubagentOwner(attached, live)) {
throw new SubagentSessionOwnership(sessionId)
}
if (live !== undefined) return live
const persistence = checkPersistedIdentity ? ctx.get('sessionPersistence') : undefined
const stored = persistence === undefined
? undefined
: (await persistence.list()).find(header => header.id === sessionId)
if (stored !== undefined) {
if (stored.cwd !== cwd) {
throw new SessionCwdConflict(sessionId, cwd, stored.cwd)
if (persistence !== undefined && stored !== undefined) {
const inspected = await persistence.inspect(sessionId)
// Ownership first: explicit-id adoption of a session-backed
// subagent must answer `agent-busy` regardless of the requested
// cwd (the api/commands.ts contract), not a cwd conflict.
if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
throw new SubagentSessionOwnership(sessionId)
}
if (inspected.meta.cwd !== cwd) {
throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd)
}
return (await ctx.agents.resume({
resumeSessionId: sessionId,
@@ -851,7 +1155,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// Another Host entry path may have published the same identity while
// this operation crossed an asynchronous persistence/filesystem step.
const live = ctx.agents.get(sessionId)
if (live !== undefined) return live
if (live !== undefined) {
if (hasSubagentOwner(live.session, live)) throw new SubagentSessionOwnership(sessionId)
return live
}
const attached = ctx.sessions.get(sessionId)
if (attached !== undefined && hasSubagentOwner(attached, undefined)) {
throw new SubagentSessionOwnership(sessionId)
}
throw error
}).finally(() => {
sessionCreations.delete(sessionId)
@@ -859,6 +1170,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
sessionCreations.set(sessionId, creation)
}
const agent = await creation
if (hasSubagentOwner(agent.session, agent)) throw new SubagentSessionOwnership(sessionId)
if (agent.session.header.cwd !== cwd) {
throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
}
@@ -1258,6 +1570,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
})
}
if (error instanceof SubagentSessionOwnership) {
return err(request, subagentOwnershipError(error.sessionId))
}
return err(request, {
code: 'internal',
message: `failed to create session "${sessionId}": ${String(error)}`,
@@ -1280,25 +1595,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async history(request) {
const { sessionId, beforeSeq, maxMessages } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
// Everything below the resume above is synchronous: the page slice,
// the seq read, and the projection walk see one un-torn session state.
const page = paginate(found.agent.session.events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
// Views are computed against the registry at pagination time; result
// pairing scans within the page only (message-boundary pagination keeps
// a call and its result on one page — a cross-page miss soft-falls).
const entries: HistoryEntry[] = page.events.map((event) => {
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
return { event, ...view === undefined ? {} : { view } }
})
// Baseline rider: tail page only — loadOlder (beforeSeq present) is
// the one path that never needs a fresh projection baseline.
const projections = beforeSeq === undefined ? projectionsFor(ctx, found.agent) : undefined
let state: { events: SessionEvent[]; projections?: SessionProjectionsBlock }
try {
state = await historyStateFor(sessionId, beforeSeq === undefined)
} catch (error: unknown) {
if (error instanceof SessionNotFound) {
return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } })
}
return err(request, {
code: 'internal',
message: `history unavailable for session "${sessionId}": ${String(error)}`,
details: {},
})
}
const page = historyPage(ctx, state.events, beforeSeq, maxMessages)
return ok(request, {
events: entries,
events: page.events,
hasMore: page.hasMore,
...projections === undefined ? {} : { projections },
...state.projections === undefined ? {} : { projections: state.projections },
})
},
@@ -1373,9 +1687,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async fork(request) {
const { sessionId, atSeq } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const source = found.agent.session
let source: SessionReadState
try {
source = await readSessionState(sessionId)
} catch (error: unknown) {
if (error instanceof SessionNotFound) {
return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } })
}
return err(request, {
code: 'internal',
message: `fork source unavailable for session "${sessionId}": ${String(error)}`,
details: {},
})
}
const events = source.events
// An in-log anchor belongs to the turn containing it and must never
// clip backward to an earlier completed turn. Omitted and past-end
@@ -1403,6 +1727,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// right after the boundary turn.
let cut = boundary.seq + 1
while (cut < events.length && events[cut]?.type !== 'turn/start') cut++
let workspace: Workspace | undefined
try {
workspace = await forkWorkspace(source)
} catch (error: unknown) {
return err(request, {
code: 'internal',
message: `failed to resolve fork workspace for session "${sessionId}": ${String(error)}`,
details: {},
})
}
const childId = `session-${randomUUID()}` as SessionId
try {
await ctx.agents.create({
@@ -1423,9 +1757,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
details: {},
})
}
// Keep the child in the source's Workspace so the list nests it under
// its parent; the child is already published if the attach fails.
const workspace = ctx.workspace.list().find(w => w.sessionIds.includes(source.id))
// An ordinary source keeps its direct Workspace. A subagent source is
// not listed there, so its ordinary fork joins the nearest owning
// ancestor instead. The child is already published if attach fails.
if (workspace !== undefined) {
try {
await workspace.attachSession(childId)
@@ -1461,20 +1795,42 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
updateQueue(request) {
const { sessionId, itemId, action } = request.payload
const agent = ctx.agents.get(sessionId)
const queued = agent?.inbox.nextTurn
const index = queued?.findIndex(message => message.id === itemId) ?? -1
const message = queued?.[index]
if (agent === undefined || message === undefined) {
if (agent !== undefined && hasSubagentOwner(agent.session, agent)) {
return Promise.resolve(err(request, subagentOwnershipError(sessionId)))
}
if (agent === undefined) {
return Promise.resolve(err(request, {
code: 'queue-item-not-found',
message: 'queued item is no longer pending',
details: { itemId },
}))
}
const target = agent.inbox.nextTurn.some(message => message.id === itemId)
? 'next-turn'
: agent.inbox.nextStep.some(message => message.id === itemId) ? 'next-step' : undefined
const message = target === undefined
? undefined
: (target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep)
.find(candidate => candidate.id === itemId)
if (target === undefined || message === undefined) {
return Promise.resolve(err(request, {
code: 'queue-item-not-found',
message: 'queued item is no longer pending',
details: { itemId },
}))
}
if (action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) {
return Promise.resolve(err(request, {
code: 'steer-unavailable',
message: 'current turn no longer accepts steering',
details: { itemId },
}))
}
if (action.kind === 'edit') {
agent.inbox.splice('next-turn', index, 1, [freezeMessage({ ...message, content: action.content })])
agent.inbox.update(target, itemId, freezeMessage({ ...message, content: action.content }))
} else {
agent.inbox.splice('next-turn', index, 1, [])
agent.inbox.remove(target, itemId)
if (action.kind === 'steer') agent.steer(message)
}
return Promise.resolve(ok(request, { accepted: true as const }))
},
@@ -1489,11 +1845,119 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
details: { sessionId },
}))
}
if (hasSubagentOwner(agent.session, agent)) {
return Promise.resolve(err(request, subagentOwnershipError(sessionId)))
}
agent.cancel({ kind: 'user' }, { keepInbox: true })
return Promise.resolve(ok(request, { accepted: true as const }))
},
},
subagents: {
async list(request, signal) {
try {
const entries = await ctx.subagents.listChildren(request.payload.parentSessionId, signal)
return ok(request, {
entries: entries.map(entry => entry.kind === 'child'
? {
...entry,
activity: ctx.agents.get(entry.id)?.status === 'running' ? 'running' : 'inactive',
}
: entry),
parentAvailable: ctx.agents.get(request.payload.parentSessionId) !== undefined,
})
} catch (error: unknown) {
if (signal?.aborted
|| (error instanceof SubagentError && error.code === 'CANCELLED')
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
return err(request, {
code: 'cancelled',
message: 'subagent catalog read was cancelled',
details: {},
})
}
return err(request, {
code: 'internal',
message: 'subagent catalog read failed',
details: {},
})
}
},
async history(request, signal) {
const {
parentSessionId, childSessionId, mode, beforeSeq, maxMessages,
} = request.payload
const verified = await catalogChild(ctx, {
parentSessionId, childSessionId, mode,
}, signal)
if (verified.error !== undefined) return err(request, verified.error)
try {
const snapshot = await ctx.sessionQuery.readSession(childSessionId)
signal?.throwIfAborted()
if (snapshot.session.parentSession !== parentSessionId) {
return err(request, {
code: 'subagent-unauthorized',
message: 'subagent parent changed during history read',
details: { childSessionId },
})
}
const page = historyPage(ctx, snapshot.events, beforeSeq, maxMessages)
const projections = beforeSeq === undefined
? detachedProjectionsFor(ctx, snapshot.events)
: undefined
return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
} catch (error: unknown) {
if (signal?.aborted
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
return err(request, {
code: 'cancelled',
message: 'subagent history read was cancelled',
details: {},
})
}
if (error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
return err(request, {
code: 'subagent-not-found',
message: 'subagent disappeared during history read',
details: { parentSessionId, childSessionId },
})
}
return err(request, {
code: 'internal',
message: 'subagent history read failed',
details: {},
})
}
},
async prompt(request, signal) {
const { parentSessionId, childSessionId, content } = request.payload
const parent = ctx.agents.get(parentSessionId)
if (parent === undefined) {
return err(request, {
code: 'subagent-parent-unavailable',
message: `parent session "${parentSessionId}" is not live`,
details: { parentSessionId },
})
}
const verified = await catalogChild(ctx, {
parentSessionId, childSessionId, mode: 'continuable',
}, signal)
if (verified.error !== undefined) return err(request, verified.error)
try {
const messageId = await ctx.subagents.followup(parent, childSessionId, content, {
source: { kind: 'user', rpcId: request.rpcId },
signal,
})
return ok(request, { messageId })
} catch (error: unknown) {
return subagentPromptError(request, error, signal)
}
},
},
workspace: {
list(request) {
return Promise.resolve(ok(request, {
@@ -1748,9 +2212,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
commands: {
// Both methods address one session's agent (agentFor keeps its
// resume-on-miss: clients only send a sessionId for a published
// session, and resume restores an existing entity).
// Both methods address one session's agent. agentFor resumes on miss
// and fences every subagent-owned identity with `agent-busy`; the
// api/commands.ts module contract owns that fence's wording, so this
// comment only notes the routing shape: clients send a sessionId for a
// published session, and resume restores an existing entity.
async list(request) {
// Missing service = the deployment omitted dsh-commands from its
// composition, not an empty catalog: fail loud instead of serving [].
@@ -2002,14 +2468,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// Queue snapshot baseline (pendingQuestions precedent): frames replayed
// in arrival order per session; a reconnecting client rebuilds its
// queue view from these alone.
for (const agent of ctx.agents.list()) {
const items = agent.inbox.nextTurn
if (items.length === 0) continue
queue.push(frame({
type: 'session/queue',
sessionId: agent.id,
items: [...items],
}))
for (const session of ctx.sessions.list()) {
const agent = ctx.agents.get(session.id)
if (agent?.session === session && agent.inbox.hasPending) {
queue.push(frame({ type: 'session/queue', sessionId: session.id, items: queueItems(agent) }))
}
}
// Per-session open-call table for result-view pairing. Bounded by the
// per-turn call count: entries clear on turn/end; a table miss (stream
@@ -2032,20 +2495,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const view = viewFor(ctx, event, callId =>
openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
if (event.type === 'agent/inbox/spliced' && event.data.target === 'next-turn') {
const agent = ctx.agents.get(session.id)
if (agent?.session === session) {
queue.push(frame({
type: 'session/queue',
sessionId: session.id,
items: agent.inbox.nextTurn.toSpliced(
event.data.start,
event.data.removedCount ?? 0,
...event.data.inserted,
),
}))
}
}
}),
ctx.on('session/created', (session: Session) => {
subscribeSession(queue, session)
@@ -2077,9 +2526,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// Derived at frame time like summarize(); a just-created session
// has run no turn yet, so this is constantly true in practice.
blank: sessionBlank(session),
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
// cwd rides the frame so the client list needs no refresh to group the new session.
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
// Including cwd lets the client group the new session without refreshing the list.
...sessionListFields(session.header),
}))
}),
ctx.on('session/disposed', (session: Session) => {

View File

@@ -30,10 +30,10 @@ export const askUserQuestionItemSchema = z.object({
]).optional(),
}) satisfies z.ZodType<Wire<AskUserQuestionItem>>
/** User-message envelope carried by queue baselines. */
const userMessageSchema = z.object({
id: messageIdSchema,
role: z.literal('user'),
/** Unified message envelope carried by transient queue frames. */
const messageSchema = z.object({
id: z.string().min(1),
role: z.union([z.literal('system'), z.literal('user'), z.literal('assistant')]),
content: z.array(contentBlockSchema),
source: z.looseObject({ kind: z.string() }),
})
@@ -52,7 +52,11 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('session/queue'),
sessionId: sessionIdSchema,
items: z.array(userMessageSchema),
items: z.array(z.object({
id: messageIdSchema,
placement: z.union([z.literal('queued'), z.literal('steering')]),
message: messageSchema,
})),
}),
// value stays wide: it already passed its unit's own schema on the host,
// and deep-validating here would import every domain's schema into the carrier.
@@ -62,7 +66,14 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
/** HostFrame union (payload slot of a host-stream ServerRequest). */
export const hostFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, blank: z.boolean(), parentSessionId: sessionIdSchema.optional(), cwd: z.string().optional() }),
z.object({
type: z.literal('host/session-added'),
sessionId: sessionIdSchema,
blank: z.boolean(),
parentSessionId: sessionIdSchema.optional(),
origin: z.literal('subagent').optional(),
cwd: z.string().optional(),
}),
z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }),
z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }),
z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }),

View File

@@ -8,8 +8,9 @@
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction/types'
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types'
import type { Message } from '@deepseek-ai/dsh-llm/types'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
import type { UserMessage } from '@deepseek-ai/dsh-llm/message'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
import type { RpcError, RpcId, RpcRequest } from './rpc.ts'
@@ -31,6 +32,16 @@ export type ToolEventView =
| { for: 'call'; view: ToolCallView }
| { for: 'result'; view: ToolResultView }
/** One pending inbox occurrence in the authoritative `session/queue` snapshot. */
export interface QueuedInboxItem {
/** Message identity used by inbox mutations. */
id: MessageId
/** Agent-resolved FIFO placement; clients render queued and steering items on different surfaces. */
placement: 'queued' | 'steering'
/** Complete pending message; it is not durable until the Agent claims it. */
message: Message
}
/** Streaming face of the contract: the two SSE stream openers (mux + host). */
export interface EventsApi {
/**
@@ -62,11 +73,14 @@ export type MuxFrame =
| { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }
| { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' }
/**
* Complete next-turn queue snapshot emitted when a mux stream opens and
* after every live next-turn mutation. Pending next-step input is outside
* this Web queue projection.
* Complete transient inbox state after every enqueue, mutation, claim, or
* discard. Pending work is not model-visible and therefore has no durable
* session event; the whole snapshot makes edit, deletion, cancel, and
* reconnect converge through one authoritative signal. `session/queue`
* covers both resolved placements: queued items render
* in QueueDock, while pending steering renders at the conversation tail.
*/
| { type: 'session/queue'; sessionId: SessionId; items: UserMessage[] }
| { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] }
/**
* One projection unit's finished value changed (session-projection RFC).
* Live push state, never logged — replay recomputes on the host (the
@@ -79,9 +93,9 @@ export type MuxFrame =
| { type: 'stream/error'; error: RpcError }
/**
* Host stream frames. session-added carries the lineage anchor, the project
* cwd, and the blank bit (the list-summary fields a client cannot wait for a
* refresh to learn); the frame fires at session/created, so blank is
* Host stream frames. session-added carries the lineage anchor, product
* origin, project cwd, and blank bit (the list-summary fields a client cannot
* wait for a refresh to learn); the frame fires at session/created, so blank is
* constantly true — clients flip it on the session's first
* `host/session-status(running:true)` (a blank session never runs), and a
* reconnecting client takes `session.list`'s summary.blank as authoritative.
@@ -95,7 +109,14 @@ export type MuxFrame =
* workspace-changed — `workspace.list` re-baselines it on reconnect).
*/
export type HostFrame =
| { type: 'host/session-added'; sessionId: SessionId; blank: boolean; parentSessionId?: SessionId; cwd?: string }
| {
type: 'host/session-added'
sessionId: SessionId
blank: boolean
parentSessionId?: SessionId
origin?: 'subagent'
cwd?: string
}
| { type: 'host/session-removed'; sessionId: SessionId }
| { type: 'host/session-status'; sessionId: SessionId; running: boolean }
| { type: 'host/agent-error'; sessionId: SessionId; message: string }

View File

@@ -47,7 +47,7 @@ export type {
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
export type { CredentialsApi, CredentialView } from './credentials.ts'

View File

@@ -46,6 +46,7 @@ export interface RpcErrorDetailsMap {
'directory-picker-unavailable': { capability: string }
'agent-busy': { reason: string }
'queue-item-not-found': { itemId: MessageId }
'steer-unavailable': { itemId: MessageId }
/** A known slash command reported a usage/state error; the message is the command's own text. */
'command-error': {}
/** A leading-/ prompt named no registered command; the message names the token. */
@@ -71,6 +72,16 @@ export interface RpcErrorDetailsMap {
'credential-rejected': { ref: string }
'title-invalid': { sessionId: SessionId }
'fork-unavailable': { sessionId: SessionId }
'subagent-parent-unavailable': { parentSessionId: SessionId }
'subagent-not-found': { parentSessionId: SessionId; childSessionId: SessionId }
'subagent-catalog-diagnostic': {
parentSessionId: SessionId
childSessionId: SessionId
reason: 'corrupt' | 'unsupported' | 'unavailable'
}
'subagent-not-resumable': { childSessionId: SessionId }
'subagent-unauthorized': { childSessionId: SessionId }
'subagent-delivery-unavailable': { childSessionId: SessionId }
'internal': {}
}

View File

@@ -10,7 +10,8 @@ import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry, { InboxItemId } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { MessageId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
@@ -216,7 +217,7 @@ describe('subagent ownership fence', () => {
const queued = await api.sessions.updateQueue(request({
sessionId: originChild.id,
itemId: InboxItemId('queued-item'),
itemId: MessageId('queued-item'),
action: { kind: 'remove' },
}))
expect(queued.result.ok).toBe(false)

View File

@@ -339,7 +339,7 @@ describe('session.updateQueue', () => {
})
describe('session/queue frames', () => {
it('publishes authoritative next-turn snapshots without duplicating message identity', async () => {
it('publishes authoritative inbox snapshots without duplicating message identity', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
@@ -367,12 +367,18 @@ describe('session/queue frames', () => {
{
type: 'session/queue',
sessionId: agent.id,
items: [queued],
items: [
{ id: queued.id, placement: 'queued', message: queued },
{ id: steering.id, placement: 'steering', message: steering },
],
},
{
type: 'session/queue',
sessionId: agent.id,
items: [edited],
items: [
{ id: edited.id, placement: 'queued', message: edited },
{ id: steering.id, placement: 'steering', message: steering },
],
},
])
})

View File

@@ -52,6 +52,7 @@ function stubAgent(session: Session): Agent {
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
}

View File

@@ -421,7 +421,11 @@ describe('events frame schemas', () => {
{ type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
{ type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' },
{ type: 'session/queue', sessionId: 's', items: [
{ id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } },
{
id: 'm1',
placement: 'queued',
message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } },
},
] },
{ type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 },
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },

View File

@@ -7,6 +7,28 @@ describe('adapter failure normalization', () => {
expect(normalizeLlmFailure(thrown)).toEqual({ message: 'LLM adapter failed', code: 'UNKNOWN' })
})
it('normalizes empty primitive throws and data descriptors without values', () => {
expect(normalizeLlmFailure('')).toEqual({ message: 'LLM adapter failed', code: 'UNKNOWN' })
expect(normalizeLlmFailure(null)).toEqual({ message: 'null', code: 'UNKNOWN' })
const error = new Error('provider failed')
Object.defineProperty(error, 'failure', { get: () => ({ message: 'ignored', code: 'IGNORED' }) })
Object.defineProperty(error, 'code', { get: () => 'IGNORED' })
expect(normalizeLlmFailure(error)).toEqual({ message: 'provider failed', code: 'UNKNOWN' })
const accessorCode = Object.assign(new Error('provider failed'), {
failure: { message: 'provider failed', code: 'FOREIGN' },
})
Object.defineProperty(accessorCode, 'code', { get: () => 'FOREIGN' })
expect(normalizeLlmFailure(accessorCode)).toEqual({ message: 'provider failed', code: 'UNKNOWN' })
const primitiveFailure = Object.assign(new Error('provider failed'), {
failure: null,
code: 'FOREIGN',
})
expect(normalizeLlmFailure(primitiveFailure)).toEqual({ message: 'provider failed', code: 'UNKNOWN' })
})
it('contains hostile Error property reflection', () => {
const withFailure = new Error('provider failed') as Error & { failure: unknown; code: string }
withFailure.failure = { message: 'provider failed', code: 'FOREIGN' }

View File

@@ -835,6 +835,21 @@ describe('LlmService', () => {
...prepared.config,
messages: [],
})).toThrow(expect.objectContaining({ code: 'INVALID_PREPARED_CALL' }))
const late = await ctx.llm.prepareCall({ provider: 'route', model: 'model' })
const lateOptions = { ...late.config, messages: [] }
const lateStream = late.stream(lateOptions)
lateOptions.model = 'other'
expect(await collect(lateStream)).toContainEqual({
type: 'finish',
reason: {
kind: 'error',
failure: {
message: 'prepared LLM call config changed before adapter dispatch',
code: 'INVALID_PREPARED_CALL',
},
},
})
})
it('reuses one exact-model lookup for prepared config and context metadata', async () => {

View File

@@ -45,7 +45,9 @@ function agent(ctx: Context, cwd?: string): Agent {
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
}
@@ -260,7 +262,9 @@ describe('pty-local plugin shape', () => {
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', ctx: ownerFiber.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(owner)
const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
@@ -305,7 +309,9 @@ describe('pty-local plugin shape', () => {
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', ctx: ownerFiber.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(owner)
const gate = Promise.withResolvers<undefined>()

View File

@@ -38,7 +38,9 @@ function stubAgent(ctx: Context, rawId: string): Agent {
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', ctx: scope.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
}

View File

@@ -34,6 +34,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() })

View File

@@ -51,6 +51,7 @@ function agent(ctx: Context, cwd: string): Agent {
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)

View File

@@ -47,6 +47,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent {
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)

View File

@@ -43,7 +43,9 @@ function agent(ctx: Context): Agent {
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', ctx: scope.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)
return value

View File

@@ -21,7 +21,9 @@ function fakeAgent(ctx: Context, rawId: string): Agent {
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', ctx: scope.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(agent)
return agent

View File

@@ -57,6 +57,17 @@ describe('DeepSeekHarness', () => {
it('ignores notifications that precede the submitted message receipt', async () => {
const notifications = [
{ method: 'session.status', params: { sessionId: 'owned', status: 'running' } },
{
method: 'session.event',
params: { sessionId: 'owned', event: { type: 'turn/start', data: { turn: 1 } } },
},
{
method: 'session.event',
params: {
sessionId: 'owned',
event: { type: 'agent/inbox/spliced', data: { inserted: null } },
},
},
{
method: 'session.event',
params: {

View File

@@ -160,6 +160,21 @@ describe('scanRows', () => {
})
describe('rowToMeta', () => {
it('restores optional origin metadata', () => {
expect(rowToMeta({
id: 'with-origin',
version: 0,
created_at: 1,
cwd: null,
parent_session: null,
seed_length: null,
origin: 'subagent',
incarnation: 'with-origin',
revision: 1,
delegation_depth: null,
})).toMatchObject({ id: 'with-origin', origin: 'subagent' })
})
it('rejects fractional stored creation metadata', () => {
expect(() => rowToMeta({
id: 'fractional',

View File

@@ -719,7 +719,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
await ctx.plugin(Object.assign((inner: Context) => {
reuse = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } })
}, { inject: ['sessions'] }))
await expect(ctx.sessions.flush(reuse)).resolves.toBeUndefined()
await expect(ctx.sessions.flush(reuse)).resolves.toBe(true)
reuse.append('turn/start', { turn: 1 })
reuse.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
await ctx.sessions.flush(reuse)
@@ -796,7 +796,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
// A live session with that id arrives and claims it (cursor 0 matches
// trivially), persisting its seed.
const live = ctx.sessions.create(SessionId('lazy-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } })
await expect(ctx.sessions.flush(live)).resolves.toBeUndefined()
await expect(ctx.sessions.flush(live)).resolves.toBe(true)
const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim'))
// Seeded 0-5 plus the constructor's end-seed event at 6.
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6])

View File

@@ -51,6 +51,7 @@ function agentForCwd(cwd: string): Agent {
steer: () => {},
inject: () => { throw new Error('step-boundary catalog must not use agent.inject()') },
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
}
@@ -68,6 +69,7 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent {
steer: () => {},
inject: () => { throw new Error('step-boundary catalog must not use agent.inject()') },
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
}
@@ -402,6 +404,26 @@ describe('dsh-tool-skill', () => {
expect(decision).toEqual({ kind: 'enter', messages: [] })
})
it('keeps a proposed catalog that already matches the current snapshot', async () => {
const home = await tempDir('tool-matching-proposal')
const ctx = await setup(home)
ctx.skills.register({
name: 'first-skill',
description: 'First skill',
source: 'runtime',
content: 'First body.',
})
const session = new Session(SessionId('matching-proposal'))
const proposed = createUserMessage({
content: catalogContent(['- `first-skill`: First skill']),
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
})
const decision = await proposeStep(ctx, sessionAgent(session), [proposed])
expect(decision).toEqual({ kind: 'enter', messages: [proposed] })
})
it('injects complete replacement catalogs for additions and an empty tombstone for removals', async () => {
const home = await tempDir('tool-dynamic-catalog')
const ctx = await setup(home)

View File

@@ -1,19 +1,35 @@
/**
* Shared driver for in-process subagent providers. The agent factory's
* Shared driver for in-process ONE-SHOT subagent providers. The agent factory's
* creation transaction owns unpublished setup and rollback; after publication
* the returned AgentHandle is the one quiescent lifecycle owner held by the
* provider's caller.
*
* Continuable children never come through here: the continuation manager
* composes and drives them directly, so this driver owns exactly one turn with
* one result.
*
* @module @deepseek-ai/dsh-subagent-inprocess
*/
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import {
applyChildComposition,
assertSubagentMaxDepth,
childSessionMeta,
resolveChildAgentOptions,
resolveChildDepth,
} from '@deepseek-ai/dsh-subagent'
import type {
ResolvedSubagentStartRequest,
SubagentDescriptorData,
SubagentResult,
SubagentRun,
SubagentStopReason,
} from '@deepseek-ai/dsh-subagent'
// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve
// to the policy services when composed — the driver consumes both
// opportunistically (the documented `ctx.get` pattern), never as a hard dep.
@@ -29,14 +45,6 @@ export {
STRUCTURED_OUTPUT_INSTRUCTION,
} from './structured.ts'
/** Thrown when starting a child would exceed the requested depth cap. */
class SubagentDepthError extends Error {
constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) {
super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`)
this.name = 'SubagentDepthError'
}
}
/** Map a session turn outcome to the subagent seam's terminal vocabulary. */
function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
switch (reason?.kind) {
@@ -64,42 +72,42 @@ function prePublicationAbort(): Error {
return new Error('subagent request was aborted before child publication')
}
/** Append one one-shot descriptor inside the child's initial turn before its first request. */
function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void {
let appended = false
childCtx.on('agent/pre-step', async (agent, _messages, _context, next) => {
const decision = await next()
if (!appended && decision.kind === 'enter') {
appended = true
agent.session.append('subagent/descriptor', descriptor)
}
return decision
})
}
/**
* Establish and drive one in-process child. Fulfillment means the agent is
* already published in the registry; rejection means the agent factory's
* creation transaction and any partially-created child have reached quiescence.
* Establish and drive one in-process one-shot child. Fulfillment means the agent
* is already published in the registry and transfers its turn, cancellation,
* and disposal work through the returned run. Rejection means the agent
* factory's unpublished creation transaction reached quiescence without
* publishing a child. Every start appends its resolved descriptor inside the
* child's initial turn.
* @param request - the trusted typed start request, including its required signal.
* @param options - the optional fork seed.
* @returns a ready holder-owned run.
* @returns a published holder-owned run.
*/
export async function startInProcessRun(
request: SubagentStartRequest,
request: ResolvedSubagentStartRequest,
options: InProcessRunOptions,
): Promise<SubagentRun> {
assertSubagentMaxDepth(request.maxDepth)
if (request.signal.aborted) throw prePublicationAbort()
const parent = request.parent
const childDepth = delegationDepthOf(parent) + 1
if (!Number.isSafeInteger(childDepth)) {
throw new RangeError('subagent child depth exceeds the safe-integer range')
}
if (request.maxDepth !== undefined && childDepth > request.maxDepth) {
throw new SubagentDepthError(childDepth, request.maxDepth)
}
const childDepth = resolveChildDepth(parent, request.maxDepth)
const childId = SessionId(randomUUID())
const seedLength = options.seed?.length ?? 0
const parentHeader = parent.session.header
const parentProvider = parent.options.provider
const parentModel = parent.options.model
const parentMaxTokens = parent.options.maxTokens
const agentOptions: AgentOptions = {
...parentProvider !== undefined ? { provider: parentProvider } : {},
...parentModel !== undefined ? { model: parentModel } : {},
...parentMaxTokens !== undefined ? { maxTokens: parentMaxTokens } : {},
...request.agentOptions,
subagentDepth: childDepth,
}
const seed = options.seed
const activationBoundary = seed?.length ?? 0
// Capture before the first await: a later parent switch belongs to the
// parent's future.
@@ -108,6 +116,8 @@ export async function startInProcessRun(
let structured: StructuredAttachment | undefined
const setup = (childCtx: Context): void => {
// Inherited overrides land on the child's own log, so its effective policy
// is reconstructable from that log alone.
const childSession = (childCtx.agent as Agent).session
if (inheritedMode !== undefined) {
childSession.append('sandbox/mode', { mode: inheritedMode, source: 'delegation' })
@@ -115,61 +125,72 @@ export async function startInProcessRun(
if (inheritedPolicy !== undefined) {
childSession.append('approval/policy', { policy: inheritedPolicy, source: 'delegation' })
}
if (request.persona !== undefined) {
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: request.persona })
}
if (request.toolFilter !== undefined) childCtx.tools.restrict(request.toolFilter)
applyChildComposition(childCtx, {
persona: request.persona,
toolFilter: request.toolFilter,
})
if (request.outputSchema !== undefined) {
structured = attachStructuredRuntime(childCtx, request.outputSchema)
}
attachDescriptorAppend(childCtx, request.descriptor)
}
const flags = { cancelled: false }
const handle = await parent.ctx.agents.create({
sessionId: childId,
meta: {
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
parentSession: parentHeader.id,
// Durable: the recursion budget must survive persistence and resume.
delegationDepth: childDepth,
...seedLength > 0 ? { seedLength } : {},
},
...options.seed === undefined ? {} : { seed: options.seed },
agentOptions,
meta: childSessionMeta(parent, childDepth, activationBoundary),
...seed !== undefined ? { seed } : {},
agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth),
signal: request.signal,
setup,
})
const child = handle.agent
// Agent creation detaches its creation-only abort listener before returning.
// Close the narrow handoff race before installing the live-run listener.
// Static analysis does not model the abort that may land between the
// factory's listener detachment and this continuation.
// oxlint-disable-next-line typescript/no-unnecessary-condition
if (request.signal.aborted) {
flags.cancelled = true
await handle.dispose()
throw prePublicationAbort()
}
return drivePublishedRun(
handle,
request.signal,
request.prompt,
childId,
activationBoundary,
structured,
)
}
/**
* Wrap a published child in the single run lifecycle that owns signal handoff,
* one turn, result settlement, and quiescent disposal.
*/
function drivePublishedRun(
handle: AgentHandle,
signal: AbortSignal,
prompt: ContentBlock[],
childId: SessionId,
boundary: number,
structured: StructuredAttachment | undefined,
): SubagentRun {
const child = handle.agent
const flags = { cancelled: false }
const onAbort = (): void => {
flags.cancelled = true
child.cancel({ kind: 'parent' })
}
request.signal.addEventListener('abort', onAbort, { once: true })
signal.addEventListener('abort', onAbort, { once: true })
// Agent creation detaches its creation-only listener before returning. The
// post-registration check closes that handoff without treating an already
// published child as a failed start.
if (signal.aborted) onAbort()
const result: Promise<SubagentResult> = (async () => {
try {
const message = createUserMessage({ content: request.prompt, source: { kind: 'user' } })
child.followup(message)
await child.whenIdle()
if (!flags.cancelled) {
child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } }))
await child.whenIdle()
}
return readResult(
child,
seedLength,
boundary,
flags.cancelled,
structured ? { captured: structured.captured() } : undefined,
)
} finally {
request.signal.removeEventListener('abort', onAbort)
signal.removeEventListener('abort', onAbort)
}
})()
@@ -177,31 +198,33 @@ export async function startInProcessRun(
id: childId,
localAgent: child,
result,
dispose(): Promise<void> {
request.signal.removeEventListener('abort', onAbort)
async dispose(): Promise<void> {
signal.removeEventListener('abort', onAbort)
flags.cancelled = true
return handle.dispose()
const settlements = await Promise.allSettled([handle.dispose(), result])
const disposal = settlements[0]
// The result channel owns run faults; disposal reports only failure to
// release the published handle after both operations settle.
if (disposal.status === 'rejected') throw disposal.reason
},
}
}
/** Read one settled child's result from events after its optional fork seed. */
/** Read one settled child's result from events after its activation boundary. */
function readResult(
child: Agent,
seedLength: number,
boundary: number,
cancelled: boolean,
structured?: { captured?: { value: unknown } | undefined },
): SubagentResult {
const own = child.session.events.slice(seedLength)
const own = child.session.events.slice(boundary)
const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message')
const lastEnd = own.findLast((event): event is SessionEvent<'turn/end'> => event.type === 'turn/end')
const lastEnd = findLastMessageTurnEnd(own)
const output: ContentBlock[] = lastMessage?.data.message.content ?? []
const recorded = toStopReason(lastEnd?.data.reason)
// A requested cancellation owns every non-completed in-flight outcome; a
// turn already completed stays so.
const stopReason: SubagentStopReason = cancelled && recorded !== 'completed'
? 'aborted'
: recorded
// Disposal can tear the owner down before the loop records its ordinary
// `aborted` end, yielding `disposed` instead.
const stopReason: SubagentStopReason = cancelled && recorded !== 'completed' ? 'aborted' : recorded
if (structured !== undefined) {
if (structured.captured !== undefined) {
return { output, structured: structured.captured.value, stopReason }

View File

@@ -9,7 +9,7 @@ import InvariantService from '@deepseek-ai/dsh-invariants'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import SubagentService from '@deepseek-ai/dsh-subagent'
import SubagentService, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
@@ -35,7 +35,17 @@ async function setup(script: Script, parentOptions: Partial<AgentOptions> = {})
}
function request(parent: Agent, signal = new AbortController().signal) {
return { prompt: [{ type: 'text' as const, text: 'child task' }], parent, signal }
return {
label: 'child task',
prompt: [{ type: 'text' as const, text: 'child task' }],
parent,
signal,
descriptor: snapshotSubagentDescriptor({
mode: 'one-shot',
provider: 'test',
label: 'child task',
}),
}
}
function text(blocks: readonly { type: string; text?: string }[]): string {
@@ -56,27 +66,92 @@ describe('startInProcessRun', () => {
expect(ctx.agents.get(run.id)).toBeUndefined()
})
it('reports the final whole-agent outcome after idle replacement work', async () => {
const { ctx, parent } = await setup([maxTokensResponse('partial answer'), textResponse('replacement answer')])
let replaced = false
ctx.on('agent/status', (agent, status) => {
if (replaced || status !== 'idle' || agent.session.header.parentSession === undefined) return
replaced = true
agent.followup(createUserMessage({
content: [{ type: 'text', text: 'replacement work' }],
source: { kind: 'plugin', plugin: 'replacement' },
}))
it('uses explicit child model selectors when the parent has none and preserves its cwd', async () => {
const { ctx } = await setup([textResponse('driver answer')])
const parent = ctx.agentLoop.create(SessionId('bare-parent'), {}, { cwd: '/workspace' })
const run = await startInProcessRun({
...request(parent),
agentOptions: { provider: 'mock', model: 'mock' },
}, {})
const child = ctx.agents.get(run.id)!
expect(child.options).toMatchObject({ provider: 'mock', model: 'mock' })
expect(child.session.header.cwd).toBe('/workspace')
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
await run.dispose()
})
it('does not add a final durability checkpoint to a foreground run', async () => {
const { ctx, parent } = await setup([textResponse('driver answer')])
let flushes = 0
ctx.on('session/flush', (session) => {
if (session.header.parentSession === undefined) return
flushes++
throw new Error('disk full')
})
const run = await startInProcessRun(request(parent), {})
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
expect(flushes).toBe(0)
await run.dispose()
})
it('keeps published run and handle disposal failures on separate channels', async () => {
const { ctx, parent } = await setup([])
const runError = new Error('published run failed')
const disposalError = new Error('published handle disposal failed')
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length
const parentWithFailedDisposal = {
options: parent.options,
session: parent.session,
ctx: {
get: () => undefined,
agents: {
create: async (options: Parameters<typeof ctx.agents.create>[0]) => {
const handle = await ctx.agents.create(options)
handle.agent.followup = () => { throw runError }
return {
...handle,
dispose: async () => {
await handle.dispose()
throw disposalError
},
}
},
},
},
} as unknown as Agent
const run = await startInProcessRun(request(parentWithFailedDisposal), {})
expect(ctx.agents.get(run.id)).toBeDefined()
await expect(run.result).rejects.toBe(runError)
await expect(run.dispose()).rejects.toBe(disposalError)
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
it('reports the turn outcome when later metadata is appended during flush', async () => {
const { ctx, parent } = await setup([maxTokensResponse('partial answer')])
let injected = false
ctx.on('session/flush', (session) => {
if (injected || session.header.parentSession === undefined) return
const lastEnd = session.events.findLast(event => event.type === 'turn/end')
if (lastEnd?.type !== 'turn/end' || lastEnd.data.reason.kind !== 'max-tokens') return
injected = true
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'late metadata' }],
source: { kind: 'plugin', plugin: 'late-metadata' },
}), { surfaceOp: 'append' })
})
const run = await startInProcessRun(request(parent), {})
const result = await run.result
const child = ctx.agents.get(run.id)!
expect(replaced).toBe(true)
expect(injected).toBe(false)
expect(child.session.events.findLast(event => event.type === 'turn/end'))
.toMatchObject({ data: { reason: { kind: 'completed' } } })
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('replacement answer')
.toMatchObject({ data: { reason: { kind: 'max-tokens' } } })
expect(result.stopReason).toBe('max-tokens')
await run.dispose()
})
@@ -94,13 +169,16 @@ describe('startInProcessRun', () => {
await run.dispose()
})
it('persists the child depth in its session header', async () => {
it('persists the child origin and depth in its session header', async () => {
const { ctx, parent } = await setup([textResponse('child answer')])
const run = await startInProcessRun(request(parent), {})
await run.result
// The recursion budget is durable session data, not only runtime options —
// a depth that lived only in AgentOptions would reset to 0 on resume.
expect(ctx.agents.get(run.id)!.session.header.delegationDepth).toBe(1)
expect(ctx.agents.get(run.id)!.session.header).toMatchObject({
origin: 'subagent',
delegationDepth: 1,
})
await run.dispose()
})
@@ -179,6 +257,21 @@ describe('startInProcessRun', () => {
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
it('stamps only the resolved depth when neither parent nor request declares a model route', async () => {
// The one-shot analogue of the deleted resume coverage ("resumes without
// inventing undeclared agent model options"): a bare parent with no request
// agentOptions yields a child whose options carry ONLY the stamped depth —
// no provider/model is fabricated, so the child's turn errors for want of a
// route rather than silently adopting one.
const { ctx } = await setup([])
const parent = ctx.agentLoop.create(SessionId('routeless-parent'), {})
const run = await startInProcessRun(request(parent), {})
const child = ctx.agents.get(run.id)!
expect(child.options).toEqual({ subagentDepth: 1 })
await expect(run.result).resolves.toMatchObject({ stopReason: 'error' })
await run.dispose()
})
it('uses the request signal after publication and dispose as cancellation paths', async () => {
const { parent, adapter } = await setup(['hang', 'hang'])
const controller = new AbortController()
@@ -189,10 +282,7 @@ describe('startInProcessRun', () => {
expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' })
const child = parent.ctx.agents.get(signalled.id)
const turnEnd = child?.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({
kind: 'aborted',
reason: { kind: 'parent' },
})
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'parent' } })
await signalled.dispose()
const disposed = await startInProcessRun(request(parent), {})
@@ -213,7 +303,7 @@ describe('startInProcessRun', () => {
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
it('closes the abort handoff after the factory detaches its creation listener', async () => {
it('treats abort after factory publication as a cancelled run with an id', async () => {
const { ctx, parent } = await setup([])
const controller = new AbortController()
const beforeAgents = ctx.agents.list().length
@@ -229,15 +319,17 @@ describe('startInProcessRun', () => {
create: async (options: Parameters<typeof ctx.agents.create>[0]) => {
const handle = await ctx.agents.create(options)
// `create()` has detached its creation-only listener, but the
// provider continuation has not installed its live-run listener.
// published run has not installed its live listener yet.
controller.abort('handoff race')
return handle
},
},
},
} as unknown as Agent
await expect(startInProcessRun(request(parentWithAbortAtHandoff, controller.signal), {}))
.rejects.toThrow('aborted before child publication')
const run = await startInProcessRun(request(parentWithAbortAtHandoff, controller.signal), {})
expect(ctx.agents.get(run.id)).toBeDefined()
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
await run.dispose()
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})

View File

@@ -847,16 +847,13 @@ export class SubagentContinuationManager {
// quiet Agent from one whose accepted turn has not been admitted yet.
// Registered through the child's own scoped context, so scope filtering
// already restricts both listeners to this exact agent.
handle.agent.ctx.on('agent/inbox/dequeue', (_agent, item) => {
/* v8 ignore next -- a dequeue of an id this manager never admitted needs
handle.agent.ctx.on('agent/inbox/claimed', (_agent, { message }) => {
/* v8 ignore next -- a claim of an id this manager never admitted needs
* another sender on the same child, which no current path allows. */
if (activation.accepted.delete(item.message.id)) this.wake(activation)
if (activation.accepted.delete(message.id)) this.wake(activation)
})
handle.agent.ctx.on('agent/inbox/discard', (_agent, items) => {
// Deleting every id in the batch is unconditional; waking once afterwards
// costs nothing and avoids branching on which ids this manager admitted.
for (const item of items) activation.accepted.delete(item.message.id)
this.wake(activation)
handle.agent.ctx.on('agent/inbox/discarded', (_agent, { message }) => {
if (activation.accepted.delete(message.id)) this.wake(activation)
})
// Agent creation committed setup at its publication boundary;
// revocations from here on are immediate live revocation.

View File

@@ -207,7 +207,6 @@ function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopR
return 'max-tokens'
case 'aborted':
case 'interrupted':
case 'disposed':
return 'aborted'
case 'error':
return 'error'

View File

@@ -142,11 +142,24 @@ async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void>
}, { timeout: 5_000 })
}
/** Observe calls at the Agent cancellation boundary without a production event. */
function observeCancel(agent: Agent, callback: () => void): void {
const cancel = agent.cancel.bind(agent)
let observed = false
vi.spyOn(agent, 'cancel').mockImplementation((cause, options) => {
if (!observed) {
observed = true
callback()
}
cancel(cause, options)
})
}
describe('SubagentService.startContinuable', () => {
it('returns both identities at inbox acceptance, without waiting for the turn or the log', async () => {
const { ctx, parent, adapter } = await setup([textResponse('first answer')])
const enqueued: { id: MessageId; loggedYet: boolean }[] = []
ctx.on('agent/inbox/enqueue', (agent, accepted) => {
ctx.on('agent/inbox/inserted', (agent, accepted) => {
// Acceptance is the boundary `startContinuable` resolves at, so observe
// the log state exactly there rather than after later microtasks.
enqueued.push({ id: accepted.message.id, loggedYet: hasUserText(agent.session.events, 'child task') })
@@ -737,7 +750,9 @@ describe('continuable durability and teardown', () => {
const grandchild = await ctx.subagents.startContinuable(startSpec(targetChild))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(3) })
const cancellations: SessionId[] = []
ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) })
observeCancel(targetChild, () => { cancellations.push(targetChild.id) })
const grandchildAgent = ctx.agents.get(grandchild.childId)!
observeCancel(grandchildAgent, () => { cancellations.push(grandchildAgent.id) })
const drained = ctx.subagents.drainContinuableDescendants([parent])
const convergedDrain = ctx.subagents.drainContinuableDescendants([parent])
@@ -784,7 +799,8 @@ describe('continuable durability and teardown', () => {
const grandchild = await ctx.subagents.startContinuable(startSpec(child))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
const cancellations: SessionId[] = []
ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) })
const grandchildAgent = ctx.agents.get(grandchild.childId)!
observeCancel(grandchildAgent, () => { cancellations.push(grandchildAgent.id) })
const drained = ctx.subagents.drainContinuableDescendants([child])
@@ -828,7 +844,8 @@ describe('continuable durability and teardown', () => {
expect(ctx.agents.get(intermediateId)).toBeUndefined()
expect(ctx.agents.get(descendant.childId)).toBeDefined()
const cancellations: SessionId[] = []
ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) })
const descendantAgent = ctx.agents.get(descendant.childId)!
observeCancel(descendantAgent, () => { cancellations.push(descendantAgent.id) })
const drained = ctx.subagents.drainContinuableDescendants([parent])
@@ -926,7 +943,7 @@ describe('continuable durability and teardown', () => {
const drains: Promise<void>[] = []
const accepted: MessageId[] = []
ctx.on('subagent/start', () => { drains.push(drainManager(ctx)) })
ctx.on('agent/inbox/enqueue', (_agent, item) => { accepted.push(item.message.id) })
ctx.on('agent/inbox/inserted', (_agent, item) => { accepted.push(item.message.id) })
await expect(ctx.subagents.startContinuable(startSpec(parent)))
.rejects.toMatchObject({ code: 'DRAINING' })
@@ -967,12 +984,12 @@ describe('continuable durability and teardown', () => {
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const order: string[] = []
child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
child.ctx.on('agent/inbox/inserted', (_agent, accepted) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'before drain')) {
order.push('enqueue')
}
})
child.ctx.on('agent/cancel-requested', () => { order.push('cancel') })
observeCancel(child, () => { order.push('cancel') })
const delivery = followup(ctx, parent, started.childId, message('before drain'))
// Let the child-lock operation reach the live admission cutoff. Admission
@@ -1150,9 +1167,9 @@ describe('continuable review regressions', () => {
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', (info) => { ends.push(info) })
// Block the resumed prompt so this epoch produces nothing of its own.
ctx.on('agent/prompt-submit', async (subject, _message, _signal, next) => {
ctx.on('agent/pre-step', async (subject, _messages, _context, next) => {
if (subject === parent) return next()
return { kind: 'block', reason: 'blocked by policy' }
return { kind: 'reject' }
})
await followup(ctx, parent, started.childId, message('again'))
await waitNoActivation(ctx, started.childId)
@@ -1258,7 +1275,7 @@ describe('continuable review regressions', () => {
expect(found).toBeDefined()
return found!
})
child.ctx.on('agent/cancel-requested', () => { order.push('cancel') })
observeCancel(child, () => { order.push('cancel') })
const drained = drainManager(ctx)
hold.resolve(undefined)
@@ -1298,7 +1315,7 @@ describe('continuable review regressions', () => {
// Cancel from the synchronous enqueue observer: the discard fires after the
// id is recorded but before `followup()` returns.
const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
const off = child.ctx.on('agent/inbox/inserted', (_agent, accepted) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
child.cancel({ kind: 'user' })
}
@@ -1330,7 +1347,7 @@ describe('continuable review regressions', () => {
await followup(ctx, parent, started.childId, message('queued'))
expect(activation.accepted.size).toBe(1)
const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
const off = child.ctx.on('agent/inbox/inserted', (_agent, accepted) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
child.cancel({ kind: 'user' })
}
@@ -1348,9 +1365,9 @@ describe('continuable review regressions', () => {
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', (info) => { ends.push(info) })
// Block admission so the child's only turn never opens.
ctx.on('agent/prompt-submit', async (subject, _message, _signal, next) => {
ctx.on('agent/pre-step', async (subject, _messages, _context, next) => {
if (subject === parent) return next()
return { kind: 'block', reason: 'blocked by policy' }
return { kind: 'reject' }
})
const started = await ctx.subagents.startContinuable(startSpec(parent))
@@ -1370,7 +1387,7 @@ describe('continuable review regressions', () => {
const registeredAtEnqueue: boolean[] = []
// A synchronous inbox observer runs before the admitting microtask, the
// exact window where `Agent.status` is still idle.
ctx.on('agent/inbox/enqueue', (agent) => {
ctx.on('agent/inbox/inserted', (agent) => {
if (agent.session.header.parentSession !== undefined) {
registeredAtEnqueue.push(ctx.agents.get(agent.id) === agent)
}

View File

@@ -116,7 +116,6 @@ describe('SubagentService.listChildren', () => {
const child = ctx.sessions.create(childId, { meta: { parentSession: parentId } })
child.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
child.append('subagent/descriptor', descriptorPayload('query-only child'))
@@ -234,7 +233,7 @@ describe('SubagentService.listChildren', () => {
// descriptor and the parent lineage, without starting an Activation.
const liveId = SessionId('live-child')
const live = ctx.sessions.create(liveId, { meta: { parentSession: parent.id } })
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
live.append('turn/start', { turn: 1 })
live.append('subagent/descriptor', descriptorPayload('live child'))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toContainEqual({

View File

@@ -96,14 +96,15 @@ function callReport(ctx: Context, child: Agent, output: string, signal = testSig
})
}
/** Reports durably visible in one Agent's Session. */
/** Reports already visible or still pending in one Agent. */
function reports(agent: Agent): { id: string; text: string; sender: string }[] {
return agent.session.events.flatMap((event) => {
if (event.type !== 'user/message' || event.data.source.kind !== 'subagent-report') return []
const visible = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : [])
return [...visible, ...agent.inbox.nextStep].flatMap((message) => {
if (message.source.kind !== 'subagent-report') return []
return [{
id: event.data.id,
text: event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n'),
sender: event.data.source.senderSessionId,
id: message.id,
text: message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n'),
sender: message.source.senderSessionId,
}]
})
}
@@ -163,8 +164,10 @@ describe('dsh-tool-subagent-report', () => {
const { started, child } = await startChild(ctx, parent)
const parentRequests = adapter.requests.filter(request => request.sessionId === parent.id).length
const enqueues: string[] = []
ctx.on('agent/inbox/enqueue', (agent, item) => {
if (agent === parent) enqueues.push(item.placement)
ctx.on('agent/inbox/inserted', (agent, item) => {
if (agent === parent) {
enqueues.push(agent.inbox.nextTurn.some(message => message.id === item.message.id) ? 'queued' : 'steering')
}
})
const result = await callReport(ctx, child, 'CHILD_FINDING')
@@ -178,7 +181,7 @@ describe('dsh-tool-subagent-report', () => {
text: `Background subagent ${started.childId} reported:\nCHILD_FINDING`,
sender: started.childId,
}])
expect(enqueues).toEqual([])
expect(enqueues).toEqual(['steering'])
expect(parent.status).toBe('idle')
expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(parentRequests)
})
@@ -187,8 +190,10 @@ describe('dsh-tool-subagent-report', () => {
const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'wakeup' } })
const { child } = await startChild(ctx, parent)
const enqueues: string[] = []
ctx.on('agent/inbox/enqueue', (agent, item) => {
if (agent === parent) enqueues.push(item.placement)
ctx.on('agent/inbox/inserted', (agent, item) => {
if (agent === parent) {
enqueues.push(agent.inbox.nextTurn.some(message => message.id === item.message.id) ? 'queued' : 'steering')
}
})
const result = await callReport(ctx, child, 'WAKE_UP')
@@ -227,9 +232,9 @@ describe('dsh-tool-subagent-report', () => {
expect((await callReport(ctx, grandchild, 'FROM_GRANDCHILD')).isError).toBe(false)
expect(reports(parent)).toEqual([])
// The intermediate parent's turn is open, so quiet context is staged until
// that turn reaches its next safe log boundary.
expect(reports(child)).toEqual([])
// The intermediate parent's turn is open, so quiet context is pending in
// its inbox until that turn reaches its next safe log boundary.
expect(reports(child)).toHaveLength(1)
adapter.release()
await vi.waitFor(() => { expect(reports(child)).toHaveLength(1) })
expect(reports(child)[0]?.sender).toBe(grandchildStart.childId)

View File

@@ -50,8 +50,11 @@ const WAIT_POLL_INTERVAL_MS = 10
* `waitForTurnStart` waits for an open durable turn, optionally at or beyond a
* specified turn number. `waitForTurnEnd` holds the subprocess open until the
* selected session's latest complete raw-JSONL turn boundary is `turn/end`.
* `waitForGoalPhase` waits for the latest durable goal snapshot to reach one phase.
* `waitForInboxMessage` waits for inserted inbox text containing a scenario marker.
* `waitForTitleAfterTurnEnd` additionally waits for a later durable title.
* `waitForSubagentTurnEnd` applies the same work-turn boundary to one
* background child, whose progress has no ACP update to wait on.
* A standalone `cancel` may also wait for a cwd-relative readiness marker.
* All wait timeouts default to 10s.
*/
@@ -67,8 +70,11 @@ export type InputStep =
text: string
waitForFile?: { path: string; timeoutMs?: number }
}
| { op: 'waitForFile'; path: string; timeoutMs?: number }
| { op: 'waitForTurnStart'; minimumTurn?: number; timeoutMs?: number }
| { op: 'waitForTurnEnd'; timeoutMs?: number }
| { op: 'waitForSubagentTurnEnd'; child?: number; minimumTurn?: number; timeoutMs?: number }
| { op: 'waitForGoalPhase'; phase: 'active' | 'paused' | 'blocked' | 'complete'; timeoutMs?: number }
| { op: 'waitForInboxMessage'; text: string; timeoutMs?: number }
| { op: 'waitForTitleAfterTurnEnd'; timeoutMs?: number }
| { op: 'cancel'; waitForFile?: { path: string; timeoutMs?: number } }
@@ -292,6 +298,8 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
(id) => { sessionId = id },
(id, timeoutMs, minimumTurn) => waitForPersistedTurnStart(sessionsRoot, id, timeoutMs, minimumTurn),
(id, timeoutMs) => waitForPersistedTurnEnd(sessionsRoot, id, timeoutMs),
(child, timeoutMs, minimumTurn) => waitForPersistedChildTurnEnd(sessionsRoot, child, timeoutMs, minimumTurn),
(id, phase, timeoutMs) => waitForPersistedGoalPhase(sessionsRoot, id, phase, timeoutMs),
(id, text, timeoutMs) => waitForPersistedInboxMessage(sessionsRoot, id, text, timeoutMs),
(id, timeoutMs) => waitForPersistedTitleAfterTurnEnd(sessionsRoot, id, timeoutMs),
)
@@ -367,6 +375,8 @@ async function runStep(
setSessionId: (id: string) => void,
waitForTurnStart: (sessionId: string, timeoutMs?: number, minimumTurn?: number) => Promise<void>,
waitForTurnEnd: (sessionId: string, timeoutMs?: number) => Promise<void>,
waitForChildTurnEnd: (child: number, timeoutMs?: number, minimumTurn?: number) => Promise<void>,
waitForGoalPhase: (sessionId: string, phase: string, timeoutMs?: number) => Promise<void>,
waitForInboxMessage: (sessionId: string, text: string, timeoutMs?: number) => Promise<void>,
waitForTitleAfterTurnEnd: (sessionId: string, timeoutMs?: number) => Promise<void>,
): Promise<void> {
@@ -440,12 +450,24 @@ async function runStep(
await promptDone
return
}
case 'waitForFile':
await waitForWorkspaceFile(cwd, step.path, step.timeoutMs)
return
case 'waitForTurnEnd': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: waitForTurnEnd before newSession')
await waitForTurnEnd(sessionId, step.timeoutMs)
return
}
case 'waitForSubagentTurnEnd':
await waitForChildTurnEnd(step.child ?? 1, step.timeoutMs, step.minimumTurn)
return
case 'waitForGoalPhase': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: waitForGoalPhase before newSession')
await waitForGoalPhase(sessionId, step.phase, step.timeoutMs)
return
}
case 'waitForInboxMessage': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: waitForInboxMessage before newSession')
@@ -525,6 +547,59 @@ async function waitForPersistedTurnEnd(
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
}
/**
* Wait until the Nth harvested child Session closes a model work turn.
*
* Harvest order matches `session.1.jsonl`, `session.2.jsonl`, and so on. A
* continuable child appends its descriptor after any inherited history and
* before accepting its first prompt, so only a later request header proves its
* own model work reached a closed turn.
*/
async function waitForPersistedChildTurnEnd(
root: string,
child: number,
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
minimumTurn = 1,
): Promise<void> {
await vi.waitFor(async () => {
const log = (await harvestSessionLogs(root))[child]
if (log === undefined || !latestTurnIsClosed(log.content)
|| !hasRequestHeaderAfterDescriptor(log.content)
|| !hasClosedTurn(log.content, minimumTurn)) {
throw new Error(
`snapshot-harness: subagent child #${child} did not persist closed turn ${minimumTurn} within ${timeoutMs}ms`,
)
}
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
}
/** Whether a raw session log contains the requested closed turn. */
function hasClosedTurn(content: string, turn: number): boolean {
return content.split('\n').filter(Boolean).some((line) => {
const event = JSON.parse(line) as { type?: unknown; data?: { turn?: unknown } }
return event.type === 'turn/end' && event.data?.turn === turn
})
}
/** Wait until the latest durable goal snapshot reaches one phase. */
async function waitForPersistedGoalPhase(
root: string,
sessionId: string,
phase: string,
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
): Promise<void> {
await vi.waitFor(async () => {
const content = (await harvestSessionLogs(root)).find(log => log.id === sessionId)?.content
const matched = content?.split('\n').filter(Boolean).some((line) => {
const event = JSON.parse(line) as { type?: unknown; data?: { goal?: { phase?: unknown } } }
return event.type === 'goal/change' && event.data?.goal?.phase === phase
}) ?? false
if (!matched) {
throw new Error(`snapshot-harness: session "${sessionId}" did not persist goal phase "${phase}" within ${timeoutMs}ms`)
}
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
}
/** Wait until an inserted inbox message contains scenario-owned text. */
async function waitForPersistedInboxMessage(
root: string,
@@ -544,10 +619,23 @@ async function waitForPersistedInboxMessage(
message.content?.some(block => block.type === 'text'
&& typeof block.text === 'string' && block.text.includes(text))) === true
}) ?? false
if (!matched) throw new Error(`snapshot-harness: session "${sessionId}" did not persist expected inbox message within ${timeoutMs}ms`)
if (!matched) {
throw new Error(`snapshot-harness: session "${sessionId}" did not persist expected inbox message within ${timeoutMs}ms`)
}
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
}
/** Whether a child log contains model work after its own descriptor event. */
function hasRequestHeaderAfterDescriptor(content: string): boolean {
const events = content.slice(0, content.lastIndexOf('\n') + 1)
.split('\n')
.filter(line => line.length > 0)
.map(line => JSON.parse(line) as { type?: unknown })
const descriptor = events.findLastIndex(event => event.type === 'subagent/descriptor')
return descriptor >= 0
&& events.slice(descriptor + 1).some(event => event.type === 'request/header')
}
/** Wait until a complete provider or fallback title record follows the latest closed turn. */
async function waitForPersistedTitleAfterTurnEnd(
root: string,

View File

@@ -31,6 +31,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
runMaintenance: <T>(task: (signal: AbortSignal) => Promise<T>) => task(new AbortController().signal),
whenIdle() { return Promise.resolve() },
}
agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() })

View File

@@ -228,6 +228,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
cancel(cause) {
cancelled.push(cause)
},
runMaintenance: task => task(new AbortController().signal),
whenIdle() {
return Promise.resolve()
},

View File

@@ -20,12 +20,12 @@ buffer
style 0-17 fg=bright-magenta bold
8| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
style 0-60 dim
9| "Esc cancel turn • Ctrl+O cycle cards (collapse/expand/hide) • Ctrl+R toggle reasoning"
9| "Esc cancel active work • Ctrl+O cycle cards (collapse/expand/hide) • Ctrl+R toggle reasoning"
style 0-91 dim
10| "Ctrl+L redraw "
style 0-12 dim
11| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 0-72 dim
10| "Ctrl+L redraw "
style 0-14 dim
11| "Ctrl+C cancel active work; clear input or exit while idle • Ctrl+D exit "
style 0-70 dim
12| " "
13| "/clear — Clear the transcript view (session history is unchanged) "
style 0-64 dim

View File

@@ -20,12 +20,12 @@ buffer
style 0-17 fg=bright-magenta bold
8| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
style 0-60 dim
9| "Esc cancel turn • Ctrl+O cycle cards (collapse/expand/hide) • Ctrl+R toggle reasoning"
9| "Esc cancel active work • Ctrl+O cycle cards (collapse/expand/hide) • Ctrl+R toggle reasoning"
style 0-91 dim
10| "Ctrl+L redraw "
style 0-12 dim
11| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 0-72 dim
10| "Ctrl+L redraw "
style 0-14 dim
11| "Ctrl+C cancel active work; clear input or exit while idle • Ctrl+D exit "
style 0-70 dim
12| " "
13| "/clear — Clear the transcript view (session history is unchanged) "
style 0-64 dim

View File

@@ -5448,7 +5448,9 @@ describe('terminal mounting', () => {
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() })
@@ -5475,7 +5477,9 @@ describe('terminal mounting', () => {
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
// Mirror dsh-tui's own inject (minus loader, the absence under test).
@@ -5512,7 +5516,9 @@ describe('terminal mounting', () => {
id: otherSession.id, options: {}, session: otherSession, inbox: new Inbox(otherSession, { inserted: () => {}, discarded: () => {} }),
status: 'idle', ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
})
expect(terminal.started).toBe(0)
@@ -5521,7 +5527,9 @@ describe('terminal mounting', () => {
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
} as Agent
ctx.agents.register(agent)
await tick()
@@ -5554,7 +5562,9 @@ describe('terminal mounting', () => {
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
})
await tick()
expect(terminal.started).toBe(0)
@@ -5600,7 +5610,9 @@ describe('terminal mounting', () => {
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'running', ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
terminal.start = () => { throw new Error('terminal startup failed') }