feat(tools): require cancellation signal on every invocation

This commit is contained in:
Tianyi Cui
2026-07-19 23:38:54 +08:00
parent a99750f341
commit e8b95c8754
77 changed files with 1129 additions and 446 deletions

View File

@@ -29,7 +29,7 @@ tools:
### Cancellation
Cancellation is cooperative and quiescent. A cancellation that arrives after registry entry is rechecked after pre-policy, approval, around-dispatch, and post-result policy waits, so a body cannot start late and cancellation that wins before final result materialization supersedes a successful pipeline outcome; if the body has started, the registry preserves the caller signal through wrapper replacement and awaits settlement. A tool-owned structured error still wins. The registry never races away from a live same-process promise: every async tool must observe or forward `exec.signal` and settle only after owned work stops. A signal already aborted on entry still reaches the body for domain-specific cleanup; the agent-loop scheduler prevents model-driven calls from entering in that state. A timeout wrapper may replace the intermediate `ABORTED` with its owned `TOOL_TIMEOUT` when its deadline won. The [tool-cancellation RFC](../../../docs/rfc/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md) owns the service boundary and its hard-termination limit.
Cancellation is cooperative and quiescent. Every typed invocation supplies a caller-owned `AbortSignal`; tool bodies receive it as required readonly `exec.signal`, while only `tools/execute` wrappers may temporarily replace the required signal. The registry preserves caller cancellation through replacement and never races away from a started same-process promise. Cancellation before body invocation is `ABORTED_BEFORE_DISPATCH`; cancellation after invocation can replace only a successful outcome with `ABORTED`. A denial, wrapper failure, tool failure, post-policy failure, or timeout-owned `TOOL_TIMEOUT` remains more specific. A pre-aborted entry materializes and freezes arguments, then skips every policy and dispatch phase and publishes one result. Every async tool must observe or forward the signal and settle only after owned work stops. The [tool-cancellation RFC](../../../docs/rfc/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md) owns the full contract and hard-termination limit.
### Live events
@@ -38,9 +38,9 @@ The live registry pipeline has three transformable waterfalls followed by the ob
### Key types
- `ToolDefinition``ToolSchema` + `execute(args, exec)`, whose async work must cooperatively stop through `exec.signal`, plus optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification.
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, signal, agent?, parent? }`; `signal` is required and readonly, callers may pass an enclosing execution's opaque token as `parent`, and callers never choose the new execution's own token.
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore; the registry separately retains and re-fuses the original caller signal. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
- `ToolExecution` — the readonly pipeline view: immutable `{ token, callId, name, arguments, signal, agent?, parent? }`; the registry separately retains and re-fuses the original caller signal. `ToolDispatchExecution` is the `tools/execute`-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws or cancellation wins; it never injects immediately.
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source, envelope, and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step.
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.

View File

@@ -160,9 +160,8 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
// (its executor kills on this signal) instead of orphaned, and
// queued-unstarted dispatches are abandoned.
const runController = new AbortController()
const onOuterAbort = (): void => { runController.abort(exec.signal?.reason) }
if (exec.signal?.aborted) onOuterAbort()
exec.signal?.addEventListener('abort', onOuterAbort, { once: true })
const onOuterAbort = (): void => { runController.abort(exec.signal.reason) }
exec.signal.addEventListener('abort', onOuterAbort, { once: true })
let dispatches = 0
// The per-run serialization queue: every binding call chains onto the tail, so even
@@ -273,7 +272,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
meta,
}
} finally {
exec.signal?.removeEventListener('abort', onOuterAbort)
exec.signal.removeEventListener('abort', onOuterAbort)
}
},
// ACP execute cards use the program as their visible title.

View File

@@ -90,12 +90,13 @@ declare module 'cordis' {
* @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
* @mode waterfall
*/
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
/**
* Accept, replace, enrich, or block a normalized dispatch result. `next()`
* accepts it unchanged; thrown tools still reach this seam as errors. Async
* listeners must observe `exec.signal`; after they settle, caller
* cancellation replaces only a successful accepted outcome with `ABORTED`.
* cancellation replaces only a successful accepted outcome with the code
* selected by whether the tool body was invoked.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
* @param exec - the call that just ran (name, parsed arguments, caller agent).
* @param result - the dispatch outcome a listener may accept, replace, or block.
@@ -218,7 +219,8 @@ export interface ToolExecutionInput {
* the outer `run_code` outcome without receiving its live mutable execution.
*/
readonly parent?: ToolExecutionToken
signal?: AbortSignal
/** Required caller-owned cancellation for this invocation. */
readonly signal: AbortSignal
}
/**
@@ -232,17 +234,25 @@ export type ToolExecutionMode =
/**
* One pending tool call inside the registry pipeline. Parsed arguments cross
* one lossless-JSON materialization boundary before policy and are deep-frozen;
* call identity and the registry-assigned {@link token} are readonly. An
* around-dispatch wrapper may set, replace, or remove `signal`; immediately
* before the body, the registry re-fuses the original caller signal so a
* wrapper cannot detach caller cancellation. The registry freezes the complete
* object before `tools/result` observers run.
* call identity, the caller signal, and the registry-assigned {@link token} are
* readonly. The registry freezes the complete object before `tools/result`
* observers run.
*/
export interface ToolExecution extends ToolExecutionInput {
/** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
readonly token: ToolExecutionToken
}
/**
* Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper
* may replace the signal for its delegated lifetime, but it cannot remove it.
* The registry fuses every replacement with the captured caller signal.
*/
export interface ToolDispatchExecution extends Omit<ToolExecution, 'signal'> {
/** Cancellation signal visible to the next wrapper or tool body. */
signal: AbortSignal
}
/**
* Runtime context handed to a tool implementation after the registry has
* accepted a {@link ToolExecution}. A composite tool uses
@@ -258,6 +268,9 @@ export interface ToolRunContext extends ToolExecution {
deferContext(context: HookContext): void
}
/** Registry-owned live execution object; public pipeline views stay readonly. */
type MutableToolRunContext = Omit<ToolRunContext, 'signal'> & { signal: AbortSignal }
/**
* Scheduler-only result after ordered pre-execute and guards. A `post-result`
* still receives post-execute; a `final-result` bypasses it.
@@ -299,6 +312,13 @@ export interface ToolRegistryScheduler {
* @internal
*/
export const TOOL_REGISTRY_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler')
/** Canonical error code for cancellation after a tool body was invoked. */
export const TOOL_ABORTED = 'ABORTED'
/** Canonical error code for cancellation before a tool body was invoked. */
export const TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH'
/** Structured error metadata for a failed tool call (alongside the model-facing text). */
export interface ToolErrorInfo {
name: string
@@ -448,15 +468,21 @@ interface ToolGuardRegistration {
guard: ToolGuard
}
/** Caller cancellation captured before around-dispatch wrappers may replace the public signal slot. */
/** Approval decision plus whether the approval channel reported cancellation. */
interface ToolAskResolution {
readonly decision: Extract<PreToolDecision, { kind: 'allow' | 'deny' }>
readonly approvalCancelled: boolean
}
/** Caller cancellation and dispatch state kept outside the around-wrapper view. */
interface ToolCancellationState {
readonly callerSignal: AbortSignal | undefined
readonly abortedAtEntry: boolean
readonly callerSignal: AbortSignal
bodyInvoked: boolean
}
/** One dispatch-scoped fused signal plus listener cleanup after the body settles. */
interface FusedToolSignal {
readonly signal: AbortSignal | undefined
readonly signal: AbortSignal
dispose(): void
}
@@ -807,9 +833,9 @@ export class ToolRegistry extends Service {
* results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is
* the same lossless, frozen snapshot final observers receive. Cancellation
* arriving after entry and before final result materialization skips a
* not-yet-started body or replaces a successful pipeline outcome with
* `ABORTED`; already-started work is still drained and may retain a
* tool-owned structured error.
* not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a
* successful started outcome with `ABORTED`; already-started work is still
* drained and may retain a tool-owned structured error.
* @param exec - the typed same-process call input. The registry assigns its
* correlation token before policy begins.
* @returns the materialized final result.
@@ -836,7 +862,7 @@ export class ToolRegistry extends Service {
}
}
private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: ToolRunContext } {
private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: MutableToolRunContext } {
const deferredContexts: HookContext[] = []
const token = createExecutionToken()
const callId = exec.callId
@@ -848,9 +874,9 @@ export class ToolRegistry extends Service {
token,
callId,
name,
signal,
...agent !== undefined ? { agent } : {},
...parent !== undefined ? { parent } : {},
...signal !== undefined ? { signal } : {},
deferContext(context: HookContext): void {
deferredContexts.push(context)
},
@@ -860,15 +886,15 @@ export class ToolRegistry extends Service {
if (detached === undefined) {
throw new TypeError('tool execution arguments must be losslessly JSON-serializable')
}
const execution: ToolRunContext = { ...base, arguments: deepFreeze(detached) }
const execution: MutableToolRunContext = { ...base, arguments: deepFreeze(detached) }
this.deferredContexts.set(execution, deferredContexts)
this.cancellationStates.set(execution, {
callerSignal: signal,
abortedAtEntry: signal?.aborted === true,
bodyInvoked: false,
})
return { kind: 'ready', exec: execution }
} catch (error: unknown) {
const execution: ToolRunContext = { ...base, arguments: undefined }
const execution: MutableToolRunContext = { ...base, arguments: undefined }
return { kind: 'final-result', exec: execution, result: toolErrorResult(error) }
}
}
@@ -890,15 +916,21 @@ export class ToolRegistry extends Service {
const created = this.createExecution(input)
if (created.kind !== 'ready') return next(created)
const exec = created.exec
if (this.callerCancelled(exec)) {
return next({ kind: 'final-result', exec, result: toolAbortedBeforeDispatchResult() })
}
try {
const carrier = scopeTarget(this, exec.agent)
const gate = await this.ctx.waterfall(
carrier, 'tools/pre-execute', exec,
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
)
const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate
if (this.callerCancelledAfterEntry(exec)) {
return await next({ kind: 'post-result', exec, result: toolAbortedResult() })
const askResolution: ToolAskResolution = gate.kind === 'ask'
? await this.serviceAsk(exec, gate)
: { decision: gate, approvalCancelled: false }
const { decision } = askResolution
if (this.callerCancelled(exec) && askResolution.approvalCancelled) {
return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() })
}
const denialReason = decision.kind === 'allow'
? this.guardReason(exec)
@@ -913,20 +945,31 @@ export class ToolRegistry extends Service {
},
})
}
if (this.callerCancelled(exec)) {
return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() })
}
return await next({ kind: 'dispatch', exec })
} catch (error: unknown) {
return this.callerCancelledAfterEntry(exec)
? await next({ kind: 'post-result', exec, result: toolAbortedResult() })
: next({ kind: 'final-result', exec, result: toolErrorResult(error) })
return next({ kind: 'final-result', exec, result: toolErrorResult(error) })
}
}
/** Whether the original live caller signal aborted after this execution entered the registry. */
private callerCancelledAfterEntry(exec: ToolRunContext): boolean {
/** Whether the original caller signal is currently aborted. */
private callerCancelled(exec: ToolRunContext): boolean {
const state = this.cancellationStates.get(exec)
/* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
return !state.abortedAtEntry && state.callerSignal?.aborted === true
return state.callerSignal.aborted
}
/** Canonical cancellation outcome selected by whether the tool body started. */
private cancellationResult(exec: ToolRunContext, prior?: ToolExecutionResult): ToolExecutionResult {
const state = this.cancellationStates.get(exec)
/* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
return state.bodyInvoked
? toolAbortedResult(prior)
: toolAbortedBeforeDispatchResult(prior)
}
/**
@@ -934,24 +977,23 @@ export class ToolRegistry extends Service {
* into any around-wrapper replacement. Cancellation never abandons the body:
* a started promise reaches quiescence before its outcome becomes `ABORTED`.
*/
private async dispatchToolBody(exec: ToolRunContext): Promise<ToolExecutionResult> {
private async dispatchToolBody(exec: MutableToolRunContext): Promise<ToolExecutionResult> {
const state = this.cancellationStates.get(exec)
/* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
const wrapperSignal = exec.signal
const fused = fuseToolSignals(state.callerSignal, wrapperSignal)
const signal = fused.signal
const abortedBeforeBody = isAborted(signal)
if (!state.abortedAtEntry && abortedBeforeBody) {
if (isAborted(signal)) {
fused.dispose()
return toolAbortedResult()
return toolAbortedBeforeDispatchResult()
}
if (signal === undefined) delete exec.signal
else exec.signal = signal
exec.signal = signal
try {
const tool = this.get(exec.name, exec.agent)
if (!tool) throw new ToolNotFoundError(exec.name)
state.bodyInvoked = true
const returned = await tool.execute(exec.arguments, exec)
const content = Array.isArray(returned) ? returned : returned.content
const meta = Array.isArray(returned) ? undefined : returned.meta
@@ -960,15 +1002,14 @@ export class ToolRegistry extends Service {
isError: false,
...meta !== undefined ? { meta } : {},
}
return !abortedBeforeBody && isAborted(signal)
return isAborted(signal)
? toolAbortedResult(result)
: result
} catch (error: unknown) {
return toolErrorResult(error)
} finally {
fused.dispose()
if (wrapperSignal === undefined) delete exec.signal
else exec.signal = wrapperSignal
exec.signal = wrapperSignal
}
}
@@ -981,10 +1022,11 @@ export class ToolRegistry extends Service {
*/
private async dispatchScheduledExecution(exec: ToolRunContext): Promise<ScheduledToolDispatch> {
try {
const mutableExec = exec as MutableToolRunContext
const carrier = scopeTarget(this, exec.agent)
const result = await this.ctx.waterfall(
carrier, 'tools/execute', exec,
() => this.dispatchToolBody(exec),
carrier, 'tools/execute', mutableExec,
() => this.dispatchToolBody(mutableExec),
)
const deferredContexts = this.deferredContexts.get(exec)
/* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */
@@ -1000,8 +1042,8 @@ export class ToolRegistry extends Service {
}
return {
kind: 'post-result',
result: this.callerCancelledAfterEntry(exec) && !resultWithDeferredContexts.isError
? toolAbortedResult(resultWithDeferredContexts)
result: this.callerCancelled(exec) && !resultWithDeferredContexts.isError
? this.cancellationResult(exec, resultWithDeferredContexts)
: resultWithDeferredContexts,
}
} catch (error: unknown) {
@@ -1021,8 +1063,8 @@ export class ToolRegistry extends Service {
const postResult = await this.postExecute(exec, result)
return this.finishScheduledExecution(
exec,
this.callerCancelledAfterEntry(exec) && !postResult.isError
? toolAbortedResult(postResult)
this.callerCancelled(exec) && !postResult.isError
? this.cancellationResult(exec, postResult)
: postResult,
)
} catch (error: unknown) {
@@ -1050,8 +1092,8 @@ export class ToolRegistry extends Service {
/** Notify observers without exposing a mutation or error channel into the outcome. */
private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void {
// Freeze the remaining mutable signal slot before observers receive the
// shared WeakMap-keyable execution object.
// Freeze the registry's live object before observers receive its readonly
// WeakMap-keyable view.
Object.freeze(exec)
const callbacks = this.ctx.events.dispatch('emit', [
scopeTarget(this, exec.agent), 'tools/result', exec, result,
@@ -1079,26 +1121,41 @@ export class ToolRegistry extends Service {
private async serviceAsk(
exec: ToolExecution,
ask: Extract<PreToolDecision, { kind: 'ask' }>,
): Promise<Extract<PreToolDecision, { kind: 'allow' | 'deny' }>> {
): Promise<ToolAskResolution> {
const approval = this.ctx.get('approval')
if (approval === undefined) {
return { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` }
return {
decision: { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` },
approvalCancelled: false,
}
}
if (exec.agent === undefined) {
return { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` }
return {
decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` },
approvalCancelled: false,
}
}
const outcome = await approval.request({
agent: exec.agent,
toolName: exec.name,
callId: exec.callId,
...ask.reason !== undefined ? { reason: ask.reason } : {},
...exec.signal !== undefined ? { signal: exec.signal } : {},
signal: exec.signal,
})
switch (outcome) {
case 'allowed-once': return { kind: 'allow' }
case 'rejected': return { kind: 'deny', reason: `the user rejected tool "${exec.name}"` }
case 'cancelled': return { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` }
case 'unavailable': return { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` }
case 'allowed-once': return { decision: { kind: 'allow' }, approvalCancelled: false }
case 'rejected': return {
decision: { kind: 'deny', reason: `the user rejected tool "${exec.name}"` },
approvalCancelled: false,
}
case 'cancelled': return {
decision: { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` },
approvalCancelled: true,
}
case 'unavailable': return {
decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` },
approvalCancelled: false,
}
default: return assertNever(outcome, 'ApprovalOutcome')
}
}
@@ -1165,19 +1222,16 @@ function toolErrorResult(error: unknown): ToolExecutionResult {
}
/** Read live abort state across an await without treating it as synchronously immutable. */
function isAborted(signal: AbortSignal | undefined): boolean {
return signal?.aborted === true
function isAborted(signal: AbortSignal): boolean {
return signal.aborted
}
/**
* Fuse caller and wrapper cancellation without nesting `AbortSignal.any`.
* Keeping the relay dispatch-scoped also removes listeners when work settles.
*/
function fuseToolSignals(caller: AbortSignal | undefined, wrapper: AbortSignal | undefined): FusedToolSignal {
if (caller === undefined || caller === wrapper) {
return { signal: wrapper ?? caller, dispose() {} }
}
if (wrapper === undefined) return { signal: caller, dispose() {} }
function fuseToolSignals(caller: AbortSignal, wrapper: AbortSignal): FusedToolSignal {
if (caller === wrapper) return { signal: caller, dispose() {} }
const controller = new AbortController()
let listening = false
@@ -1205,13 +1259,24 @@ function fuseToolSignals(caller: AbortSignal | undefined, wrapper: AbortSignal |
return { signal: controller.signal, dispose }
}
/** Canonical result when cancellation prevents dispatch or supersedes a successful outcome. */
/** Canonical result when cancellation supersedes success after body invocation. */
function toolAbortedResult(prior?: ToolExecutionResult): ToolExecutionResult {
const additionalContexts = prior?.additionalContexts ?? []
return {
content: [{ type: 'text', text: 'Error: tool call aborted' }],
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { name: 'AbortError', code: TOOL_ABORTED },
...additionalContexts.length > 0 ? { additionalContexts } : {},
}
}
/** Canonical result when cancellation prevents tool body invocation. */
function toolAbortedBeforeDispatchResult(prior?: ToolExecutionResult): ToolExecutionResult {
const additionalContexts = prior?.additionalContexts ?? []
return {
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
...additionalContexts.length > 0 ? { additionalContexts } : {},
}
}

View File

@@ -6,12 +6,14 @@ import type { Scope } from '@deepseek-ai/dsh-scope'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineTool } from '@deepseek-ai/dsh-tools'
import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEventMap } from '@deepseek-ai/dsh-session'
const testToolSignal = new AbortController().signal
/**
* Code Mode unit tier (per the RFC's plan): provider contribution per mode,
* misconfiguration rejections, the run_code dispatch bridge (serialization,
@@ -95,6 +97,7 @@ function fakeAgent(options: { cwd?: string } = { cwd: '/workspace' }): { agent:
/** Dispatch run_code through the registry pipeline, as the loop would. */
async function runCode(ctx: Context, code: string, extras: { agent?: Agent; signal?: AbortSignal } = {}): Promise<ToolExecutionResult> {
return ctx.tools.execute({
signal: testToolSignal,
callId: CallId('call-1'),
name: RUN_CODE_NAME,
arguments: { code },
@@ -357,8 +360,7 @@ describe('the run_code dispatch bridge', () => {
const previous = exec.signal
exec.signal = new AbortController().signal
const result = await next()
if (previous === undefined) delete exec.signal
else exec.signal = previous
exec.signal = previous
return result
})
ctx.on('tools/result', (exec) => {
@@ -577,7 +579,7 @@ describe('the run_code dispatch bridge', () => {
seen.push(args.id)
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 500)
exec.signal?.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true })
exec.signal.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true })
})
return [{ type: 'text' as const, text: args.id }]
},
@@ -613,7 +615,7 @@ describe('the run_code dispatch bridge', () => {
started()
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 500)
exec.signal?.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true })
exec.signal.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true })
})
return [{ type: 'text' as const, text: args.id }]
},
@@ -841,7 +843,7 @@ describe('the run_code dispatch bridge', () => {
expect((result.content[0] as { text: string }).text).toBe('{ n: 42 }')
})
it('reports a pre-aborted outer signal as the run failure without dispatching anything', async () => {
it('short-circuits a pre-aborted outer signal before the code runtime', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const calls = registerEcho(ctx)
runtime.behavior = (request) => {
@@ -853,7 +855,12 @@ describe('the run_code dispatch bridge', () => {
controller.abort('too-late')
const result = await runCode(ctx, 'program', { signal: controller.signal })
expect(result.isError).toBe(true)
expect((result.content[0] as { text: string }).text).toContain('code run failed (abort)')
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
expect(runtime.lastRequest).toBeUndefined()
expect(calls).toEqual([])
})

View File

@@ -11,6 +11,8 @@ import ToolRegistry, {
type ToolExecutionMode,
} from '@deepseek-ai/dsh-tools'
const testToolSignal = new AbortController().signal
async function setup() {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -19,7 +21,7 @@ async function setup() {
}
function exec(name: string, args: unknown): ToolExecutionInput {
return { callId: CallId('c1'), name, arguments: args }
return { signal: testToolSignal, callId: CallId('c1'), name, arguments: args }
}
describe('ToolRegistry.executionMode', () => {

View File

@@ -0,0 +1,100 @@
import { describe, expectTypeOf, it } from 'vitest'
import type { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type {
ToolDispatchExecution,
ToolExecution,
ToolExecutionInput,
ToolRunContext,
} from '@deepseek-ai/dsh-tools'
function inputAndExecutionContracts(
input: ToolExecutionInput,
execution: ToolExecution,
run: ToolRunContext,
): void {
// @ts-expect-error -- every typed invocation must supply a caller-owned signal.
const missingSignal: ToolExecutionInput = { callId: CallId('missing'), name: 'probe', arguments: {} }
void missingSignal
// @ts-expect-error -- caller input is readonly after construction.
input.signal = new AbortController().signal
// @ts-expect-error -- required readonly properties cannot be deleted.
delete input.signal
// @ts-expect-error -- required signals cannot become undefined.
input.signal = undefined
// @ts-expect-error -- pipeline observers receive a readonly execution view.
execution.signal = new AbortController().signal
// @ts-expect-error -- pipeline observers cannot remove the required signal.
delete execution.signal
// @ts-expect-error -- tool bodies receive a readonly run context.
run.signal = new AbortController().signal
// @ts-expect-error -- tool bodies cannot remove the required signal.
delete run.signal
// @ts-expect-error -- tool bodies cannot replace the required signal with undefined.
run.signal = undefined
}
void inputAndExecutionContracts
function observerContracts(ctx: Context): void {
ctx.on('tools/pre-execute', (exec, next) => {
// @ts-expect-error -- pre-policy sees a readonly signal.
exec.signal = new AbortController().signal
// @ts-expect-error -- pre-policy cannot remove the required signal.
delete exec.signal
// @ts-expect-error -- pre-policy cannot replace the required signal with undefined.
exec.signal = undefined
return next()
})
ctx.on('tools/post-execute', (exec, _result, next) => {
// @ts-expect-error -- post-policy sees a readonly signal.
exec.signal = new AbortController().signal
// @ts-expect-error -- post-policy sees a readonly signal.
delete exec.signal
// @ts-expect-error -- post-policy cannot replace the required signal with undefined.
exec.signal = undefined
return next()
})
ctx.on('tools/result', (exec) => {
// @ts-expect-error -- result observers see a readonly signal.
exec.signal = new AbortController().signal
// @ts-expect-error -- result observers cannot remove the required signal.
delete exec.signal
// @ts-expect-error -- result observers see a readonly signal.
exec.signal = undefined
})
ctx.on('tools/execute', (exec, next) => {
exec.signal = new AbortController().signal
// @ts-expect-error -- around-dispatch may replace but not remove the signal.
delete exec.signal
// @ts-expect-error -- around-dispatch cannot replace the required signal with undefined.
exec.signal = undefined
return next()
})
}
void observerContracts
const inferredTool = defineTool({
name: 'signal-inference',
description: 'Pins contextual signal inference.',
parameters: {},
async execute(_args, exec) {
expectTypeOf(exec.signal).toEqualTypeOf<AbortSignal>()
// @ts-expect-error -- defineTool contextually exposes a readonly signal.
exec.signal = new AbortController().signal
return []
},
})
void inferredTool
describe('tool execution signal types', () => {
it('requires an exact AbortSignal at every readonly tool view', () => {
expectTypeOf<ToolExecutionInput['signal']>().toEqualTypeOf<AbortSignal>()
expectTypeOf<ToolExecution['signal']>().toEqualTypeOf<AbortSignal>()
expectTypeOf<ToolRunContext['signal']>().toEqualTypeOf<AbortSignal>()
expectTypeOf<ToolDispatchExecution['signal']>().toEqualTypeOf<AbortSignal>()
expectTypeOf<typeof inferredTool.execute>().toBeFunction()
})
})

View File

@@ -12,6 +12,8 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
const testToolSignal = new AbortController().signal
/** Mount the registry (with its systemPrompt dependency) on a fresh context. */
async function mount(): Promise<Context> {
const ctx = new Context()
@@ -43,6 +45,7 @@ function tool(name: string, reply = `ran:${name}`): ToolDefinition {
async function run(ctx: Context, name: string, agent?: Agent): Promise<string> {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('c1'),
name,
arguments: {},
@@ -305,6 +308,7 @@ describe('scoped execution dispatch', () => {
expect(await run(ctx, 'danger', key)).toBe('Error: danger denied')
const callerArguments = { source: true }
const safeResult = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('safe-call'),
name: 'safe',
arguments: callerArguments,
@@ -348,7 +352,7 @@ describe('scoped execution dispatch', () => {
if (exec.name === 'parent') parent = exec.token
return next()
})
await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} })
await ctx.tools.execute({ signal: testToolSignal, callId: CallId('parent'), name: 'parent', arguments: {} })
stopCapture()
policyCalls = 0
const signal = new AbortController().signal
@@ -372,6 +376,7 @@ describe('scoped execution dispatch', () => {
signal,
})
const subjectlessResult = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('non-cloneable-subjectless'),
name: 't',
arguments: { invalid: () => undefined },
@@ -414,6 +419,7 @@ describe('scoped execution dispatch', () => {
callId: CallId('stateful-parent'),
name: 't',
arguments: {},
signal: testToolSignal,
get parent(): ToolExecutionToken | undefined {
parentReads += 1
return parentReads === 1 ? undefined : forged
@@ -438,7 +444,7 @@ describe('scoped execution dispatch', () => {
if (exec.name === 'parent') parent = exec.token
return next()
})
await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} })
await ctx.tools.execute({ signal: testToolSignal, callId: CallId('parent'), name: 'parent', arguments: {} })
stopCapture()
const acceptedSignal = new AbortController().signal
const driftSignal = new AbortController().signal
@@ -485,6 +491,7 @@ describe('scoped execution dispatch', () => {
const input = {
callId: CallId('throwing-arguments'),
name: 't',
signal: testToolSignal,
get arguments(): unknown {
argumentReads += 1
throw new Error('getter exploded')
@@ -525,6 +532,7 @@ describe('scoped execution dispatch', () => {
})
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('bad-arguments'), name: 't', arguments: argumentsValue,
})
@@ -545,6 +553,7 @@ describe('scoped execution dispatch', () => {
})
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue,
})
@@ -584,7 +593,7 @@ describe('scoped execution dispatch', () => {
})
ctx.on('tools/result', (_exec, result) => { seen.push(result.isError) })
const result = await ctx.tools.execute({ callId: CallId('final'), name: 't', arguments: {}, agent: key })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('final'), name: 't', arguments: {}, agent: key })
expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] })
expect(seen).toEqual([true, true])
expect(dispatchModes).toEqual(['emit'])

View File

@@ -6,10 +6,13 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
import ToolRegistry, {
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH,
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolExecution, type ToolExecutionResult,
type ToolDispatchExecution, type ToolExecutionResult,
} from '@deepseek-ai/dsh-tools'
const testToolSignal = new AbortController().signal
async function setup() {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -79,7 +82,7 @@ describe('ToolRegistry', () => {
it('executes a tool and returns its content', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false })
})
@@ -92,7 +95,7 @@ describe('ToolRegistry', () => {
return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] } }
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'meta-tool', arguments: {} })
expect(result).toEqual({
content: [{ type: 'text', text: 'ok' }],
isError: false,
@@ -109,7 +112,7 @@ describe('ToolRegistry', () => {
return { content: [{ type: 'text', text: 'ok' }] }
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'no-meta-tool', arguments: {} })
expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false })
expect('meta' in result).toBe(false)
})
@@ -127,6 +130,7 @@ describe('ToolRegistry', () => {
})
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('bad-meta'), name: 'bad-meta', arguments: {},
})
expect(result.isError).toBe(true)
@@ -144,13 +148,13 @@ describe('ToolRegistry', () => {
},
})
const unknown = await ctx.tools.execute({ callId: CallId('c1'), name: 'nope', arguments: {} })
const unknown = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'nope', arguments: {} })
expect(unknown.isError).toBe(true)
expect(unknown.content[0]).toMatchObject({ text: 'Error: unknown tool "nope"' })
// An unknown tool is a routable failure class, same as a tool-thrown one.
expect(unknown.error).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' })
const thrown = await ctx.tools.execute({ callId: CallId('c2'), name: 'boom', arguments: {} })
const thrown = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c2'), name: 'boom', arguments: {} })
expect(thrown.isError).toBe(true)
expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' })
})
@@ -170,6 +174,7 @@ describe('ToolRegistry', () => {
})
await expect(ctx.tools.execute({
signal: testToolSignal,
callId: CallId('hostile'), name: 'hostile-throw', arguments: {},
})).resolves.toMatchObject({
isError: true,
@@ -195,7 +200,7 @@ describe('ToolRegistry', () => {
return next()
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
})
@@ -207,7 +212,7 @@ describe('ToolRegistry', () => {
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> =>
({ kind: 'ask', reason: 'needs approval' }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: needs approval' })
})
@@ -218,7 +223,7 @@ describe('ToolRegistry', () => {
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval (not yet supported)' })
})
@@ -269,7 +274,7 @@ describe('ToolRegistry', () => {
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('rejected'))
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: the user rejected tool "echo"' })
})
@@ -279,16 +284,51 @@ describe('ToolRegistry', () => {
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('cancelled'))
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: approval for tool "echo" was cancelled' })
})
it('returns ABORTED_BEFORE_DISPATCH when caller cancellation overtakes approval', async () => {
const ctx = await approvalSetup()
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<ApprovalOutcome>()
let dispatched = 0
ctx.tools.register({
...echoTool,
name: 'approval-probe',
async execute() { dispatched += 1; return [] },
})
ctx.on('approval/request', () => {
entered.resolve(undefined)
return release.promise
})
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
const controller = new AbortController()
const pending = ctx.tools.execute({
callId: CallId('approval-cancelled'),
name: 'approval-probe',
arguments: {},
agent: fakeAgent(),
signal: controller.signal,
})
await entered.promise
controller.abort('caller cancelled approval')
release.resolve('allowed-once')
await expect(pending).resolves.toMatchObject({
isError: true,
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
expect(dispatched).toBe(0)
})
it('denies with the no-channel reason when the seam is mounted but nobody answers', async () => {
const ctx = await approvalSetup()
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but no approval channel is available' })
})
@@ -302,7 +342,7 @@ describe('ToolRegistry', () => {
})
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {} })
expect(asked).toBe(false)
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but the call has no agent to route it through' })
@@ -317,7 +357,7 @@ describe('ToolRegistry', () => {
ctx.provide('approval', { request: () => Promise.resolve('yolo') } as unknown as ApprovalService)
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
expect(result.isError).toBe(true)
const text = result.content[0]?.type === 'text' ? result.content[0].text : ''
expect(text).toContain('unreachable')
@@ -331,7 +371,7 @@ describe('ToolRegistry', () => {
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
({ kind: 'accept', content: [{ type: 'text', text: 'rewritten' }] }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(false)
expect(result.content[0]).toMatchObject({ text: 'rewritten' })
})
@@ -343,7 +383,7 @@ describe('ToolRegistry', () => {
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
({ kind: 'block', feedback: [{ type: 'text', text: 'output rejected: try again' }] }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'output rejected: try again' })
})
@@ -359,7 +399,7 @@ describe('ToolRegistry', () => {
additionalContexts: [{ content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }],
}))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'rejected' })
expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }])
@@ -372,7 +412,7 @@ describe('ToolRegistry', () => {
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }] }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }])
})
@@ -409,7 +449,7 @@ describe('ToolRegistry', () => {
}
})
const result = await ctx.tools.execute({ callId: CallId('composite'), name: 'composite', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('composite'), name: 'composite', arguments: {} })
expect(result.additionalContexts?.map(context => context.source)).toEqual([
{ kind: 'plugin', plugin: 'nested-1' },
@@ -433,7 +473,7 @@ describe('ToolRegistry', () => {
},
}))
const failed = await ctx.tools.execute({ callId: CallId('failed'), name: 'failing-composite', arguments: {} })
const failed = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('failed'), name: 'failing-composite', arguments: {} })
expect(failed.isError).toBe(true)
expect(failed.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'nested' }])
@@ -442,7 +482,7 @@ describe('ToolRegistry', () => {
feedback: [{ type: 'text', text: 'blocked' }],
additionalContexts: [{ content: [{ type: 'text', text: 'block-only' }], source: { kind: 'plugin', plugin: 'blocker' } }],
}))
const blocked = await ctx.tools.execute({ callId: CallId('blocked'), name: 'failing-composite', arguments: {} })
const blocked = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('blocked'), name: 'failing-composite', arguments: {} })
expect(blocked.isError).toBe(true)
expect(blocked.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'blocker' }])
})
@@ -465,7 +505,7 @@ describe('ToolRegistry', () => {
return decision
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'x' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'x' } })
expect(result.isError).toBe(false)
// pre runs fully (gate) before dispatch, then post runs over the result.
expect(order).toEqual(['pre:before', 'pre:after', 'post:before', 'post:after'])
@@ -485,7 +525,7 @@ describe('ToolRegistry', () => {
}))
ctx.on('tools/pre-execute', async (_exec, next) => { order.push('pre'); return next() })
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
order.push('execute:before')
const result = await next()
order.push('execute:after')
@@ -493,7 +533,7 @@ describe('ToolRegistry', () => {
})
ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } })
expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false })
// The around seam wraps dispatch; pre gates before it, post runs over its result.
expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post'])
@@ -524,14 +564,45 @@ describe('ToolRegistry', () => {
release.resolve(undefined)
await expect(pending).resolves.toMatchObject({
content: [{ type: 'text', text: 'Error: tool call aborted' }],
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
expect(dispatched).toBe(0)
})
it('materializes ABORTED when an async pre-execute gate throws after cancellation', async () => {
it('preserves a pre-execute denial that settles after cancellation', async () => {
const ctx = await setup()
let dispatched = 0
ctx.tools.register({
...echoTool,
name: 'denied-after-cancel',
async execute() { dispatched += 1; return [] },
})
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.on('tools/pre-execute', async () => {
entered.resolve(undefined)
await release.promise
return { kind: 'deny', reason: 'policy denied the call' }
})
const controller = new AbortController()
const pending = ctx.tools.execute({
callId: CallId('denied-after-cancel'), name: 'denied-after-cancel', arguments: {}, signal: controller.signal,
})
await entered.promise
controller.abort('cancelled while policy decided')
release.resolve(undefined)
await expect(pending).resolves.toEqual({
content: [{ type: 'text', text: 'Error: policy denied the call' }],
isError: true,
})
expect(dispatched).toBe(0)
})
it('preserves an async pre-execute failure that settles after cancellation', async () => {
const ctx = await setup()
let dispatched = 0
ctx.tools.register({
@@ -555,9 +626,9 @@ describe('ToolRegistry', () => {
controller.abort('cancelled in policy')
release.resolve(undefined)
await expect(pending).resolves.toMatchObject({
await expect(pending).resolves.toEqual({
content: [{ type: 'text', text: 'Error: gate interrupted' }],
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
})
expect(dispatched).toBe(0)
})
@@ -581,8 +652,7 @@ describe('ToolRegistry', () => {
await release.promise
return await next()
} finally {
if (upstream === undefined) delete exec.signal
else exec.signal = upstream
exec.signal = upstream
}
})
@@ -596,7 +666,7 @@ describe('ToolRegistry', () => {
await expect(pending).resolves.toMatchObject({
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
expect(dispatched).toBe(0)
})
@@ -616,8 +686,7 @@ describe('ToolRegistry', () => {
try {
return await next()
} finally {
if (upstream === undefined) delete exec.signal
else exec.signal = upstream
exec.signal = upstream
}
})
@@ -626,7 +695,50 @@ describe('ToolRegistry', () => {
callId: CallId('cancelled-wrapper'), name: 'must-not-run', arguments: {}, signal: controller.signal,
})
expect(result.error).toEqual({ name: 'AbortError', code: 'ABORTED' })
expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
expect(dispatched).toBe(0)
})
it('uses ABORTED_BEFORE_DISPATCH when cancellation overtakes a wrapper short-circuit', async () => {
const ctx = await setup()
let dispatched = 0
ctx.tools.register({
...echoTool,
name: 'short-circuited',
async execute() { dispatched += 1; return [] },
})
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.on('tools/execute', async () => {
entered.resolve(undefined)
await release.promise
return {
content: [{ type: 'text', text: 'wrapper success' }],
isError: false,
additionalContexts: [{
content: [{ type: 'text', text: 'wrapper context' }],
source: { kind: 'plugin', plugin: 'wrapper' },
}],
}
})
const controller = new AbortController()
const pending = ctx.tools.execute({
callId: CallId('cancelled-short-circuit'),
name: 'short-circuited',
arguments: {},
signal: controller.signal,
})
await entered.promise
controller.abort('cancelled while wrapper waited')
release.resolve(undefined)
await expect(pending).resolves.toMatchObject({
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
additionalContexts: [{ source: { kind: 'plugin', plugin: 'wrapper' } }],
})
expect(dispatched).toBe(0)
})
@@ -662,7 +774,7 @@ describe('ToolRegistry', () => {
await expect(pending).resolves.toMatchObject({
content: [{ type: 'text', text: 'Error: tool call aborted' }],
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { name: 'AbortError', code: TOOL_ABORTED },
additionalContexts: [{ source: { kind: 'plugin', plugin: 'child' } }],
})
})
@@ -713,6 +825,94 @@ describe('ToolRegistry', () => {
})
})
it('preserves an around-dispatch failure that settles after cancellation', async () => {
const ctx = await setup()
let dispatched = 0
ctx.tools.register({
...echoTool,
name: 'wrapper-failure',
async execute() { dispatched += 1; return [] },
})
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.on('tools/execute', async () => {
entered.resolve(undefined)
await release.promise
throw new HarnessError('wrapper failed', 'WRAPPER_FAILURE')
})
const controller = new AbortController()
const pending = ctx.tools.execute({
callId: CallId('wrapper-failure'), name: 'wrapper-failure', arguments: {}, signal: controller.signal,
})
await entered.promise
controller.abort('cancelled while wrapper failed')
release.resolve(undefined)
await expect(pending).resolves.toMatchObject({
content: [{ type: 'text', text: 'Error: wrapper failed' }],
isError: true,
error: { name: 'HarnessError', code: 'WRAPPER_FAILURE' },
})
expect(dispatched).toBe(0)
})
it('preserves a tool-owned failure after the body observes cancellation', async () => {
const ctx = await setup()
const entered = Promise.withResolvers<undefined>()
ctx.tools.register({
...echoTool,
name: 'tool-failure',
execute(_args, exec) {
entered.resolve(undefined)
return new Promise<never[]>((_resolve, reject) => {
exec.signal.addEventListener('abort', () => {
reject(new HarnessError('tool failed', 'TOOL_FAILURE'))
}, { once: true })
})
},
})
const controller = new AbortController()
const pending = ctx.tools.execute({
callId: CallId('tool-failure'), name: 'tool-failure', arguments: {}, signal: controller.signal,
})
await entered.promise
controller.abort('cancelled running body')
await expect(pending).resolves.toMatchObject({
content: [{ type: 'text', text: 'Error: tool failed' }],
isError: true,
error: { name: 'HarnessError', code: 'TOOL_FAILURE' },
})
})
it('preserves a post-policy failure that settles after cancellation', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.on('tools/post-execute', async () => {
entered.resolve(undefined)
await release.promise
throw new HarnessError('post-policy failed', 'POST_FAILURE')
})
const controller = new AbortController()
const pending = ctx.tools.execute({
callId: CallId('post-failure'), name: 'echo', arguments: {}, signal: controller.signal,
})
await entered.promise
controller.abort('cancelled while post-policy failed')
release.resolve(undefined)
await expect(pending).resolves.toMatchObject({
content: [{ type: 'text', text: 'Error: post-policy failed' }],
isError: true,
error: { name: 'HarnessError', code: 'POST_FAILURE' },
})
})
it('fuses caller cancellation back into a wrapper replacement for the running body', async () => {
const ctx = await setup()
const entered = Promise.withResolvers<undefined>()
@@ -724,9 +924,9 @@ describe('ToolRegistry', () => {
execute(_args, exec) {
bodySignal = exec.signal
entered.resolve(undefined)
if (exec.signal?.aborted) return Promise.resolve([])
if (exec.signal.aborted) return Promise.resolve([])
return new Promise((resolve) => {
exec.signal?.addEventListener('abort', () => { resolve([]) }, { once: true })
exec.signal.addEventListener('abort', () => { resolve([]) }, { once: true })
})
},
})
@@ -736,8 +936,7 @@ describe('ToolRegistry', () => {
try {
return await next()
} finally {
if (upstream === undefined) delete exec.signal
else exec.signal = upstream
exec.signal = upstream
}
})
@@ -758,30 +957,29 @@ describe('ToolRegistry', () => {
expect(replacement.signal.aborted).toBe(false)
})
it('restores a removed caller signal for dispatch', async () => {
it('restores the required caller signal after around dispatch', async () => {
const ctx = await setup()
let bodySignal: AbortSignal | undefined
ctx.tools.register({
...echoTool,
name: 'signal-probe',
async execute(_args, exec) { bodySignal = exec.signal; return [] },
})
let postSignal: AbortSignal | undefined
ctx.on('tools/execute', async (exec, next) => {
const upstream = exec.signal
delete exec.signal
exec.signal = new AbortController().signal
try {
return await next()
} finally {
if (upstream !== undefined) exec.signal = upstream
exec.signal = upstream
}
})
ctx.on('tools/post-execute', async (exec, _result, next) => {
postSignal = exec.signal
return next()
})
const controller = new AbortController()
await ctx.tools.execute({
callId: CallId('restored-signal'), name: 'signal-probe', arguments: {}, signal: controller.signal,
callId: CallId('restored-signal'), name: 'echo', arguments: {}, signal: controller.signal,
})
expect(bodySignal).toBe(controller.signal)
expect(postSignal).toBe(controller.signal)
})
it('waits for an uncooperative started body before returning ABORTED', async () => {
@@ -820,25 +1018,75 @@ describe('ToolRegistry', () => {
})
})
it('lets an already-aborted entry signal reach the body for domain-specific cleanup', async () => {
it('materializes a pre-aborted call and publishes one result without entering pipeline phases', async () => {
const ctx = await setup()
let dispatched = 0
const phases = { pre: 0, around: 0, body: 0, post: 0, result: 0 }
const callerArguments = { nested: { value: 1 } }
const callerSignal = AbortSignal.abort('already cancelled')
let argumentReads = 0
let observedArguments: unknown
let observedExecution: object | undefined
let observedToken: symbol | undefined
let observedSignal: AbortSignal | undefined
let observedResult: ToolExecutionResult | undefined
ctx.tools.register({
...echoTool,
name: 'domain-abort',
async execute(_args, exec) {
dispatched += 1
expect(exec.signal?.aborted).toBe(true)
throw new HarnessError('domain cleanup completed', 'DOMAIN_ABORTED')
},
async execute() { phases.body += 1; return [] },
})
ctx.on('tools/pre-execute', async (_exec, next) => { phases.pre += 1; return next() })
ctx.on('tools/execute', async (_exec, next) => { phases.around += 1; return next() })
ctx.on('tools/post-execute', async (_exec, _result, next) => { phases.post += 1; return next() })
ctx.on('tools/result', (exec, result) => {
phases.result += 1
observedExecution = exec
observedArguments = exec.arguments
observedToken = exec.token
observedSignal = exec.signal
observedResult = result
})
const result = await ctx.tools.execute({
callId: CallId('pre-aborted'), name: 'domain-abort', arguments: {}, signal: AbortSignal.abort(),
callId: CallId('pre-aborted'),
name: 'domain-abort',
get arguments() { argumentReads += 1; return callerArguments },
signal: callerSignal,
})
expect(dispatched).toBe(1)
expect(result.error).toEqual({ name: 'HarnessError', code: 'DOMAIN_ABORTED' })
expect(argumentReads).toBe(1)
expect(phases).toEqual({ pre: 0, around: 0, body: 0, post: 0, result: 1 })
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
expect(observedResult).toBe(result)
expect(Object.isFrozen(observedExecution)).toBe(true)
expect(typeof observedToken).toBe('symbol')
expect(observedSignal).toBe(callerSignal)
expect(Object.isFrozen(result)).toBe(true)
expect(observedArguments).not.toBe(callerArguments)
expect(Object.isFrozen(observedArguments)).toBe(true)
expect(Object.isFrozen((observedArguments as { nested: object }).nested)).toBe(true)
})
it('lets argument materialization failure win over a pre-aborted signal', async () => {
const ctx = await setup()
let observed = 0
ctx.on('tools/result', () => { observed += 1 })
const result = await ctx.tools.execute({
callId: CallId('invalid-pre-aborted'),
name: 'missing',
arguments: { invalid: () => undefined },
signal: AbortSignal.abort('already cancelled'),
})
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: tool execution arguments must be losslessly JSON-serializable' }],
isError: true,
})
expect(observed).toBe(1)
})
it('a pre-execute deny short-circuits before tools/execute (the seam never runs)', async () => {
@@ -847,12 +1095,12 @@ describe('ToolRegistry', () => {
let entered = false
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'deny', reason: 'nope' }))
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
entered = true
return next()
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: nope' })
expect(entered).toBe(false) // a denied call never enters the around-dispatch seam
@@ -867,7 +1115,7 @@ describe('ToolRegistry', () => {
})
let seen: { isError: boolean; error?: unknown } | undefined
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
const result = await next()
// The base next() IS dispatch-with-normalization: the wrapper sees the
// normalized isError result, never a raw throw from the tool body.
@@ -875,7 +1123,7 @@ describe('ToolRegistry', () => {
return result
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'boom', arguments: {} })
expect(seen).toEqual({ isError: true, error: { name: 'HarnessError', code: 'BOOM' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' })
@@ -890,13 +1138,13 @@ describe('ToolRegistry', () => {
})
let postSaw: boolean | undefined
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => next())
ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => next())
ctx.on('tools/post-execute', async (_exec, result, next) => {
postSaw = result.isError
return next()
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'boom', arguments: {} })
expect(postSaw).toBe(true) // the normalized isError still flows through post-execute
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: exploded' })
@@ -916,7 +1164,7 @@ describe('ToolRegistry', () => {
const upstream = new AbortController().signal
const replacement = new AbortController().signal
ctx.on('tools/execute', async (exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
ctx.on('tools/execute', async (exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
expect(exec.signal).toBe(upstream)
// Cordis next() ignores passed arguments, so a wrapper mutates exec in
// place (the documented "mutate the shared object, then delegate" idiom).
@@ -939,10 +1187,10 @@ describe('ToolRegistry', () => {
async execute() { dispatched = true; return [] },
})
ctx.on('tools/execute', async (_exec: ToolExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
ctx.on('tools/execute', async (_exec: ToolDispatchExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
({ content: [{ type: 'text', text: 'short-circuited' }], isError: false }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'never-runs', arguments: {} })
expect(dispatched).toBe(false) // returning without next() skips core dispatch
expect(result.content[0]).toMatchObject({ text: 'short-circuited' })
})
@@ -960,6 +1208,7 @@ describe('ToolRegistry', () => {
}))
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('around-context'), name: 'echo', arguments: {},
})
expect(result.additionalContexts).toEqual([{
@@ -973,7 +1222,7 @@ describe('ToolRegistry', () => {
ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => { throw new Error('wrapper broke') })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: wrapper broke' }],
isError: true,
@@ -987,7 +1236,7 @@ describe('ToolRegistry', () => {
throw new Error('permission hook broke')
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: permission hook broke' }],
@@ -1002,7 +1251,7 @@ describe('ToolRegistry', () => {
throw new Error('post hook broke')
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: post hook broke' }],
@@ -1017,7 +1266,7 @@ describe('ToolRegistry', () => {
throw new HarnessError('denied', 'DENIED')
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toMatchObject({
isError: true,
@@ -1204,6 +1453,7 @@ describe('defineTool / schema DSL', () => {
}])
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('c1'),
name: 'typed-echo',
arguments: { text: 'hello', uppercase: true },
@@ -1258,6 +1508,7 @@ describe('defineTool / schema DSL', () => {
// Execution round-trip
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('c1'),
name: 'roundtrip',
arguments: { req: 'hello' },
@@ -1290,6 +1541,7 @@ describe('defineTool / schema DSL', () => {
})
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('c1'),
name: 'raw-tool',
arguments: { path: '/tmp' },
@@ -1463,7 +1715,7 @@ describe('schema DSL optional and nested contracts', () => {
throw { message: 'denied by object' }
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'object-thrower', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'object-thrower', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: denied by object' })
})
@@ -1478,7 +1730,7 @@ describe('schema DSL optional and nested contracts', () => {
throw 'kaboom'
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'string-thrower', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'string-thrower', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' })
})
@@ -1493,7 +1745,7 @@ describe('schema DSL optional and nested contracts', () => {
throw { code: 500 }
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'object-no-message', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'object-no-message', arguments: {} })
expect(result.isError).toBe(true)
const firstContent = result.content[0]!
expect(firstContent.type).toBe('text')
@@ -1630,7 +1882,7 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
},
}))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'reader', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({
text: 'Error: invalid arguments: missing required property "path"',
@@ -1647,7 +1899,7 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
return [{ type: 'text', text: `read ${args.path}` }]
},
}))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } })
expect(result).toEqual({ content: [{ type: 'text', text: 'read /x' }], isError: false })
})
@@ -1670,7 +1922,7 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
return [{ type: 'text', text: args.path }]
},
}))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'reader', arguments: {} })
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' })
})
@@ -1685,7 +1937,7 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
throw new HarnessError('disk full', 'ENOSPC')
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'coded', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'coded', arguments: {} })
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'HarnessError', code: 'ENOSPC' })
expect(result.content[0]).toMatchObject({ text: 'Error: disk full' })
@@ -1700,7 +1952,7 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
throw new Error('just a message')
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'plain', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'plain', arguments: {} })
expect(result.isError).toBe(true)
expect(result.error).toBeUndefined()
expect(result.content[0]).toMatchObject({ text: 'Error: just a message' })
@@ -1719,7 +1971,7 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
})
// Missing the "required" path — but raw tools validate their own input, so
// this reaches execute rather than being rejected by the harness.
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'raw', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'raw', arguments: {} })
expect(result.isError).toBe(false)
})