|
|
@@ -1,4 +1,4 @@
|
|
|
-import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
|
+import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
|
import { describe, expect, it } from 'vitest'
|
|
|
import { Context } from 'cordis'
|
|
|
import { type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent'
|
|
|
@@ -10,7 +10,8 @@ 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, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
|
|
-import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
|
|
+import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
|
|
+import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
|
|
import { startInProcessRun } from '../src/index.ts'
|
|
|
|
|
|
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
|
|
@@ -155,6 +156,33 @@ describe('startInProcessRun', () => {
|
|
|
await run.dispose()
|
|
|
})
|
|
|
|
|
|
+ it('keeps earlier streamed text when the final step appends an empty usage-only message', async () => {
|
|
|
+ // Step 1 streams "partial one" plus a tool call; step 2 hits max-tokens
|
|
|
+ // having assembled only a tool-call block, so the loop appends an EMPTY
|
|
|
+ // assistant/message to host usage. The empty message is not assistant
|
|
|
+ // output and must not erase step 1's text from the run's output.
|
|
|
+ const { ctx, parent } = await setup([
|
|
|
+ toolCallResponse('t1', 'noop', {}, 'partial one'),
|
|
|
+ [
|
|
|
+ { type: 'block-start', index: 0, blockType: 'tool-call' },
|
|
|
+ { type: 'tool-call-delta', index: 0, id: CallId('t2'), name: 'noop', argumentsDelta: '{}' },
|
|
|
+ { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('t2'), name: 'noop', arguments: '{}' } },
|
|
|
+ { type: 'usage', usage: { inputTokens: 20, outputTokens: 5 } },
|
|
|
+ { type: 'finish', reason: { kind: 'max-tokens' } },
|
|
|
+ ],
|
|
|
+ ])
|
|
|
+ const disposeNoop = ctx.tools.register(defineContentToolFixture({
|
|
|
+ name: 'noop', description: 'probe', parameters: {},
|
|
|
+ execute() { return Promise.resolve([{ type: 'text', text: 'noop result' }]) },
|
|
|
+ }))
|
|
|
+ const run = await startInProcessRun(request(parent), {})
|
|
|
+ const result = await run.result
|
|
|
+ expect(result.stopReason).toBe('max-tokens')
|
|
|
+ expect(text(result.output)).toBe('partial one')
|
|
|
+ await run.dispose()
|
|
|
+ disposeNoop()
|
|
|
+ })
|
|
|
+
|
|
|
it('seeds a forked child but reads only the child-owned output', async () => {
|
|
|
const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
|
|
|
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } }))
|
|
|
@@ -278,7 +306,12 @@ describe('startInProcessRun', () => {
|
|
|
const signalled = await startInProcessRun(request(parent, controller.signal), {})
|
|
|
await new Promise(resolve => setTimeout(resolve, 30))
|
|
|
controller.abort('stop child')
|
|
|
- await expect(signalled.result).resolves.toMatchObject({ stopReason: 'aborted' })
|
|
|
+ // No step completed a message, so the text streamed before the abort is
|
|
|
+ // the cancelled run's output.
|
|
|
+ await expect(signalled.result).resolves.toEqual({
|
|
|
+ output: [{ type: 'text', text: 'partial' }],
|
|
|
+ stopReason: 'aborted',
|
|
|
+ })
|
|
|
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')
|