diff --git a/src/core/agent.ts b/src/core/agent.ts index 8e9f017..8fd22cd 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -474,6 +474,11 @@ export class Agent { if (lastAssistant) { const combined = lastAssistant.content .filter((block): block is Extract => block.type === 'text') + .filter( + (block) => + block.text.length > 0 || + !(block.meta?.thought_signature || block.meta?.thoughtSignature) + ) .map((block) => block.text) .join('\n'); if (combined.trim().length > 0) { @@ -1068,12 +1073,20 @@ export class Agent { if (chunk.content_block?.type === 'text') { currentBlockIndex = chunk.index ?? 0; textBuffers.set(currentBlockIndex, ''); - assistantBlocks[currentBlockIndex] = { type: 'text', text: '' }; + assistantBlocks[currentBlockIndex] = { + type: 'text', + text: '', + ...(chunk.content_block.meta ? { meta: chunk.content_block.meta } : {}), + }; this.events.emitProgress({ channel: 'progress', type: 'text_chunk_start', step: this.stepCount }); } else if (chunk.content_block?.type === 'reasoning') { currentBlockIndex = chunk.index ?? 0; reasoningBuffers.set(currentBlockIndex, ''); - assistantBlocks[currentBlockIndex] = { type: 'reasoning', reasoning: '' }; + assistantBlocks[currentBlockIndex] = { + type: 'reasoning', + reasoning: '', + ...(chunk.content_block.meta ? { meta: chunk.content_block.meta } : {}), + }; if (this.exposeThinking) { this.events.emitProgress({ channel: 'progress', type: 'think_chunk_start', step: this.stepCount }); } @@ -1161,7 +1174,8 @@ export class Agent { const storedBlocks = this.retainThinking ? originalBlocks : originalBlocks.filter((block) => block.type !== 'reasoning'); - const metadata = this.buildMessageMetadata(originalBlocks, storedBlocks, this.retainThinking ? 'provider' : 'omit'); + const metadataBlocks = this.retainThinking ? originalBlocks : storedBlocks; + const metadata = this.buildMessageMetadata(metadataBlocks, storedBlocks, this.retainThinking ? 'provider' : 'omit'); this.messages.push({ role: 'assistant', content: storedBlocks, metadata }); await this.persistMessages(); @@ -2507,6 +2521,7 @@ function ensureModelFactory(factory?: ModelFactory): ModelFactory { extraBody: config.extraBody, providerOptions: config.providerOptions, multimodal: config.multimodal, + thinking: config.thinking, }); } if (config.provider === 'glm') { diff --git a/src/core/types.ts b/src/core/types.ts index a25c5c4..cffb98b 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -3,7 +3,7 @@ export type MessageRole = 'user' | 'assistant' | 'system'; export type ContentBlock = - | { type: 'text'; text: string } + | { type: 'text'; text: string; meta?: Record } | { type: 'image_url'; image_url: { url: string } } | { type: 'tool_use'; id: string; name: string; input: any; meta?: Record } | { type: 'tool_result'; tool_use_id: string; content: any; is_error?: boolean } diff --git a/src/infra/providers/core/fork.ts b/src/infra/providers/core/fork.ts index 88c7db2..338ffda 100644 --- a/src/infra/providers/core/fork.ts +++ b/src/infra/providers/core/fork.ts @@ -426,7 +426,7 @@ export const geminiResumeHandler: ResumeHandler = { const processedBlocks = msg.content.map(block => { if (block.type === 'reasoning') { // Keep if has signature, otherwise omit - if (block.meta?.thoughtSignature) { + if (block.meta?.thought_signature || block.meta?.thoughtSignature) { return block; } return null; diff --git a/src/infra/providers/gemini.ts b/src/infra/providers/gemini.ts index 3506611..ba7563e 100644 --- a/src/infra/providers/gemini.ts +++ b/src/infra/providers/gemini.ts @@ -50,6 +50,11 @@ export interface GeminiProviderOptions { thinking?: ThinkingOptions; } +type GeminiDecodedPart = + | { type: 'text'; text: string; thoughtSignature?: string } + | { type: 'reasoning'; text: string; thoughtSignature?: string } + | { type: 'tool_use'; id?: string; name: string; args: any; thoughtSignature?: string }; + export class GeminiProvider implements ModelProvider { readonly maxWindowSize = 1_000_000; readonly maxOutputTokens = 4096; @@ -225,135 +230,115 @@ export class GeminiProvider implements ModelProvider { throw new Error(`Gemini API error: ${response.status} ${error}`); } - const reader = response.body?.getReader(); - if (!reader) throw new Error('No response body'); - - const decoder = new TextDecoder(); - let buffer = ''; - let textStarted = false; - const textIndex = 0; - let toolIndex = 1; - const toolCalls: Array<{ name: string; args: any; thoughtSignature?: string }> = []; + let nextBlockIndex = 0; + let activeBlock: { type: 'text' | 'reasoning'; index: number } | undefined; let lastUsage: { input: number; output: number } | undefined; - let collectAll = false; - while (true) { - const { done, value } = await reader.read(); - if (done) break; + function* closeActiveBlock(): Generator { + if (!activeBlock) return; + yield { type: 'content_block_stop', index: activeBlock.index }; + activeBlock = undefined; + } - const chunk = decoder.decode(value, { stream: true }); - if (collectAll) { - buffer += chunk; - continue; + function* emitPart(part: GeminiDecodedPart): Generator { + if (part.type === 'tool_use') { + yield* closeActiveBlock(); + const index = nextBlockIndex++; + const id = part.id ?? `toolcall-${Date.now()}-${index}`; + const meta = { + ...(part.thoughtSignature ? { thought_signature: part.thoughtSignature } : {}), + ...(!part.id ? { gemini_function_call_id_present: false } : {}), + }; + yield { + type: 'content_block_start', + index, + content_block: { + type: 'tool_use', + id, + name: part.name, + input: {}, + ...(Object.keys(meta).length > 0 ? { meta } : {}), + }, + }; + yield { + type: 'content_block_delta', + index, + delta: { type: 'input_json_delta', partial_json: safeJsonStringify(part.args) }, + }; + yield { type: 'content_block_stop', index }; + return; } - buffer += chunk; - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (let i = 0; i < lines.length; i++) { - let trimmed = lines[i].trim(); - if (!trimmed) continue; - if (trimmed.startsWith('event:') || trimmed.startsWith(':')) continue; - if (trimmed.startsWith('data:')) { - trimmed = trimmed.slice(5).trim(); - } - if (!trimmed || trimmed === '[DONE]') continue; - if (trimmed.startsWith('[')) { - collectAll = true; - buffer = [trimmed, ...lines.slice(i + 1), buffer].filter(Boolean).join('\n'); - break; - } - - let event: any; - try { - event = JSON.parse(trimmed); - } catch { - collectAll = true; - buffer = [trimmed, ...lines.slice(i + 1), buffer].filter(Boolean).join('\n'); - break; - } - - const { textChunks, functionCalls, usage } = this.parseGeminiChunk(event); - if (usage) { - lastUsage = usage; - } - - for (const text of textChunks) { - if (!textStarted) { - textStarted = true; - yield { - type: 'content_block_start', - index: textIndex, - content_block: { type: 'text', text: '' }, - }; - } + if (part.thoughtSignature) { + yield* closeActiveBlock(); + const index = nextBlockIndex++; + const meta = { thought_signature: part.thoughtSignature }; + yield { + type: 'content_block_start', + index, + content_block: + part.type === 'reasoning' + ? { type: 'reasoning', reasoning: '', meta } + : { type: 'text', text: '', meta }, + }; + if (part.text.length > 0) { yield { type: 'content_block_delta', - index: textIndex, - delta: { type: 'text_delta', text }, + index, + delta: + part.type === 'reasoning' + ? { type: 'reasoning_delta', text: part.text } + : { type: 'text_delta', text: part.text }, }; } + yield { type: 'content_block_stop', index }; + return; + } - for (const call of functionCalls) { - toolCalls.push(call); - } + if (part.text.length === 0) return; + if (!activeBlock || activeBlock.type !== part.type) { + yield* closeActiveBlock(); + const index = nextBlockIndex++; + activeBlock = { type: part.type, index }; + yield { + type: 'content_block_start', + index, + content_block: + part.type === 'reasoning' + ? { type: 'reasoning', reasoning: '' } + : { type: 'text', text: '' }, + }; } + + yield { + type: 'content_block_delta', + index: activeBlock.index, + delta: + part.type === 'reasoning' + ? { type: 'reasoning_delta', text: part.text } + : { type: 'text_delta', text: part.text }, + }; } - if (buffer.trim()) { - try { - const parsed = JSON.parse(buffer.trim()); - const events = Array.isArray(parsed) ? parsed : [parsed]; + try { + for await (const payload of this.iterateGeminiStreamEvents(response)) { + const events = Array.isArray(payload) ? payload : [payload]; for (const event of events) { - const { textChunks, functionCalls, usage } = this.parseGeminiChunk(event); + const { parts, usage } = this.parseGeminiChunk(event); if (usage) { lastUsage = usage; } - for (const text of textChunks) { - if (!textStarted) { - textStarted = true; - yield { - type: 'content_block_start', - index: textIndex, - content_block: { type: 'text', text: '' }, - }; - } - yield { - type: 'content_block_delta', - index: textIndex, - delta: { type: 'text_delta', text }, - }; - } - for (const call of functionCalls) { - toolCalls.push(call); + for (const part of parts) { + yield* emitPart(part); } } - } catch { - // ignore trailing buffer } + } catch (error) { + yield* closeActiveBlock(); + throw error; } - if (textStarted) { - yield { type: 'content_block_stop', index: textIndex }; - } - - for (const call of toolCalls) { - const id = `toolcall-${Date.now()}-${toolIndex}`; - const meta = call.thoughtSignature ? { thought_signature: call.thoughtSignature } : undefined; - yield { - type: 'content_block_start', - index: toolIndex, - content_block: { type: 'tool_use', id, name: call.name, input: {}, ...(meta ? { meta } : {}) }, - }; - yield { - type: 'content_block_delta', - index: toolIndex, - delta: { type: 'input_json_delta', partial_json: safeJsonStringify(call.args) }, - }; - yield { type: 'content_block_stop', index: toolIndex }; - toolIndex += 1; - } + yield* closeActiveBlock(); if (lastUsage) { yield { @@ -392,6 +377,127 @@ export class GeminiProvider implements ModelProvider { return url; } + private async *iterateGeminiStreamEvents(response: Response): AsyncGenerator { + const parseJsonPayload = (payload: string, context: string): any[] => { + const trimmed = payload.trim(); + if (!trimmed) return []; + let parsed: any; + try { + parsed = JSON.parse(trimmed); + } catch (error: any) { + throw new Error(`Gemini stream parse error in ${context}: ${error?.message ?? 'invalid JSON'}`); + } + return Array.isArray(parsed) ? parsed : [parsed]; + }; + + const reader = response.body?.getReader(); + if (!reader) throw new Error('No response body'); + + const decoder = new TextDecoder(); + const doneMarker = Symbol('done'); + let wireMode: 'unknown' | 'sse' | 'json' = 'unknown'; + let buffer = ''; + let dataLines: string[] = []; + let frameIndex = 0; + + const dispatchFrame = (): any | typeof doneMarker | undefined => { + if (dataLines.length === 0) return undefined; + const data = dataLines.join('\n'); + dataLines = []; + frameIndex += 1; + if (data.trim() === '[DONE]') return doneMarker; + try { + return JSON.parse(data); + } catch (error: any) { + throw new Error( + `Gemini stream parse error in SSE frame ${frameIndex}: ${error?.message ?? 'invalid JSON'}` + ); + } + }; + + const consumeLine = (rawLine: string): any | typeof doneMarker | undefined => { + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; + if (line.length === 0) return dispatchFrame(); + if (line.startsWith(':')) return undefined; + + const colon = line.indexOf(':'); + const field = colon === -1 ? line : line.slice(0, colon); + let value = colon === -1 ? '' : line.slice(colon + 1); + if (value.startsWith(' ')) value = value.slice(1); + if (field === 'data') dataLines.push(value); + return undefined; + }; + + try { + while (true) { + const { done, value } = await reader.read(); + buffer += done ? decoder.decode() : decoder.decode(value, { stream: true }); + + if (wireMode === 'unknown') { + const start = buffer.trimStart(); + if (start.startsWith('[') || start.startsWith('{')) { + wireMode = 'json'; + } else { + const firstLine = start.split(/\r?\n/, 1)[0]; + if ( + firstLine.startsWith('data:') || + firstLine.startsWith('event:') || + firstLine.startsWith('id:') || + firstLine.startsWith('retry:') || + firstLine.startsWith(':') + ) { + wireMode = 'sse'; + } + } + } + + if (wireMode === 'json') { + if (!done) continue; + for (const event of parseJsonPayload(buffer, 'JSON-array fallback')) { + yield event; + } + return; + } + + if (wireMode === 'unknown') { + if (done && buffer.trim().length > 0) { + throw new Error('Gemini stream parse error: unrecognized response framing'); + } + if (done) return; + continue; + } + + let newlineIndex: number; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + const event = consumeLine(line); + if (event === doneMarker) return; + if (event !== undefined) yield event; + } + + if (done) break; + } + + if (buffer.length > 0) { + const event = consumeLine(buffer); + if (event === doneMarker) return; + if (event !== undefined) yield event; + } + const finalEvent = dispatchFrame(); + if (finalEvent !== undefined && finalEvent !== doneMarker) { + yield finalEvent; + } + } finally { + try { + await reader.cancel(); + } catch { + // The stream may already be closed or errored. + } + reader.releaseLock(); + } + } + private buildGeminiRequestBody( messages: Message[], opts: { @@ -411,10 +517,16 @@ export class GeminiProvider implements ModelProvider { if (opts.temperature !== undefined) generationConfig.temperature = opts.temperature; if (opts.maxTokens !== undefined) generationConfig.maxOutputTokens = opts.maxTokens; - if (opts.thinking?.budgetTokens) { - generationConfig.thinkingConfig = { thinkingBudget: opts.thinking.budgetTokens }; + if (opts.thinking?.budgetTokens !== undefined) { + generationConfig.thinkingConfig = { + thinkingBudget: opts.thinking.budgetTokens, + includeThoughts: true, + }; } else if (opts.thinking?.level) { - generationConfig.thinkingConfig = { thinkingLevel: opts.thinking.level.toUpperCase() }; + generationConfig.thinkingConfig = { + thinkingLevel: opts.thinking.level.toUpperCase(), + includeThoughts: true, + }; } const body: any = { @@ -454,11 +566,13 @@ export class GeminiProvider implements ModelProvider { const contents: any[] = []; const toolNameById = new Map(); const toolSignatureById = new Map(); + const toolHasProviderIdById = new Map(); for (const msg of messages) { for (const block of getMessageBlocks(msg)) { if (block.type === 'tool_use') { toolNameById.set(block.id, block.name); + toolHasProviderIdById.set(block.id, block.meta?.gemini_function_call_id_present !== false); const signature = (block as any).meta?.thought_signature ?? (block as any).meta?.thoughtSignature; if (typeof signature === 'string' && signature.length > 0) { toolSignatureById.set(block.id, signature); @@ -475,11 +589,26 @@ export class GeminiProvider implements ModelProvider { const blocks = getMessageBlocks(msg); for (const block of blocks) { if (block.type === 'text') { - if (block.text) parts.push({ text: block.text }); + const thoughtSignature = block.meta?.thought_signature ?? block.meta?.thoughtSignature; + if (block.text || thoughtSignature) { + parts.push({ + text: block.text, + ...(thoughtSignature ? { thoughtSignature } : {}), + }); + } } else if (block.type === 'reasoning') { if (reasoningTransport === 'text') { + // A thought signature is valid only on the exact native Part. Text transport + // intentionally rewrites that Part for cross-provider compatibility. const text = `${block.reasoning}`; parts.push({ text }); + } else if (reasoningTransport === 'provider') { + const thoughtSignature = block.meta?.thought_signature ?? block.meta?.thoughtSignature; + parts.push({ + text: block.reasoning, + thought: true, + ...(thoughtSignature ? { thoughtSignature } : {}), + }); } } else if (block.type === 'image') { const imagePart = buildGeminiImagePart(block); @@ -514,8 +643,10 @@ export class GeminiProvider implements ModelProvider { parts.push({ text: FILE_UNSUPPORTED_TEXT }); } } else if (block.type === 'tool_use') { + const includeId = toolHasProviderIdById.get(block.id) !== false; const part: any = { functionCall: { + ...(includeId ? { id: block.id } : {}), name: block.name, args: this.normalizeGeminiArgs(block.input), }, @@ -527,8 +658,10 @@ export class GeminiProvider implements ModelProvider { parts.push(part); } else if (block.type === 'tool_result') { const toolName = toolNameById.get(block.tool_use_id) ?? 'tool'; + const includeId = toolHasProviderIdById.get(block.tool_use_id) !== false; parts.push({ functionResponse: { + ...(includeId ? { id: block.tool_use_id } : {}), name: toolName, response: { content: this.formatGeminiToolResult(block.content) }, }, @@ -571,19 +704,28 @@ export class GeminiProvider implements ModelProvider { private extractGeminiContentBlocks(content: any): ContentBlock[] { const blocks: ContentBlock[] = []; - const parts = content?.parts ?? []; - for (const part of parts) { - if (typeof part?.text === 'string') { - blocks.push({ type: 'text', text: part.text }); - } else if (part?.functionCall) { - const call = part.functionCall; - const thoughtSignature = part?.thoughtSignature ?? call?.thoughtSignature; + for (const part of this.decodeGeminiParts(content)) { + if (part.type === 'text' || part.type === 'reasoning') { + const meta = part.thoughtSignature + ? { thought_signature: part.thoughtSignature } + : undefined; + blocks.push( + part.type === 'reasoning' + ? { type: 'reasoning', reasoning: part.text, ...(meta ? { meta } : {}) } + : { type: 'text', text: part.text, ...(meta ? { meta } : {}) } + ); + } else { + const hasProviderId = part.id !== undefined; + const meta = { + ...(part.thoughtSignature ? { thought_signature: part.thoughtSignature } : {}), + ...(!hasProviderId ? { gemini_function_call_id_present: false } : {}), + }; blocks.push({ type: 'tool_use', - id: `toolcall-${Date.now()}-${blocks.length}`, - name: call.name ?? 'tool', - input: call.args ?? {}, - ...(thoughtSignature ? { meta: { thought_signature: thoughtSignature } } : {}), + id: part.id ?? `toolcall-${Date.now()}-${blocks.length}`, + name: part.name, + input: part.args, + ...(Object.keys(meta).length > 0 ? { meta } : {}), }); } } @@ -591,28 +733,14 @@ export class GeminiProvider implements ModelProvider { } private parseGeminiChunk(event: any): { - textChunks: string[]; - functionCalls: Array<{ name: string; args: any; thoughtSignature?: string }>; + parts: GeminiDecodedPart[]; usage?: { input: number; output: number }; } { - const textChunks: string[] = []; - const functionCalls: Array<{ name: string; args: any; thoughtSignature?: string }> = []; + const parsedParts: GeminiDecodedPart[] = []; const candidates = Array.isArray(event?.candidates) ? event.candidates : []; for (const candidate of candidates) { - const parts = candidate?.content?.parts ?? []; - for (const part of parts) { - if (typeof part?.text === 'string') { - textChunks.push(part.text); - } else if (part?.functionCall) { - const thoughtSignature = part?.thoughtSignature ?? part?.functionCall?.thoughtSignature; - functionCalls.push({ - name: part.functionCall.name ?? 'tool', - args: part.functionCall.args ?? {}, - ...(thoughtSignature ? { thoughtSignature } : {}), - }); - } - } + parsedParts.push(...this.decodeGeminiParts(candidate?.content)); } const usageMetadata = event?.usageMetadata; @@ -623,6 +751,37 @@ export class GeminiProvider implements ModelProvider { } : undefined; - return { textChunks, functionCalls, usage }; + return { parts: parsedParts, usage }; + } + + private decodeGeminiParts(content: any): GeminiDecodedPart[] { + const decoded: GeminiDecodedPart[] = []; + const parts = Array.isArray(content?.parts) ? content.parts : []; + for (const part of parts) { + if (typeof part?.text === 'string') { + const thoughtSignature = + typeof part.thoughtSignature === 'string' && part.thoughtSignature.length > 0 + ? part.thoughtSignature + : undefined; + if (part.text.length === 0 && !thoughtSignature) continue; + decoded.push({ + type: part.thought === true ? 'reasoning' : 'text', + text: part.text, + ...(thoughtSignature ? { thoughtSignature } : {}), + }); + } else if (part?.functionCall) { + const thoughtSignature = part.thoughtSignature ?? part.functionCall.thoughtSignature; + decoded.push({ + type: 'tool_use', + ...(typeof part.functionCall.id === 'string' && part.functionCall.id.length > 0 + ? { id: part.functionCall.id } + : {}), + name: part.functionCall.name ?? 'tool', + args: part.functionCall.args ?? {}, + ...(thoughtSignature ? { thoughtSignature } : {}), + }); + } + } + return decoded; } } diff --git a/tests/unit/core/agent.test.ts b/tests/unit/core/agent.test.ts index 1be7350..31ca181 100644 --- a/tests/unit/core/agent.test.ts +++ b/tests/unit/core/agent.test.ts @@ -210,6 +210,267 @@ runner fs.rmSync(storeDir, { recursive: true, force: true }); }) + .test('Agent persists streamed metadata only when retaining thinking', async () => { + class SignedStreamProvider implements ModelProvider { + readonly model = 'signed-stream-provider'; + readonly maxWindowSize = 128000; + readonly maxOutputTokens = 4096; + readonly temperature = 0; + + async complete(): Promise { + throw new Error('complete() should not be called'); + } + + async *stream(): AsyncIterable { + yield { + type: 'content_block_start', + index: 0, + content_block: { + type: 'reasoning', + reasoning: '', + meta: { thought_signature: 'signature-thought' }, + }, + }; + yield { + type: 'content_block_delta', + index: 0, + delta: { type: 'reasoning_delta', text: 'PLAN' }, + }; + yield { type: 'content_block_stop', index: 0 }; + yield { + type: 'content_block_start', + index: 1, + content_block: { + type: 'text', + text: '', + meta: { thought_signature: 'signature-answer' }, + }, + }; + yield { + type: 'content_block_delta', + index: 1, + delta: { type: 'text_delta', text: 'ANSWER' }, + }; + yield { type: 'content_block_stop', index: 1 }; + yield { + type: 'content_block_start', + index: 2, + content_block: { + type: 'text', + text: '', + meta: { thought_signature: 'signature-empty' }, + }, + }; + yield { type: 'content_block_stop', index: 2 }; + yield { type: 'message_stop' }; + } + + toConfig() { + return { + provider: 'gemini' as const, + model: this.model, + reasoningTransport: 'provider' as const, + }; + } + } + + const workDir = path.join(TEST_ROOT, `agent-signed-work-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`); + const storeDir = path.join(TEST_ROOT, `agent-signed-store-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`); + const omitWorkDir = path.join(TEST_ROOT, `agent-omit-work-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`); + const omitStoreDir = path.join(TEST_ROOT, `agent-omit-store-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`); + ensureCleanDir(workDir); + ensureCleanDir(storeDir); + ensureCleanDir(omitWorkDir); + ensureCleanDir(omitStoreDir); + + try { + const templates = new AgentTemplateRegistry(); + templates.register({ + id: 'signed-stream-template', + systemPrompt: 'test signed stream metadata', + tools: [], + permission: { mode: 'auto' }, + }); + const store = new JSONStore(storeDir); + const agent = await Agent.create( + { + templateId: 'signed-stream-template', + model: new SignedStreamProvider(), + exposeThinking: true, + retainThinking: true, + sandbox: { kind: 'local', workDir, enforceBoundary: true, watchFiles: false }, + }, + { + store, + templateRegistry: templates, + sandboxFactory: new SandboxFactory(), + toolRegistry: new ToolRegistry(), + } + ); + + const retainedResult = await agent.chat('preserve signed parts'); + expect.toEqual(retainedResult.text, 'ANSWER'); + const messages = await store.loadMessages(agent.agentId); + const assistant = messages.find((message) => message.role === 'assistant'); + expect.toDeepEqual(assistant?.content, [ + { + type: 'reasoning', + reasoning: 'PLAN', + meta: { thought_signature: 'signature-thought' }, + }, + { + type: 'text', + text: 'ANSWER', + meta: { thought_signature: 'signature-answer' }, + }, + { + type: 'text', + text: '', + meta: { thought_signature: 'signature-empty' }, + }, + ]); + + const omitStore = new JSONStore(omitStoreDir); + const omitAgent = await Agent.create( + { + templateId: 'signed-stream-template', + model: new SignedStreamProvider(), + exposeThinking: true, + retainThinking: false, + sandbox: { kind: 'local', workDir: omitWorkDir, enforceBoundary: true, watchFiles: false }, + }, + { + store: omitStore, + templateRegistry: templates, + sandboxFactory: new SandboxFactory(), + toolRegistry: new ToolRegistry(), + } + ); + + const progressTypes: string[] = []; + for await (const envelope of omitAgent.chatStream('omit thought history')) { + progressTypes.push(envelope.event.type); + } + expect.toDeepEqual(progressTypes, [ + 'think_chunk_start', + 'think_chunk', + 'think_chunk_end', + 'text_chunk_start', + 'text_chunk', + 'text_chunk_end', + 'text_chunk_start', + 'text_chunk_end', + 'done', + ]); + const omitMessages = await omitStore.loadMessages(omitAgent.agentId); + const omittedAssistant = omitMessages.find((message) => message.role === 'assistant'); + expect.toDeepEqual(omittedAssistant?.content, [ + { + type: 'text', + text: 'ANSWER', + meta: { thought_signature: 'signature-answer' }, + }, + { + type: 'text', + text: '', + meta: { thought_signature: 'signature-empty' }, + }, + ]); + expect.toBeFalsy(JSON.stringify(omittedAssistant).includes('PLAN')); + } finally { + fs.rmSync(workDir, { recursive: true, force: true }); + fs.rmSync(storeDir, { recursive: true, force: true }); + fs.rmSync(omitWorkDir, { recursive: true, force: true }); + fs.rmSync(omitStoreDir, { recursive: true, force: true }); + } + }) + + .test('Agent Gemini modelConfig forwards thinking and emits isolated thought events', async () => { + const workDir = path.join(TEST_ROOT, `agent-gemini-work-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`); + const storeDir = path.join(TEST_ROOT, `agent-gemini-store-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`); + ensureCleanDir(workDir); + ensureCleanDir(storeDir); + + const templates = new AgentTemplateRegistry(); + templates.register({ + id: 'gemini-thinking-template', + systemPrompt: 'test Gemini thinking through Agent modelConfig', + tools: [], + permission: { mode: 'auto' }, + }); + const store = new JSONStore(storeDir); + const originalFetch = globalThis.fetch; + let capturedBody: any; + globalThis.fetch = (async (_url: any, init: any) => { + capturedBody = JSON.parse(init.body); + return new Response( + `data: ${JSON.stringify({ + candidates: [ + { + content: { + parts: [ + { text: 'PLAN', thought: true }, + { text: 'ANSWER' }, + ], + }, + }, + ], + })}\n\n`, + { status: 200, headers: { 'content-type': 'text/event-stream' } } + ); + }) as any; + + try { + const agent = await Agent.create( + { + templateId: 'gemini-thinking-template', + modelConfig: { + provider: 'gemini', + apiKey: 'test-key', + model: 'gemini-2.5-flash', + reasoningTransport: 'omit', + thinking: { budgetTokens: 0 }, + }, + exposeThinking: true, + retainThinking: false, + sandbox: { kind: 'local', workDir, enforceBoundary: true, watchFiles: false }, + }, + { + store, + templateRegistry: templates, + sandboxFactory: new SandboxFactory(), + toolRegistry: new ToolRegistry(), + } + ); + + const progressTypes: string[] = []; + for await (const envelope of agent.chatStream('solve this')) { + progressTypes.push(envelope.event.type); + } + expect.toDeepEqual(capturedBody.generationConfig.thinkingConfig, { + thinkingBudget: 0, + includeThoughts: true, + }); + expect.toDeepEqual(progressTypes, [ + 'think_chunk_start', + 'think_chunk', + 'think_chunk_end', + 'text_chunk_start', + 'text_chunk', + 'text_chunk_end', + 'done', + ]); + const messages = await store.loadMessages(agent.agentId); + const assistant = messages.find((message) => message.role === 'assistant'); + expect.toDeepEqual(assistant?.content, [{ type: 'text', text: 'ANSWER' }]); + expect.toBeFalsy(JSON.stringify(assistant).includes('PLAN')); + } finally { + globalThis.fetch = originalFetch; + fs.rmSync(workDir, { recursive: true, force: true }); + fs.rmSync(storeDir, { recursive: true, force: true }); + } + }) + .test('Todo 管理API在启用时可用', async () => { const template = { id: 'todo-agent', diff --git a/tests/unit/providers/gemini.test.ts b/tests/unit/providers/gemini.test.ts index 81c349e..a6b46c1 100644 --- a/tests/unit/providers/gemini.test.ts +++ b/tests/unit/providers/gemini.test.ts @@ -1,9 +1,18 @@ import { GeminiProvider } from '../../../src/infra/provider'; import { Message } from '../../../src/core/types'; +import { prepareMessagesForResume } from '../../../src/infra/providers/core/fork'; import { TestRunner, expect } from '../../helpers/utils'; const runner = new TestRunner('Provider/Gemini'); +async function collectStream(provider: GeminiProvider): Promise { + const chunks: any[] = []; + for await (const chunk of provider.stream([], { thinking: { budgetTokens: 1024 } })) { + chunks.push(chunk); + } + return chunks; +} + runner .test('baseUrl 自动补全 /v1beta', async () => { const provider = new GeminiProvider('test-key', 'gemini-3.0-flash', 'http://localhost:9999'); @@ -58,6 +67,1151 @@ runner const parameters = capturedBody.tools?.[0]?.functionDeclarations?.[0]?.parameters; expect.toBeFalsy('additionalProperties' in parameters); expect.toBeFalsy('additionalProperties' in (parameters?.properties?.value ?? {})); + }) + .test('stream emits a closed reasoning block before the answer block', async () => { + const provider = new GeminiProvider('test-key', 'gemini-2.5-pro', undefined, undefined, { + reasoningTransport: 'provider', + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + `data: ${JSON.stringify({ + candidates: [ + { + content: { + parts: [ + { text: 'thinking', thought: true }, + { text: 'answer' }, + ], + }, + }, + ], + })}\n\n`, + { status: 200, headers: { 'content-type': 'text/event-stream' } } + )) as any; + + try { + const chunks = await collectStream(provider); + expect.toDeepEqual(chunks, [ + { + type: 'content_block_start', + index: 0, + content_block: { type: 'reasoning', reasoning: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'reasoning_delta', text: 'thinking' }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'content_block_start', + index: 1, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 1, + delta: { type: 'text_delta', text: 'answer' }, + }, + { type: 'content_block_stop', index: 1 }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('stream closes a reasoning-only response', async () => { + const provider = new GeminiProvider('test-key', 'gemini-2.5-pro', undefined, undefined, { + reasoningTransport: 'provider', + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + `data: ${JSON.stringify({ + candidates: [ + { + content: { + parts: [{ text: 'thinking only', thought: true }], + }, + }, + ], + })}\n\n`, + { status: 200, headers: { 'content-type': 'text/event-stream' } } + )) as any; + + try { + const chunks = await collectStream(provider); + expect.toDeepEqual(chunks, [ + { + type: 'content_block_start', + index: 0, + content_block: { type: 'reasoning', reasoning: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'reasoning_delta', text: 'thinking only' }, + }, + { type: 'content_block_stop', index: 0 }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('stream preserves closed block order for a JSON-array response', async () => { + const provider = new GeminiProvider('test-key', 'gemini-2.5-pro', undefined, undefined, { + reasoningTransport: 'provider', + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify([ + { + candidates: [ + { + content: { + parts: [ + { text: 'thinking', thought: true }, + { text: 'answer' }, + ], + }, + }, + ], + }, + ]), + { status: 200, headers: { 'content-type': 'application/json' } } + )) as any; + + try { + const chunks = await collectStream(provider); + expect.toDeepEqual(chunks, [ + { + type: 'content_block_start', + index: 0, + content_block: { type: 'reasoning', reasoning: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'reasoning_delta', text: 'thinking' }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'content_block_start', + index: 1, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 1, + delta: { type: 'text_delta', text: 'answer' }, + }, + { type: 'content_block_stop', index: 1 }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('stream detects a JSON-array fallback with an SSE content type', async () => { + const provider = new GeminiProvider('test-key', 'gemini-2.5-pro'); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify([ + { + candidates: [{ content: { parts: [{ text: 'fallback answer' }] } }], + }, + ]), + { status: 200, headers: { 'content-type': 'text/event-stream' } } + )) as any; + + try { + const chunks = await collectStream(provider); + expect.toDeepEqual(chunks, [ + { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'fallback answer' }, + }, + { type: 'content_block_stop', index: 0 }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('stream expands a JSON array contained in one SSE data frame', async () => { + const provider = new GeminiProvider('test-key', 'gemini-2.5-pro'); + const originalFetch = globalThis.fetch; + const events = [ + { candidates: [{ content: { parts: [{ text: 'first' }] } }] }, + { candidates: [{ content: { parts: [{ text: ' second' }] } }] }, + ]; + globalThis.fetch = (async () => + new Response(`data: ${JSON.stringify(events)}\n\n`, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + })) as any; + + try { + const chunks = await collectStream(provider); + expect.toDeepEqual(chunks, [ + { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'first' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: ' second' }, + }, + { type: 'content_block_stop', index: 0 }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('stream parses CRLF SSE frames with multiline data split across network chunks', async () => { + const provider = new GeminiProvider('test-key', 'gemini-2.5-pro', undefined, undefined, { + reasoningTransport: 'provider', + }); + const event = { + candidates: [{ content: { parts: [{ text: 'thinking', thought: true }, { text: 'answer' }] } }], + }; + const frame = + JSON.stringify(event, null, 2) + .split('\n') + .map((line) => `data: ${line}\r\n`) + .join('') + '\r\n'; + const encoded = new TextEncoder().encode(frame); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(encoded.slice(0, 17)); + controller.enqueue(encoded.slice(17, 53)); + controller.enqueue(encoded.slice(53)); + controller.close(); + }, + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } })) as any; + + try { + const chunks = await collectStream(provider); + expect.toDeepEqual(chunks, [ + { + type: 'content_block_start', + index: 0, + content_block: { type: 'reasoning', reasoning: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'reasoning_delta', text: 'thinking' }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'content_block_start', + index: 1, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 1, + delta: { type: 'text_delta', text: 'answer' }, + }, + { type: 'content_block_stop', index: 1 }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('stream reports malformed SSE data instead of silently dropping it', async () => { + const provider = new GeminiProvider('test-key', 'gemini-2.5-pro'); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response('data: {"candidates":\n\n', { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + })) as any; + + try { + await expect.toThrow(async () => { + await collectStream(provider); + }, 'Gemini stream parse error in SSE frame 1'); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('stream closes an active block before reporting a later malformed SSE frame', async () => { + const provider = new GeminiProvider('test-key', 'gemini-2.5-pro'); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + [ + `data: ${JSON.stringify({ + candidates: [{ content: { parts: [{ text: 'partial thought', thought: true }] } }], + })}\n\n`, + 'data: {"candidates":\n\n', + ].join(''), + { status: 200, headers: { 'content-type': 'text/event-stream' } } + )) as any; + + try { + const chunks: any[] = []; + let thrown: any; + try { + for await (const chunk of provider.stream([], { thinking: { budgetTokens: 1024 } })) { + chunks.push(chunk); + } + } catch (error) { + thrown = error; + } + expect.toContain(String(thrown?.message), 'Gemini stream parse error in SSE frame 2'); + expect.toDeepEqual(chunks, [ + { + type: 'content_block_start', + index: 0, + content_block: { type: 'reasoning', reasoning: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'reasoning_delta', text: 'partial thought' }, + }, + { type: 'content_block_stop', index: 0 }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('complete keeps native thoughts separate from answer text in text transport', async () => { + const provider = new GeminiProvider('test-key', 'gemini-2.5-pro', undefined, undefined, { + reasoningTransport: 'text', + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + candidates: [ + { + content: { + parts: [ + { text: 'thinking', thought: true }, + { text: 'answer' }, + ], + }, + }, + ], + }), + { status: 200, headers: { 'content-type': 'application/json' } } + )) as any; + + try { + const response = await provider.complete([], { thinking: { budgetTokens: 1024 } }); + expect.toDeepEqual(response.content, [ + { type: 'reasoning', reasoning: 'thinking' }, + { type: 'text', text: 'answer' }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('complete projects canonical reasoning into provider, text, and omit history', async () => { + const messages: Message[] = [ + { + role: 'assistant', + content: [ + { + type: 'reasoning', + reasoning: 'PLAN', + meta: { thought_signature: 'native-signature' }, + }, + { type: 'text', text: 'ANSWER' }, + ], + }, + ]; + const cases: Array<{ transport: 'provider' | 'text' | 'omit'; expected: any[] }> = [ + { + transport: 'provider', + expected: [ + { text: 'PLAN', thought: true, thoughtSignature: 'native-signature' }, + { text: 'ANSWER' }, + ], + }, + { + transport: 'text', + expected: [{ text: 'PLAN' }, { text: 'ANSWER' }], + }, + { + transport: 'omit', + expected: [{ text: 'ANSWER' }], + }, + ]; + const originalFetch = globalThis.fetch; + + try { + for (const testCase of cases) { + let capturedBody: any; + globalThis.fetch = (async (_url: any, init: any) => { + capturedBody = JSON.parse(init.body); + return new Response( + JSON.stringify({ candidates: [{ content: { parts: [{ text: 'done' }] } }] }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + }) as any; + const provider = new GeminiProvider('test-key', 'gemini-2.5-pro', undefined, undefined, { + reasoningTransport: testCase.transport, + }); + await provider.complete(messages); + expect.toDeepEqual(capturedBody.contents[0].parts, testCase.expected); + } + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('stream keeps native thoughts separate from answer text in text transport', async () => { + const provider = new GeminiProvider('test-key', 'gemini-2.5-pro', undefined, undefined, { + reasoningTransport: 'text', + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + `data: ${JSON.stringify({ + candidates: [ + { + content: { + parts: [ + { text: 'thinking', thought: true }, + { text: 'answer' }, + ], + }, + }, + ], + })}\n\n`, + { status: 200, headers: { 'content-type': 'text/event-stream' } } + )) as any; + + try { + const chunks = await collectStream(provider); + expect.toDeepEqual(chunks, [ + { + type: 'content_block_start', + index: 0, + content_block: { type: 'reasoning', reasoning: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'reasoning_delta', text: 'thinking' }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'content_block_start', + index: 1, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 1, + delta: { type: 'text_delta', text: 'answer' }, + }, + { type: 'content_block_stop', index: 1 }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('stream preserves the original order of mixed Gemini parts', async () => { + const provider = new GeminiProvider('test-key', 'gemini-2.5-pro', undefined, undefined, { + reasoningTransport: 'provider', + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + `data: ${JSON.stringify({ + candidates: [ + { + content: { + parts: [ + { text: 'thought A', thought: true }, + { text: 'answer B' }, + { text: 'thought C', thought: true }, + ], + }, + }, + ], + })}\n\n`, + { status: 200, headers: { 'content-type': 'text/event-stream' } } + )) as any; + + try { + const chunks = await collectStream(provider); + expect.toDeepEqual(chunks, [ + { + type: 'content_block_start', + index: 0, + content_block: { type: 'reasoning', reasoning: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'reasoning_delta', text: 'thought A' }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'content_block_start', + index: 1, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 1, + delta: { type: 'text_delta', text: 'answer B' }, + }, + { type: 'content_block_stop', index: 1 }, + { + type: 'content_block_start', + index: 2, + content_block: { type: 'reasoning', reasoning: '' }, + }, + { + type: 'content_block_delta', + index: 2, + delta: { type: 'reasoning_delta', text: 'thought C' }, + }, + { type: 'content_block_stop', index: 2 }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('stream keeps cross-event order and Gemini function call identity', async () => { + const provider = new GeminiProvider('test-key', 'gemini-3-pro', undefined, undefined, { + reasoningTransport: 'provider', + }); + const originalFetch = globalThis.fetch; + const events = [ + { candidates: [{ content: { parts: [{ text: 'thought A', thought: true }] } }] }, + { candidates: [{ content: { parts: [{ text: 'thought B', thought: true }] } }] }, + { candidates: [{ content: { parts: [{ text: 'answer C' }] } }] }, + { candidates: [{ content: { parts: [{ text: 'thought D', thought: true }] } }] }, + { + candidates: [ + { + content: { + parts: [ + { + functionCall: { id: 'call-E', name: 'tool_e', args: { value: 1 } }, + thoughtSignature: 'signature-E', + }, + ], + }, + }, + ], + }, + ]; + globalThis.fetch = (async () => + new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(''), { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + })) as any; + + try { + const chunks = await collectStream(provider); + expect.toDeepEqual(chunks, [ + { + type: 'content_block_start', + index: 0, + content_block: { type: 'reasoning', reasoning: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'reasoning_delta', text: 'thought A' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'reasoning_delta', text: 'thought B' }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'content_block_start', + index: 1, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 1, + delta: { type: 'text_delta', text: 'answer C' }, + }, + { type: 'content_block_stop', index: 1 }, + { + type: 'content_block_start', + index: 2, + content_block: { type: 'reasoning', reasoning: '' }, + }, + { + type: 'content_block_delta', + index: 2, + delta: { type: 'reasoning_delta', text: 'thought D' }, + }, + { type: 'content_block_stop', index: 2 }, + { + type: 'content_block_start', + index: 3, + content_block: { + type: 'tool_use', + id: 'call-E', + name: 'tool_e', + input: {}, + meta: { thought_signature: 'signature-E' }, + }, + }, + { + type: 'content_block_delta', + index: 3, + delta: { type: 'input_json_delta', partial_json: '{"value":1}' }, + }, + { type: 'content_block_stop', index: 3 }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('complete preserves Gemini function call IDs and signatures', async () => { + const provider = new GeminiProvider('test-key', 'gemini-3-pro', undefined, undefined, { + reasoningTransport: 'provider', + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + candidates: [ + { + content: { + parts: [ + { + functionCall: { id: 'call-paris', name: 'weather', args: { city: 'Paris' } }, + thoughtSignature: 'signature-paris', + }, + { + functionCall: { id: 'call-london', name: 'weather', args: { city: 'London' } }, + }, + ], + }, + }, + ], + }), + { status: 200, headers: { 'content-type': 'application/json' } } + )) as any; + + try { + const response = await provider.complete([]); + expect.toDeepEqual(response.content, [ + { + type: 'tool_use', + id: 'call-paris', + name: 'weather', + input: { city: 'Paris' }, + meta: { thought_signature: 'signature-paris' }, + }, + { + type: 'tool_use', + id: 'call-london', + name: 'weather', + input: { city: 'London' }, + }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('complete returns Gemini function call and response IDs in the next request', async () => { + const provider = new GeminiProvider('test-key', 'gemini-3-pro', undefined, undefined, { + reasoningTransport: 'provider', + }); + const messages: Message[] = [ + { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'call-paris', + name: 'weather', + input: { city: 'Paris' }, + meta: { thought_signature: 'signature-paris' }, + }, + { + type: 'tool_use', + id: 'call-london', + name: 'weather', + input: { city: 'London' }, + }, + ], + }, + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'call-paris', content: { temperature: 18 } }, + { type: 'tool_result', tool_use_id: 'call-london', content: { temperature: 14 } }, + ], + }, + ]; + const originalFetch = globalThis.fetch; + let capturedBody: any; + globalThis.fetch = (async (_url: any, init: any) => { + capturedBody = JSON.parse(init.body); + return new Response( + JSON.stringify({ candidates: [{ content: { parts: [{ text: 'done' }] } }] }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + }) as any; + + try { + await provider.complete(messages); + expect.toDeepEqual(capturedBody.contents, [ + { + role: 'model', + parts: [ + { + functionCall: { id: 'call-paris', name: 'weather', args: { city: 'Paris' } }, + thoughtSignature: 'signature-paris', + }, + { + functionCall: { id: 'call-london', name: 'weather', args: { city: 'London' } }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call-paris', + name: 'weather', + response: { content: '{"temperature":18}' }, + }, + }, + { + functionResponse: { + id: 'call-london', + name: 'weather', + response: { content: '{"temperature":14}' }, + }, + }, + ], + }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('complete preserves zero, dynamic, and level thinking configuration', async () => { + const provider = new GeminiProvider('test-key', 'gemini-thinking'); + const originalFetch = globalThis.fetch; + const capturedConfigs: any[] = []; + globalThis.fetch = (async (_url: any, init: any) => { + capturedConfigs.push(JSON.parse(init.body).generationConfig?.thinkingConfig); + return new Response( + JSON.stringify({ candidates: [{ content: { parts: [{ text: 'done' }] } }] }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + }) as any; + + try { + await provider.complete([], { thinking: { budgetTokens: 0 } }); + await provider.complete([], { thinking: { budgetTokens: -1 } }); + await provider.complete([], { thinking: { level: 'high' } }); + expect.toDeepEqual(capturedConfigs, [ + { thinkingBudget: 0, includeThoughts: true }, + { thinkingBudget: -1, includeThoughts: true }, + { thinkingLevel: 'HIGH', includeThoughts: true }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('complete preserves signatures on thought, text, and empty text parts', async () => { + const provider = new GeminiProvider('test-key', 'gemini-3-pro', undefined, undefined, { + reasoningTransport: 'provider', + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + candidates: [ + { + content: { + parts: [ + { text: 'PLAN', thought: true, thoughtSignature: 'signature-thought' }, + { text: 'ANSWER', thoughtSignature: 'signature-answer' }, + { text: '', thoughtSignature: 'signature-empty' }, + ], + }, + }, + ], + }), + { status: 200, headers: { 'content-type': 'application/json' } } + )) as any; + const expectedContent: Message['content'] = [ + { + type: 'reasoning', + reasoning: 'PLAN', + meta: { thought_signature: 'signature-thought' }, + }, + { + type: 'text', + text: 'ANSWER', + meta: { thought_signature: 'signature-answer' }, + }, + { + type: 'text', + text: '', + meta: { thought_signature: 'signature-empty' }, + }, + ]; + + try { + const response = await provider.complete([]); + expect.toDeepEqual(response.content, expectedContent); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('complete returns signed thought, text, and empty text parts in provider history', async () => { + const provider = new GeminiProvider('test-key', 'gemini-3-pro', undefined, undefined, { + reasoningTransport: 'provider', + }); + const messages: Message[] = [ + { + role: 'assistant', + content: [ + { + type: 'reasoning', + reasoning: 'PLAN', + meta: { thought_signature: 'signature-thought' }, + }, + { + type: 'text', + text: 'ANSWER', + meta: { thought_signature: 'signature-answer' }, + }, + { + type: 'text', + text: '', + meta: { thought_signature: 'signature-empty' }, + }, + ], + }, + ]; + const originalFetch = globalThis.fetch; + let capturedBody: any; + globalThis.fetch = (async (_url: any, init: any) => { + capturedBody = JSON.parse(init.body); + return new Response( + JSON.stringify({ candidates: [{ content: { parts: [{ text: 'done' }] } }] }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + }) as any; + + try { + await provider.complete(messages); + expect.toDeepEqual(capturedBody.contents, [ + { + role: 'model', + parts: [ + { + text: 'PLAN', + thought: true, + thoughtSignature: 'signature-thought', + }, + { + text: 'ANSWER', + thoughtSignature: 'signature-answer', + }, + { + text: '', + thoughtSignature: 'signature-empty', + }, + ], + }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('stream preserves signed thought, text, and empty text as atomic blocks', async () => { + const provider = new GeminiProvider('test-key', 'gemini-3-pro', undefined, undefined, { + reasoningTransport: 'provider', + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + `data: ${JSON.stringify({ + candidates: [ + { + content: { + parts: [ + { text: 'PLAN', thought: true, thoughtSignature: 'signature-thought' }, + { text: 'ANSWER', thoughtSignature: 'signature-answer' }, + { text: '', thoughtSignature: 'signature-empty' }, + ], + }, + }, + ], + })}\n\n`, + { status: 200, headers: { 'content-type': 'text/event-stream' } } + )) as any; + + try { + const chunks = await collectStream(provider); + expect.toDeepEqual(chunks, [ + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'reasoning', + reasoning: '', + meta: { thought_signature: 'signature-thought' }, + }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'reasoning_delta', text: 'PLAN' }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'content_block_start', + index: 1, + content_block: { + type: 'text', + text: '', + meta: { thought_signature: 'signature-answer' }, + }, + }, + { + type: 'content_block_delta', + index: 1, + delta: { type: 'text_delta', text: 'ANSWER' }, + }, + { type: 'content_block_stop', index: 1 }, + { + type: 'content_block_start', + index: 2, + content_block: { + type: 'text', + text: '', + meta: { thought_signature: 'signature-empty' }, + }, + }, + { type: 'content_block_stop', index: 2 }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('Gemini resume preserves reasoning blocks with normalized signatures', async () => { + const messages: Message[] = [ + { + role: 'assistant', + content: [ + { + type: 'reasoning', + reasoning: 'signed', + meta: { thought_signature: 'signature-thought' }, + }, + { type: 'reasoning', reasoning: 'unsigned' }, + { + type: 'text', + text: 'answer', + meta: { thought_signature: 'signature-answer' }, + }, + ], + }, + ]; + + expect.toDeepEqual(prepareMessagesForResume(messages, 'gemini'), [ + { + role: 'assistant', + content: [ + { + type: 'reasoning', + reasoning: 'signed', + meta: { thought_signature: 'signature-thought' }, + }, + { + type: 'text', + text: 'answer', + meta: { thought_signature: 'signature-answer' }, + }, + ], + }, + ]); + }) + .test('stream preserves the existing behavior of emitting all Gemini candidates', async () => { + const provider = new GeminiProvider('test-key', 'gemini-2.5-pro'); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + `data: ${JSON.stringify({ + candidates: [ + { content: { parts: [{ text: 'first candidate' }] } }, + { content: { parts: [{ text: 'second candidate' }] } }, + ], + })}\n\n`, + { status: 200, headers: { 'content-type': 'text/event-stream' } } + )) as any; + + try { + const chunks = await collectStream(provider); + expect.toDeepEqual(chunks, [ + { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'first candidate' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'second candidate' }, + }, + { type: 'content_block_stop', index: 0 }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('stream assigns index zero to a tool-only response', async () => { + const provider = new GeminiProvider('test-key', 'gemini-2.5-pro'); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + `data: ${JSON.stringify({ + candidates: [ + { content: { parts: [{ functionCall: { id: 'call-1', name: 'lookup', args: {} } }] } }, + ], + })}\n\n`, + { status: 200, headers: { 'content-type': 'text/event-stream' } } + )) as any; + + try { + const chunks = await collectStream(provider); + const starts = chunks.filter((chunk) => chunk.type === 'content_block_start'); + expect.toHaveLength(starts, 1); + expect.toEqual(starts[0].index, 0); + expect.toEqual(starts[0].content_block.type, 'tool_use'); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('stream marks a synthetic function call ID so it is not returned to Gemini', async () => { + const provider = new GeminiProvider('test-key', 'gemini-2.5-pro', undefined, undefined, { + reasoningTransport: 'provider', + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + `data: ${JSON.stringify({ + candidates: [ + { + content: { + parts: [{ functionCall: { name: 'lookup', args: { query: 'x' } } }], + }, + }, + ], + })}\n\n`, + { status: 200, headers: { 'content-type': 'text/event-stream' } } + )) as any; + + try { + const chunks = await collectStream(provider); + const start = chunks.find((chunk) => chunk.type === 'content_block_start'); + expect.toBeTruthy(start?.content_block?.id); + expect.toDeepEqual(start?.content_block?.meta, { + gemini_function_call_id_present: false, + }); + } finally { + globalThis.fetch = originalFetch; + } + }) + .test('complete does not return a synthetic function call ID to Gemini', async () => { + const provider = new GeminiProvider('test-key', 'gemini-2.5-pro', undefined, undefined, { + reasoningTransport: 'provider', + }); + const originalFetch = globalThis.fetch; + let requestCount = 0; + let secondBody: any; + globalThis.fetch = (async (_url: any, init: any) => { + requestCount += 1; + if (requestCount === 1) { + return new Response( + JSON.stringify({ + candidates: [ + { + content: { + parts: [ + { functionCall: { name: 'lookup', args: { query: 'x' } } }, + ], + }, + }, + ], + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + } + secondBody = JSON.parse(init.body); + return new Response( + JSON.stringify({ candidates: [{ content: { parts: [{ text: 'done' }] } }] }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + }) as any; + + try { + const first = await provider.complete([]); + const toolUse = first.content.find( + (block): block is Extract<(typeof first.content)[number], { type: 'tool_use' }> => + block.type === 'tool_use' + ); + expect.toBeTruthy(toolUse); + await provider.complete([ + { role: 'assistant', content: first.content }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: toolUse!.id, + content: { result: 'ok' }, + }, + ], + }, + ]); + + const modelCall = secondBody.contents[0].parts[0].functionCall; + const functionResponse = secondBody.contents[1].parts[0].functionResponse; + expect.toBeFalsy('id' in modelCall); + expect.toBeFalsy('id' in functionResponse); + } finally { + globalThis.fetch = originalFetch; + } }); export async function run() {