package loop import ( "context" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // nativeDatetimeCall is a provider-native tool call response: the adapters put // the call in Response.ToolCalls (not in Content), which is exactly the shape a // thinking model returns for "what time is it" when the datetime tool is // advertised. Content is empty, so a loop that ignores ToolCalls produces a // silent empty reply. func nativeDatetimeCall() model.Response { return model.Response{ Model: "m", Content: "", ToolCalls: []model.ToolCall{{ ID: "call_native_1", Name: "datetime", Args: map[string]any{"timezone": "Asia/Shanghai"}, }}, } } // assertNativeFeedBack checks that req carries the assistant tool_use turn and a // correlated tool result for toolCallID. func assertNativeFeedBack(t *testing.T, req model.Request, toolCallID string) { t.Helper() for i := 0; i < len(req.Messages)-1; i++ { m := req.Messages[i] if m.Role != model.RoleAssistant || len(m.Content) == 0 { continue } if m.Content[0].Type != model.ContentTypeToolCall || m.Content[0].ToolCallID != toolCallID { continue } next := req.Messages[i+1] if next.Role != model.RoleTool { t.Fatalf("message after assistant tool_use has role %q, want tool", next.Role) } if next.ToolCallID != toolCallID { t.Fatalf("tool result ToolCallID = %q, want %q", next.ToolCallID, toolCallID) } if len(next.Content) == 0 || next.Content[0].Type != model.ContentTypeToolResult { t.Fatalf("tool result content = %+v, want a tool_result part", next.Content) } return } t.Fatalf("no native assistant tool_use + tool_result pair for %q in %+v", toolCallID, req.Messages) } // TestRun_NativeToolCall_Streaming covers the real chat path (the loop drives // ChatStream when OnStream is set): a native provider tool call with empty // Content must be executed and fed back as native tool_use + tool_result, and // the run must finish with the model's follow-up text instead of an empty reply. func TestRun_NativeToolCall_Streaming(t *testing.T) { t.Parallel() ad := &mockAdapter{stream: []mgResp{ {resp: nativeDatetimeCall()}, {resp: model.Response{Model: "m", Content: "现在是 10:34。"}}, }} td := dispatcherWith(t, &mockTool{name: "datetime", result: map[string]any{"now": "2026-09-08 22:34:00"}}) a := newTestAgent(t, ad, td) l := New(Options{Agent: a, MaxRounds: 8, OnStream: func(model.StreamEvent) {}}) res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "瞅瞅当前系统时间是多少")}) if err != nil { t.Fatalf("Run: %v", err) } if res.Text != "现在是 10:34。" { t.Fatalf("Text = %q, want the follow-up answer (empty means the native call was dropped)", res.Text) } if res.Steps != 2 { t.Fatalf("Steps = %d, want 2", res.Steps) } if res.ToolCallsExecuted != 1 { t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted) } if len(ad.streamReqs) < 2 { t.Fatalf("stream requests = %d, want >= 2", len(ad.streamReqs)) } assertNativeFeedBack(t, ad.streamReqs[1], "call_native_1") } // TestRun_NativeToolCall_NonStreaming covers the muted/internal path where the // loop delegates to agent.RunStep (adapter.Chat), which must decode the same // native tool call. func TestRun_NativeToolCall_NonStreaming(t *testing.T) { t.Parallel() ad := &mockAdapter{chat: []mgResp{ {resp: nativeDatetimeCall()}, {resp: model.Response{Model: "m", Content: "现在是 10:34。"}}, }} td := dispatcherWith(t, &mockTool{name: "datetime", result: map[string]any{"now": "2026-09-08 22:34:00"}}) a := newTestAgent(t, ad, td) l := New(Options{Agent: a, MaxRounds: 8}) res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "几点了")}) if err != nil { t.Fatalf("Run: %v", err) } if res.Text != "现在是 10:34。" || res.ToolCallsExecuted != 1 { t.Fatalf("Text = %q, ToolCallsExecuted = %d; want follow-up text + 1 call", res.Text, res.ToolCallsExecuted) } if len(ad.reqs) < 2 { t.Fatalf("chat requests = %d, want >= 2", len(ad.reqs)) } assertNativeFeedBack(t, ad.reqs[1], "call_native_1") } // TestExtractToolCalls_PrefersNative pins the decoder contract: provider-native // ToolCalls win over a Content envelope and preserve the provider id, so the // feed-back can correlate the result. func TestExtractToolCalls_PrefersNative(t *testing.T) { t.Parallel() resp := nativeDatetimeCall() resp.Content = `{"tool_calls":[{"name":"legacy","args":{}}]}` calls, ok := agent.ExtractToolCalls(resp) if !ok || len(calls) != 1 { t.Fatalf("ExtractToolCalls = (%+v, %v), want 1 native call", calls, ok) } if calls[0].ID != "call_native_1" || calls[0].Name != "datetime" { t.Fatalf("native call = %+v, want id=call_native_1 name=datetime", calls[0]) } if calls[0].Args["timezone"] != "Asia/Shanghai" { t.Fatalf("native args = %+v, want timezone=Asia/Shanghai", calls[0].Args) } }