mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
feat(glmt): fix sequential tool_use block handling
This commit is contained in:
1 parent
baa58c9543
commit
a0f91761ed
5 files changed
+173
-77
No files matched your search
@@ -40,6 +40,7 @@ interface ToolCall {
|
|||||||
name: string;
|
name: string;
|
||||||
arguments: string;
|
arguments: string;
|
||||||
};
|
};
|
||||||
|
blockIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToolCallDelta {
|
interface ToolCallDelta {
|
||||||
@@ -240,7 +241,6 @@ export class DeltaAccumulator {
|
|||||||
addToolCallDelta(toolCallDelta: ToolCallDelta): void {
|
addToolCallDelta(toolCallDelta: ToolCallDelta): void {
|
||||||
const index = toolCallDelta.index;
|
const index = toolCallDelta.index;
|
||||||
|
|
||||||
// Initialize tool call if not exists
|
|
||||||
if (!this.toolCallsIndex[index]) {
|
if (!this.toolCallsIndex[index]) {
|
||||||
const toolCall: ToolCall = {
|
const toolCall: ToolCall = {
|
||||||
index: index,
|
index: index,
|
||||||
@@ -250,6 +250,7 @@ export class DeltaAccumulator {
|
|||||||
name: '',
|
name: '',
|
||||||
arguments: '',
|
arguments: '',
|
||||||
},
|
},
|
||||||
|
blockIndex: -1,
|
||||||
};
|
};
|
||||||
this.toolCalls.push(toolCall);
|
this.toolCalls.push(toolCall);
|
||||||
this.toolCallsIndex[index] = toolCall;
|
this.toolCallsIndex[index] = toolCall;
|
||||||
@@ -257,27 +258,39 @@ export class DeltaAccumulator {
|
|||||||
|
|
||||||
const toolCall = this.toolCallsIndex[index];
|
const toolCall = this.toolCallsIndex[index];
|
||||||
|
|
||||||
// Update id if present
|
|
||||||
if (toolCallDelta.id) {
|
if (toolCallDelta.id) {
|
||||||
toolCall.id = toolCallDelta.id;
|
toolCall.id = toolCallDelta.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update type if present
|
|
||||||
if (toolCallDelta.type) {
|
if (toolCallDelta.type) {
|
||||||
toolCall.type = toolCallDelta.type;
|
toolCall.type = toolCallDelta.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update function name if present
|
|
||||||
if (toolCallDelta.function?.name) {
|
if (toolCallDelta.function?.name) {
|
||||||
toolCall.function.name += toolCallDelta.function.name;
|
toolCall.function.name += toolCallDelta.function.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update function arguments if present
|
|
||||||
if (toolCallDelta.function?.arguments) {
|
if (toolCallDelta.function?.arguments) {
|
||||||
toolCall.function.arguments += toolCallDelta.function.arguments;
|
toolCall.function.arguments += toolCallDelta.function.arguments;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setToolCallBlockIndex(toolCallIndex: number, blockIndex: number): void {
|
||||||
|
const toolCall = this.toolCallsIndex[toolCallIndex];
|
||||||
|
if (toolCall) {
|
||||||
|
toolCall.blockIndex = blockIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getToolCallBlockIndex(toolCallIndex: number): number {
|
||||||
|
const toolCall = this.toolCallsIndex[toolCallIndex];
|
||||||
|
return toolCall?.blockIndex ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getUnstoppedBlocks(): ContentBlock[] {
|
||||||
|
return this.contentBlocks.filter((b) => !b.stopped);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all tool calls
|
* Get all tool calls
|
||||||
* @returns Tool calls array
|
* @returns Tool calls array
|
||||||
|
|||||||
@@ -209,9 +209,8 @@ export class StreamParser {
|
|||||||
|
|
||||||
if (!toolCallDeltas) return events;
|
if (!toolCallDeltas) return events;
|
||||||
|
|
||||||
// Close current content block ONCE before processing any tool calls
|
|
||||||
const currentBlock = accumulator.getCurrentBlock();
|
const currentBlock = accumulator.getCurrentBlock();
|
||||||
if (currentBlock && !currentBlock.stopped) {
|
if (currentBlock && !currentBlock.stopped && currentBlock.type !== 'tool_use') {
|
||||||
if (currentBlock.type === 'thinking') {
|
if (currentBlock.type === 'thinking') {
|
||||||
events.push(...this.closeThinkingBlock(currentBlock, accumulator));
|
events.push(...this.closeThinkingBlock(currentBlock, accumulator));
|
||||||
} else {
|
} else {
|
||||||
@@ -220,7 +219,6 @@ export class StreamParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process tool call deltas
|
|
||||||
events.push(...this.toolCallHandler.processToolCallDeltas(toolCallDeltas, accumulator));
|
events.push(...this.toolCallHandler.processToolCallDeltas(toolCallDeltas, accumulator));
|
||||||
|
|
||||||
return events;
|
return events;
|
||||||
@@ -251,44 +249,41 @@ export class StreamParser {
|
|||||||
private forceFinalization(accumulator: DeltaAccumulator): AnthropicSSEEvent[] {
|
private forceFinalization(accumulator: DeltaAccumulator): AnthropicSSEEvent[] {
|
||||||
const events: AnthropicSSEEvent[] = [];
|
const events: AnthropicSSEEvent[] = [];
|
||||||
|
|
||||||
// Close current block if any
|
const unstoppedBlocks = accumulator.getUnstoppedBlocks();
|
||||||
const currentBlock = accumulator.getCurrentBlock();
|
for (const block of unstoppedBlocks) {
|
||||||
if (currentBlock && !currentBlock.stopped) {
|
if (block.type === 'thinking') {
|
||||||
if (currentBlock.type === 'thinking') {
|
const signatureEvent = this.responseBuilder.createSignatureDeltaEvent(block);
|
||||||
events.push(...this.closeThinkingBlock(currentBlock, accumulator));
|
if (signatureEvent) {
|
||||||
} else {
|
events.push(signatureEvent);
|
||||||
events.push(this.responseBuilder.createContentBlockStopEvent(currentBlock));
|
}
|
||||||
accumulator.stopCurrentBlock();
|
|
||||||
}
|
}
|
||||||
|
events.push(this.responseBuilder.createContentBlockStopEvent(block));
|
||||||
|
block.stopped = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force finalization
|
|
||||||
events.push(...this.finalizeDelta(accumulator));
|
events.push(...this.finalizeDelta(accumulator));
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Finalize streaming and generate closing events
|
|
||||||
*/
|
|
||||||
finalizeDelta(accumulator: DeltaAccumulator): AnthropicSSEEvent[] {
|
finalizeDelta(accumulator: DeltaAccumulator): AnthropicSSEEvent[] {
|
||||||
if (accumulator.isFinalized()) {
|
if (accumulator.isFinalized()) {
|
||||||
return []; // Already finalized
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const events: AnthropicSSEEvent[] = [];
|
const events: AnthropicSSEEvent[] = [];
|
||||||
|
|
||||||
// Close current content block if any
|
const unstoppedBlocks = accumulator.getUnstoppedBlocks();
|
||||||
const currentBlock = accumulator.getCurrentBlock();
|
for (const block of unstoppedBlocks) {
|
||||||
if (currentBlock && !currentBlock.stopped) {
|
if (block.type === 'thinking') {
|
||||||
if (currentBlock.type === 'thinking') {
|
const signatureEvent = this.responseBuilder.createSignatureDeltaEvent(block);
|
||||||
events.push(...this.closeThinkingBlock(currentBlock, accumulator));
|
if (signatureEvent) {
|
||||||
} else {
|
events.push(signatureEvent);
|
||||||
events.push(this.responseBuilder.createContentBlockStopEvent(currentBlock));
|
}
|
||||||
accumulator.stopCurrentBlock();
|
|
||||||
}
|
}
|
||||||
|
events.push(this.responseBuilder.createContentBlockStopEvent(block));
|
||||||
|
block.stopped = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message delta (stop reason + usage) and message stop
|
|
||||||
const stopReason = this.responseBuilder.mapStopReason(accumulator.getFinishReason() || 'stop');
|
const stopReason = this.responseBuilder.mapStopReason(accumulator.getFinishReason() || 'stop');
|
||||||
events.push(...this.responseBuilder.createFinalizationEvents(accumulator, stopReason));
|
events.push(...this.responseBuilder.createFinalizationEvents(accumulator, stopReason));
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,20 @@
|
|||||||
* - Process tool call deltas from OpenAI
|
* - Process tool call deltas from OpenAI
|
||||||
* - Generate tool_use content blocks for Anthropic format
|
* - Generate tool_use content blocks for Anthropic format
|
||||||
* - Handle input_json_delta events
|
* - Handle input_json_delta events
|
||||||
|
* - Close previous tool_use blocks before starting new ones (Anthropic sequential block requirement)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { DeltaAccumulator } from '../delta-accumulator';
|
import type { DeltaAccumulator } from '../delta-accumulator';
|
||||||
import type { OpenAIToolCallDelta, OpenAIToolCall, ContentBlock, AnthropicSSEEvent } from './types';
|
import type { OpenAIToolCallDelta, OpenAIToolCall, ContentBlock, AnthropicSSEEvent } from './types';
|
||||||
|
import { ResponseBuilder } from './response-builder';
|
||||||
|
|
||||||
export class ToolCallHandler {
|
export class ToolCallHandler {
|
||||||
/**
|
private responseBuilder: ResponseBuilder;
|
||||||
* Process tool calls from non-streaming response
|
|
||||||
*/
|
constructor() {
|
||||||
|
this.responseBuilder = new ResponseBuilder(false);
|
||||||
|
}
|
||||||
|
|
||||||
processToolCalls(toolCalls: OpenAIToolCall[]): ContentBlock[] {
|
processToolCalls(toolCalls: OpenAIToolCall[]): ContentBlock[] {
|
||||||
const content: ContentBlock[] = [];
|
const content: ContentBlock[] = [];
|
||||||
|
|
||||||
@@ -38,10 +43,6 @@ export class ToolCallHandler {
|
|||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Process tool call deltas during streaming
|
|
||||||
* Returns events for tool use blocks and input_json deltas
|
|
||||||
*/
|
|
||||||
processToolCallDeltas(
|
processToolCallDeltas(
|
||||||
toolCallDeltas: OpenAIToolCallDelta[],
|
toolCallDeltas: OpenAIToolCallDelta[],
|
||||||
accumulator: DeltaAccumulator
|
accumulator: DeltaAccumulator
|
||||||
@@ -49,15 +50,19 @@ export class ToolCallHandler {
|
|||||||
const events: AnthropicSSEEvent[] = [];
|
const events: AnthropicSSEEvent[] = [];
|
||||||
|
|
||||||
for (const toolCallDelta of toolCallDeltas) {
|
for (const toolCallDelta of toolCallDeltas) {
|
||||||
// Track tool call state
|
|
||||||
const isNewToolCall = !accumulator.hasToolCall(toolCallDelta.index);
|
const isNewToolCall = !accumulator.hasToolCall(toolCallDelta.index);
|
||||||
accumulator.addToolCallDelta(toolCallDelta);
|
accumulator.addToolCallDelta(toolCallDelta);
|
||||||
|
|
||||||
// Emit tool use events (start + input_json deltas)
|
|
||||||
if (isNewToolCall) {
|
if (isNewToolCall) {
|
||||||
// Start new tool_use block in accumulator
|
const previousBlock = accumulator.getCurrentBlock();
|
||||||
|
if (previousBlock && previousBlock.type === 'tool_use' && !previousBlock.stopped) {
|
||||||
|
events.push(this.responseBuilder.createContentBlockStopEvent(previousBlock));
|
||||||
|
previousBlock.stopped = true;
|
||||||
|
}
|
||||||
|
|
||||||
const block = accumulator.startBlock('tool_use');
|
const block = accumulator.startBlock('tool_use');
|
||||||
const toolCall = accumulator.getToolCall(toolCallDelta.index);
|
const toolCall = accumulator.getToolCall(toolCallDelta.index);
|
||||||
|
accumulator.setToolCallBlockIndex(toolCallDelta.index, block.index);
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
event: 'content_block_start',
|
event: 'content_block_start',
|
||||||
@@ -68,27 +73,25 @@ export class ToolCallHandler {
|
|||||||
type: 'tool_use',
|
type: 'tool_use',
|
||||||
id: toolCall?.id || `tool_${toolCallDelta.index}`,
|
id: toolCall?.id || `tool_${toolCallDelta.index}`,
|
||||||
name: toolCall?.function?.name || '',
|
name: toolCall?.function?.name || '',
|
||||||
|
input: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit input_json delta if arguments present
|
|
||||||
if (toolCallDelta.function?.arguments) {
|
if (toolCallDelta.function?.arguments) {
|
||||||
const currentToolBlock = accumulator.getCurrentBlock();
|
const toolCallBlockIndex = accumulator.getToolCallBlockIndex(toolCallDelta.index);
|
||||||
if (currentToolBlock && currentToolBlock.type === 'tool_use') {
|
events.push({
|
||||||
events.push({
|
event: 'content_block_delta',
|
||||||
event: 'content_block_delta',
|
data: {
|
||||||
data: {
|
type: 'content_block_delta',
|
||||||
type: 'content_block_delta',
|
index: toolCallBlockIndex,
|
||||||
index: currentToolBlock.index,
|
delta: {
|
||||||
delta: {
|
type: 'input_json_delta',
|
||||||
type: 'input_json_delta',
|
partial_json: toolCallDelta.function.arguments,
|
||||||
partial_json: toolCallDelta.function.arguments,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -457,8 +457,7 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (role === 'user') {
|
if (role === 'user') {
|
||||||
const userParts: OpenAIContentPart[] = [];
|
const preToolResultParts: OpenAIContentPart[] = [];
|
||||||
let sawToolResult = false;
|
|
||||||
const resolvedToolUseIds = new Set<string>();
|
const resolvedToolUseIds = new Set<string>();
|
||||||
|
|
||||||
content.forEach((block, blockIndex) => {
|
content.forEach((block, blockIndex) => {
|
||||||
@@ -472,23 +471,26 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.type === 'text') {
|
if (parsed.type === 'text') {
|
||||||
if (sawToolResult) {
|
|
||||||
throw new Error(
|
|
||||||
`messages[${messageIndex}].content[${blockIndex}] text is not allowed after tool_result blocks`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const text = typeof parsed.text === 'string' ? parsed.text : '';
|
const text = typeof parsed.text === 'string' ? parsed.text : '';
|
||||||
userParts.push({ type: 'text', text });
|
if (pendingToolUseIds && pendingToolUseIds.size > 0 && !resolvedToolUseIds.size) {
|
||||||
|
preToolResultParts.push({ type: 'text', text });
|
||||||
|
} else {
|
||||||
|
translatedMessages.push({ role: 'user', content: text });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isImageBlock(parsed)) {
|
if (isImageBlock(parsed)) {
|
||||||
if (sawToolResult) {
|
if (pendingToolUseIds && pendingToolUseIds.size > 0 && !resolvedToolUseIds.size) {
|
||||||
throw new Error(
|
preToolResultParts.push(
|
||||||
`messages[${messageIndex}].content[${blockIndex}] image is not allowed after tool_result blocks`
|
toImagePart(parsed, `messages[${messageIndex}].content[${blockIndex}]`)
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
translatedMessages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: [toImagePart(parsed, `messages[${messageIndex}].content[${blockIndex}]`)],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
userParts.push(toImagePart(parsed, `messages[${messageIndex}].content[${blockIndex}]`));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,11 +500,6 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
|||||||
`messages[${messageIndex}].content[${blockIndex}] tool_result requires a preceding assistant tool_use`
|
`messages[${messageIndex}].content[${blockIndex}] tool_result requires a preceding assistant tool_use`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (userParts.length > 0) {
|
|
||||||
throw new Error(
|
|
||||||
`messages[${messageIndex}].content[${blockIndex}] tool_result blocks must come before other user content`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (typeof parsed.tool_use_id !== 'string' || parsed.tool_use_id.trim().length === 0) {
|
if (typeof parsed.tool_use_id !== 'string' || parsed.tool_use_id.trim().length === 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`messages[${messageIndex}].content[${blockIndex}].tool_use_id must be a non-empty string`
|
`messages[${messageIndex}].content[${blockIndex}].tool_use_id must be a non-empty string`
|
||||||
@@ -518,7 +515,6 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
|||||||
`messages[${messageIndex}].content[${blockIndex}].tool_use_id "${parsed.tool_use_id}" is duplicated`
|
`messages[${messageIndex}].content[${blockIndex}].tool_use_id "${parsed.tool_use_id}" is duplicated`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
sawToolResult = true;
|
|
||||||
resolvedToolUseIds.add(parsed.tool_use_id);
|
resolvedToolUseIds.add(parsed.tool_use_id);
|
||||||
translatedMessages.push({
|
translatedMessages.push({
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
@@ -543,7 +539,7 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sawToolResult) {
|
if (resolvedToolUseIds.size > 0) {
|
||||||
if (resolvedToolUseIds.size !== pendingToolUseIds?.size) {
|
if (resolvedToolUseIds.size !== pendingToolUseIds?.size) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`messages[${messageIndex}].content must provide tool_result blocks for all pending tool_use ids`
|
`messages[${messageIndex}].content must provide tool_result blocks for all pending tool_use ids`
|
||||||
@@ -551,17 +547,16 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
|||||||
}
|
}
|
||||||
pendingToolUseIds = null;
|
pendingToolUseIds = null;
|
||||||
hasPendingToolUseIds = false;
|
hasPendingToolUseIds = false;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingToolUseIds && pendingToolUseIds.size > 0) {
|
if (pendingToolUseIds && pendingToolUseIds.size > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`messages[${messageIndex}].content must start with tool_result blocks for pending tool_use ids`
|
`messages[${messageIndex}].content must include tool_result blocks for pending tool_use ids`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userParts.length > 0) {
|
if (preToolResultParts.length > 0) {
|
||||||
flushUserContent(translatedMessages, userParts);
|
flushUserContent(translatedMessages, preToolResultParts);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,4 +319,94 @@ describe('openai proxy messages endpoint', () => {
|
|||||||
expect(body.ok).toBe(true);
|
expect(body.ok).toBe(true);
|
||||||
expect(body.endpoints).toContain('/v1/messages');
|
expect(body.endpoints).toContain('/v1/messages');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('translates user messages with tool_result followed by text', async () => {
|
||||||
|
const response = await requestProxy({
|
||||||
|
model: 'hf-model',
|
||||||
|
stream: false,
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: 'search docs' },
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Let me search.' },
|
||||||
|
{ type: 'tool_use', id: 'toolu_01', name: 'search', input: { q: 'docs' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool_result', tool_use_id: 'toolu_01', content: 'found 3 docs' },
|
||||||
|
{ type: 'text', text: 'What should I do next?' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const parsedUpstream = upstreamBody as {
|
||||||
|
messages?: Array<{ role: string; content: string; tool_call_id?: string }>;
|
||||||
|
};
|
||||||
|
const roles = parsedUpstream.messages?.map((m) => m.role);
|
||||||
|
expect(roles).toEqual(['user', 'assistant', 'tool', 'user']);
|
||||||
|
const toolMsg = parsedUpstream.messages?.find((m) => m.role === 'tool');
|
||||||
|
expect(toolMsg?.tool_call_id).toBe('toolu_01');
|
||||||
|
expect(toolMsg?.content).toBe('found 3 docs');
|
||||||
|
const userAfterTool = parsedUpstream.messages?.filter((m) => m.role === 'user');
|
||||||
|
expect(userAfterTool?.[1]?.content).toBe('What should I do next?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('translates parallel tool calls with streaming', async () => {
|
||||||
|
const response = await requestProxy({
|
||||||
|
model: 'hf-model',
|
||||||
|
stream: true,
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: 'read both files' },
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool_use', id: 'toolu_01', name: 'Read', input: { file_path: 'a.ts' } },
|
||||||
|
{ type: 'tool_use', id: 'toolu_02', name: 'Read', input: { file_path: 'b.ts' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool_result', tool_use_id: 'toolu_01', content: 'content of a' },
|
||||||
|
{ type: 'tool_result', tool_use_id: 'toolu_02', content: 'content of b' },
|
||||||
|
{ type: 'text', text: 'Now compare them' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: 'Read',
|
||||||
|
description: 'Read a file',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { file_path: { type: 'string' } },
|
||||||
|
required: ['file_path'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const parsedUpstream = upstreamBody as {
|
||||||
|
messages?: Array<{
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
tool_call_id?: string;
|
||||||
|
tool_calls?: Array<{ id: string; function: { name: string; arguments: string } }>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
const assistantMsg = parsedUpstream.messages?.find((m) => m.role === 'assistant');
|
||||||
|
expect(assistantMsg?.tool_calls?.length).toBe(2);
|
||||||
|
expect(assistantMsg?.tool_calls?.[0]?.function.name).toBe('Read');
|
||||||
|
expect(assistantMsg?.tool_calls?.[1]?.function.name).toBe('Read');
|
||||||
|
const toolMsgs = parsedUpstream.messages?.filter((m) => m.role === 'tool');
|
||||||
|
expect(toolMsgs?.length).toBe(2);
|
||||||
|
expect(toolMsgs?.[0]?.tool_call_id).toBe('toolu_01');
|
||||||
|
expect(toolMsgs?.[1]?.tool_call_id).toBe('toolu_02');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user