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