refactor(agent): complete inbox lifecycle migration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.'],
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
39
packages/core/agent-loop/tests/runtime-context.spec.ts
Normal file
39
packages/core/agent-loop/tests/runtime-context.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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' } }))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
Goal 变更不会注入模型上下文。Goal 工具返回当前状态;继续执行消费方可以在调度模型工作时渲染目标描述与 Round 状态。未来如果需要始终可见的 goal 上下文,应由独立上下文插件实现,而不是放在持久化路径中。
|
||||
Goal 变更不会注入模型上下文。`get_goal` 等工具返回当前状态;继续执行消费方可以在调度模型工作时渲染目标描述与 Round 状态。未来如果需要始终可见的 goal 上下文,应由独立上下文插件实现,而不是放在持久化路径中。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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() }),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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': {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {} } },
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') }
|
||||
|
||||
Reference in New Issue
Block a user