| 1028 | } |
| 1029 | |
| 1030 | func TestToolCallRoundTripExecutesBash(t *testing.T) { |
| 1031 | // Turn 1: bash tool call. Turn 2: plain content, no tool call. A turn ends |
| 1032 | // when the assistant stops emitting tool calls (handleStreamClosed → |
| 1033 | // finalizeTurn → endTurn). |
| 1034 | turn := 0 |
| 1035 | m := newTestModel(t, func(w http.ResponseWriter, _ *http.Request) { |
| 1036 | w.Header().Set("Content-Type", "text/event-stream") |
| 1037 | turn++ |
| 1038 | switch turn { |
| 1039 | case 1: |
| 1040 | fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"bash","arguments":"{\"cmd\":\"echo HAMMER\"}"}}]}}]}`) |
| 1041 | fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"completion_tokens":5}}`) |
| 1042 | fmt.Fprint(w, "data: [DONE]\n\n") |
| 1043 | default: |
| 1044 | fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"echoed HAMMER for you"}}]}`) |
| 1045 | fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"completion_tokens":1}}`) |
| 1046 | fmt.Fprint(w, "data: [DONE]\n\n") |
| 1047 | } |
| 1048 | }) |
| 1049 | mm, cmd := m.submit("run echo", "run echo", promptEntry{display: "run echo"}) |
| 1050 | out, _ := drain(mm, cmd) |
| 1051 | final := out.(Model) |
| 1052 | |
| 1053 | if turn != 2 { |
| 1054 | t.Fatalf("expected 2 LLM turns, got %d", turn) |
| 1055 | } |
| 1056 | // history: user, assistant(bash call), tool(bash result), assistant(content) |
| 1057 | if len(final.history) != 4 { |
| 1058 | t.Fatalf("history wrong: %d messages", len(final.history)) |
| 1059 | } |
| 1060 | if final.history[2].Role != "tool" || !strings.Contains(final.history[2].Content, "HAMMER") { |
| 1061 | t.Fatalf("tool result missing: %+v", final.history[2]) |
| 1062 | } |
| 1063 | if !strings.Contains(stripANSI(final.scroll.String()), "echoed HAMMER for you") { |
| 1064 | t.Fatalf("final assistant content missing from scroll: %q", final.scroll.String()) |
| 1065 | } |
| 1066 | // No tool calls → idle, control back to the user. |
| 1067 | if final.phase.active() { |
| 1068 | t.Fatalf("turn ending with no tool calls must return to idle, phase=%v", final.phase) |
| 1069 | } |
| 1070 | // The frozen run summary must sum tokens across both LLM rounds, not |
| 1071 | // overwrite. Round 1 reports usage.completion_tokens=5, round 2 reports 1. |
| 1072 | // finalizeTurn freezes turnTokens into lastTokens (the avg-rate divisor). Sum = 6. |
| 1073 | if final.lastTokens != 6 { |
| 1074 | t.Fatalf("per-turn tokens should sum across rounds (5+1), got %d", final.lastTokens) |
| 1075 | } |
| 1076 | // A clean finish (no tool calls) freezes the ✓ outcome for the idle footer. |
| 1077 | if final.lastOutcome != outcomeDone { |
| 1078 | t.Fatalf("clean finish should freeze outcomeDone, got %v", final.lastOutcome) |
| 1079 | } |
| 1080 | } |
| 1081 | |
| 1082 | // TestToolArgsStreamBumpsEstimateAndPhase: a tool-call argument fragment (a file |
| 1083 | // streaming into write_file) ticks the live token estimate AND flips the phase |