diff --git a/cmd/odek/browser_tool_test.go b/cmd/odek/browser_tool_test.go index 5a056f4..9f235d4 100644 --- a/cmd/odek/browser_tool_test.go +++ b/cmd/odek/browser_tool_test.go @@ -14,9 +14,15 @@ import ( // newTestBrowserTool returns a browserTool configured with non_interactive=allow // so unit tests that hit local httptest servers are not blocked by the default // deny policy. +// newTestBrowserTool permits network egress without prompting, so the tool's +// gating doesn't block the hermetic test on an interactive TTY approval. +// Loopback httptest URLs classify as system_write (internal IP), so that +// class must be allowed too. func newTestBrowserTool() *browserTool { - allow := "allow" - return newBrowserTool(danger.DangerousConfig{NonInteractive: &allow}) + return newBrowserTool(danger.DangerousConfig{Classes: map[danger.RiskClass]danger.Action{ + danger.NetworkEgress: danger.Allow, + danger.SystemWrite: danger.Allow, + }}) } // ── Browser Navigate ────────────────────────────────────────────────── diff --git a/cmd/odek/serve.go b/cmd/odek/serve.go index 435b760..1e1e0f4 100644 --- a/cmd/odek/serve.go +++ b/cmd/odek/serve.go @@ -672,6 +672,16 @@ func newServeAgent(resolved config.ResolvedConfig, system string, sendFn func(v "content": info.ReasoningContent, }) } + // Stream per-iteration token usage so clients can refresh their + // context gauge live during a run instead of waiting for "done" + // (which only fires once, after the whole agent loop). + if info.InputTokens > 0 { + sendFn(map[string]any{ + "type": "usage", + "contextTokens": info.InputTokens, + "outputTokens": info.OutputTokens, + }) + } }, }) if err != nil { diff --git a/cmd/odek/serve_test.go b/cmd/odek/serve_test.go index b1a3a7b..e526a2e 100644 --- a/cmd/odek/serve_test.go +++ b/cmd/odek/serve_test.go @@ -1452,6 +1452,87 @@ sessionCheck: t.Log("✅ Token stats verified: turn-level + session-level accumulation") } +// TestServe_E2E_UsageEvents verifies that per-iteration "usage" events stream +// live during a run (before "done"), so clients can refresh their context +// gauge per LLM turn instead of only at the end of the whole agent loop. +func TestServe_E2E_UsageEvents(t *testing.T) { + // Mock LLM: one tool call, then final answer — usage in every response. + llmSrv := mockLLM(t, func(w http.ResponseWriter, callCount int) { + w.Header().Set("Content-Type", "application/json") + if callCount == 1 { + fmt.Fprint(w, `{"choices":[{"message":{"content":"Running.","tool_calls":[{"id":"c_1","function":{"name":"shell","arguments":"{\"command\":\"echo ok\"}"}}]}}],"usage":{"prompt_tokens":100,"completion_tokens":20}}`) + } else { + fmt.Fprint(w, `{"choices":[{"message":{"content":"All done."}}],"usage":{"prompt_tokens":200,"completion_tokens":40}}`) + } + }) + defer llmSrv.Close() + + envCleanup := setTestEnv(t, llmSrv.URL) + defer envCleanup() + + store := newTestSessionStore(t) + ln, mux := buildServeMux(t, store) + defer ln.Close() + + errCh := make(chan error, 1) + go func() { errCh <- serveOnListener(ln, mux) }() + waitForHTTP(t, ln.Addr().String()) + + conn := dialTestWS(t, ln.Addr().String()) + defer conn.Close() + + prompt := map[string]string{"type": "prompt", "content": "run a command"} + payload, _ := json.Marshal(prompt) + if err := golangws.Message.Send(conn, string(payload)); err != nil { + t.Fatalf("Send: %v", err) + } + + conn.SetReadDeadline(time.Now().Add(15 * time.Second)) + var usages []map[string]any + for i := 0; i < 20; i++ { + var raw []byte + if err := golangws.Message.Receive(conn, &raw); err != nil { + t.Fatalf("Receive event %d: %v", i, err) + } + t.Logf(" event[%d]: %s", i, string(raw)) + + var evt map[string]any + if err := json.Unmarshal(raw, &evt); err != nil { + t.Fatalf("unmarshal event %d: %v", i, err) + } + switch evt["type"] { + case "usage": + usages = append(usages, evt) + case "done": + goto usageCheck + case "error": + t.Fatalf("unexpected error: %v", evt["message"]) + } + } + t.Fatal("did not receive done event") + +usageCheck: + if len(usages) < 2 { + t.Fatalf("got %d usage events before done, want at least 2 (one per LLM turn)", len(usages)) + } + // First iteration: 100 in / 20 out. + if got := usages[0]["contextTokens"]; got != float64(100) { + t.Errorf("usage[0].contextTokens = %v, want 100", got) + } + if got := usages[0]["outputTokens"]; got != float64(20) { + t.Errorf("usage[0].outputTokens = %v, want 20", got) + } + // Final iteration: cumulative 300 in / 60 out. + last := usages[len(usages)-1] + if got := last["contextTokens"]; got != float64(300) { + t.Errorf("usage[last].contextTokens = %v, want 300", got) + } + if got := last["outputTokens"]; got != float64(60) { + t.Errorf("usage[last].outputTokens = %v, want 60", got) + } + t.Log("✅ Per-iteration usage events stream live before done") +} + // TestServe_E2E_LiveToolEvents verifies that tool_call and tool_result // events stream LIVE via ToolEventHandler (before the done event). // This confirms the live streaming pipeline works. diff --git a/docs/WEBUI.md b/docs/WEBUI.md index 2b08879..3b0ed95 100644 --- a/docs/WEBUI.md +++ b/docs/WEBUI.md @@ -125,6 +125,7 @@ The UI communicates entirely over a single WebSocket at `/ws`. Messages are newl | `tool_call` | Agent invokes a tool | `name`, `data` (raw tool-arguments JSON) | | `tool_result` | Tool returns output | `name`, `data` (full, untruncated output) | | `subagent_log` | Sub-agent progress within `delegate_tasks` | `task_idx`, `name`, `event`, `data` | +| `usage` | After each LLM turn within a run | `contextTokens`, `outputTokens` (cumulative for the run) | | `done` | Agent finishes | `latency` (seconds), `contextTokens`, `outputTokens`, `cacheCreationTokens`, `cacheReadTokens`, `cachedTokens`, `sessionContextTokens`, `sessionOutputTokens` | | `error` | Agent or server error | `message` | | `approval_request` | Agent needs user approval for dangerous operation | `id`, `risk` (class name), `command` (or resource), `description`, `is_operation`, `allow_trust`, `friction`, `friction_approvals` | diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 7c67e54..30e896c 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -11,6 +11,20 @@ import ( func boolPtr(b bool) *bool { return &b } +// TestMain isolates the suite from the developer's shell: ODEK_* variables +// (and legacy provider keys) leak into LoadConfig through os.Getenv and break +// tests that expect zero-valued defaults. Tests that exercise env vars set +// their own via t.Setenv, which still works after this scrub. +func TestMain(m *testing.M) { + for _, env := range os.Environ() { + key, _, _ := strings.Cut(env, "=") + if strings.HasPrefix(key, "ODEK_") || key == "DEEPSEEK_API_KEY" || key == "OPENAI_API_KEY" { + os.Unsetenv(key) + } + } + os.Exit(m.Run()) +} + func TestLoadConfig_Defaults(t *testing.T) { // No files, no env, no CLI — everything should be zero-valued t.Setenv("HOME", t.TempDir())