diff --git a/packages/core/tools/src/batch.ts b/packages/core/tools/src/batch.ts index ea744a9d8b..d1b0e1a6f4 100644 --- a/packages/core/tools/src/batch.ts +++ b/packages/core/tools/src/batch.ts @@ -257,7 +257,11 @@ class BatchSettlement { message: createToolResultMessage({ callId: block.id, content: result.content, isError: result.isError }), ...result.error?.info ? { error: result.error.info } : {}, ...result.meta !== undefined ? { meta: result.meta } : {}, - }, { surfaceOp: 'append', ...callSeq === undefined ? {} : { sourceEventSeqs: [callSeq] } }) + }, { + surfaceOp: 'append', + // oxlint-disable-next-line typescript/no-non-null-assertion -- start records a call seq whenever this batch logs results + sourceEventSeqs: [callSeq!], + }) } this.results.push({ callId: block.id, result }) this.contexts.push(...result.additionalContexts ?? []) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index ca90e1bd72..7b12f80037 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -1,4 +1,5 @@ import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -1761,6 +1762,25 @@ describe('ToolRuntime', () => { expect(observed).toBe(1) }) + it('reports cancellation before an executor refusal without entering policy', async ({ onTestFinished }) => { + const ctx = await setup() + onTestFinished(() => ctx.fiber.dispose()) + let policyCalls = 0 + ctx.on('tools/pre-execute', async (_exec, next) => { policyCalls += 1; return next() }) + const pipeline = new ToolExecutionPipeline(ctx, { + get: () => echoTool, + reject: () => new Error('executor refuses direct calls'), + }) + const prepared = await pipeline.prepare({ + callId: ToolCallId('cancelled-refusal'), name: 'echo', arguments: {}, signal: AbortSignal.abort(), + }) + expect(prepared.kind).toBe('final-result') + if (prepared.kind === 'dispatch') throw new Error('cancelled call was dispatched') + const result = await pipeline.complete(prepared.exec, prepared) + expect(result.error?.info?.code).toBe(TOOL_ABORTED_BEFORE_DISPATCH) + expect(policyCalls).toBe(0) + }) + it('a pre-execute deny short-circuits before tools/execute (the seam never runs)', async () => { const ctx = await setup() ctx.tools.register(echoTool) diff --git a/packages/test-support/tool-testkit/package.json b/packages/test-support/tool-testkit/package.json index eb3221b5f6..f0835ab2ea 100644 --- a/packages/test-support/tool-testkit/package.json +++ b/packages/test-support/tool-testkit/package.json @@ -34,6 +34,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" } } diff --git a/packages/test-support/tool-testkit/tests/model-call.spec.ts b/packages/test-support/tool-testkit/tests/model-call.spec.ts new file mode 100644 index 0000000000..3638f83965 --- /dev/null +++ b/packages/test-support/tool-testkit/tests/model-call.spec.ts @@ -0,0 +1,41 @@ +/** Model-call fixtures preserve raw JSON input and reject incomplete execution. */ +import { Context } from '@deepseek-ai/cordis' +import { ToolCallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import Tools, { type ToolExecutor } from '@deepseek-ai/dsh-tools' +import { expect, it, onTestFinished } from 'vitest' +import { executeModelToolFixture } from '../src/index.ts' + +async function setup(execute: ToolExecutor['execute']): Promise { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(SystemPrompt) + await ctx.plugin(Tools) + ctx.tools.setExecutor({ describe: () => ({ schemas: [], knownNames: [] }), execute }) + return ctx +} + +const call = { callId: ToolCallId('fixture'), name: 'echo', signal: new AbortController().signal } + +it('serializes model arguments and returns the selected executor result without an Agent', async () => { + const result = { isError: false as const, value: 'done', content: [{ type: 'text' as const, text: 'done' }] } + const ctx = await setup(async (input) => { + expect(input.calls).toEqual([{ type: 'tool-call', id: call.callId, name: 'echo', arguments: '{"text":"hello"}' }]) + expect(input.signal).toBe(call.signal) + expect(input.agent).toBeUndefined() + return { results: [{ callId: call.callId, result }], additionalContexts: [], concluded: false } + }) + expect(await executeModelToolFixture(ctx, { ...call, arguments: { text: 'hello' } })).toBe(result) +}) + +it('rejects unencodable model arguments before invoking the executor', async () => { + const ctx = await setup(async () => { throw new Error('executor must not run') }) + await expect(executeModelToolFixture(ctx, { ...call, arguments: undefined })) + .rejects.toThrow('Model tool fixture arguments must encode as JSON') +}) + +it('reports when an executor omits the requested result', async () => { + const ctx = await setup(async () => ({ results: [], additionalContexts: [], concluded: false })) + await expect(executeModelToolFixture(ctx, { ...call, arguments: {} })) + .rejects.toThrow('Executor omitted the model tool fixture result') +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea116afa03..8913024452 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15481,6 +15481,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../../llm/llm + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:* + version: link:../../core/system-prompt '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools