Files
deepseek-harness/packages/core/agent-loop/tests/mock-adapter.ts
T
creatixchu 48d8e2f8f5 feat(agent-loop): finalize a cancelled stream's delivered prefix
A turn cancelled mid-stream dropped everything the interrupted step had
streamed: chunks stayed in the log for replay, but no assistant/message
joined the surface, so the next request carried nothing the user had
watched stream. Follow-ups after cancel could not connect and forks
inherited the gap.

Keep the streaming attempt alive across the step's request loop and, when
an abort escapes with the attempt uncommitted, finalize its user-visible
prefix as the step's ordinary assistant/message citing the logged chunk
seqs. BlockAssembler.interruptedBlocks() owns the safe subset next to the
max-tokens rule: closed and open text/reasoning blocks with content, in
stream order; tool calls, empty blocks, and unknown open blocks drop.
Retry clears the attempt first, so an abort after llm/retry finalizes
nothing from the reset stream. Tool-phase cancellation and provider
failures keep their existing shapes.
2026-08-10 12:21:44 +08:00

134 lines
5.3 KiB
TypeScript

import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
/** Helpers to write scripted responses tersely. */
export function textResponse(text: string): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'text' },
...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
{ type: 'block-end', index: 0, block: { type: 'text', text } },
{ type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } },
{ type: 'finish', reason: { kind: 'stop' } },
]
}
/**
* Like {@link textResponse} but the stream ends with a `max-tokens` finish —
* the model was cut off at the output-token ceiling (DeepSeek's `length`).
* Used to exercise the turn-end `max-tokens` surfacing rule.
*/
export function maxTokensResponse(text: string): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'text' },
...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
{ type: 'block-end', index: 0, block: { type: 'text', text } },
{ type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } },
{ type: 'finish', reason: { kind: 'max-tokens' } },
]
}
export function toolCallResponse(rawCallId: string, name: string, args: object, text?: string): StreamChunk[] {
const callId = CallId(rawCallId)
const argumentsJson = JSON.stringify(args)
const chunks: StreamChunk[] = []
let index = 0
if (text) {
chunks.push(
{ type: 'block-start', index, blockType: 'text' },
{ type: 'text-delta', index, text },
{ type: 'block-end', index, block: { type: 'text', text } },
)
index += 1
}
chunks.push(
{ type: 'block-start', index, blockType: 'tool-call' },
{ type: 'tool-call-delta', index, id: callId, name, argumentsDelta: argumentsJson.slice(0, 5) },
{ type: 'tool-call-delta', index, id: callId, argumentsDelta: argumentsJson.slice(5) },
{
type: 'block-end',
index,
block: { type: 'tool-call', id: callId, name, arguments: argumentsJson },
},
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
)
return chunks
}
/** Script entry that streams the given chunks, then hangs until aborted. */
export interface HangAfter {
hangAfter: StreamChunk[]
}
/**
* Mock adapter driven by a script: each model call consumes the next entry.
* Records every request it receives for assertions. An entry may be a
* function to compute chunks from the request, a 'hang' marker that
* streams one chunk then waits until aborted, 'hang-slow' which takes
* 50ms to notice the abort — a stand-in for slow real-world teardown
* (LLM stream cancellation, tool unwinding) — or a {@link HangAfter}
* scripting the exact chunks delivered before the hang.
*/
export class MockAdapter extends LlmAdapter {
requests: GenerateOptions[] = []
constructor(
private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang' | 'hang-slow' | HangAfter)[],
private readonly reasoning?: LlmModelReasoningInfo,
private readonly defaultMaxTokens?: number,
) {
super()
}
override resolveModel(
provider: string,
model: string,
): Promise<LlmResolvedModelInfo> {
return Promise.resolve({
provider,
id: model,
name: model,
...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
...this.defaultMaxTokens === undefined ? {} : { defaultMaxTokens: this.defaultMaxTokens },
})
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const entry = this.script.shift()
if (!entry) throw new Error('MockAdapter: script exhausted')
if (entry === 'hang') {
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text: 'partial' }
await new Promise<void>((_resolve, reject) => {
if (options.signal?.aborted) { reject(new Error('aborted')); return }
options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
})
return
}
if (typeof entry === 'object' && !Array.isArray(entry) && 'hangAfter' in entry) {
for (const chunk of entry.hangAfter) yield chunk
await new Promise<void>((_resolve, reject) => {
if (options.signal?.aborted) { reject(new Error('aborted')); return }
options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
})
return
}
if (entry === 'hang-slow') {
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text: 'partial' }
await new Promise<void>((_resolve, reject) => {
const fail = (): void => { reject(new Error('aborted')) }
if (options.signal?.aborted) { setTimeout(fail, 50); return }
options.signal?.addEventListener('abort', () => { setTimeout(fail, 50) }, { once: true })
})
return
}
const chunks = typeof entry === 'function' ? entry(options) : entry
for (const chunk of chunks) {
if (options.signal?.aborted) throw new Error('aborted')
yield chunk
}
}
}