Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions cmd/odek/browser_tool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────
Expand Down
10 changes: 10 additions & 0 deletions cmd/odek/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
81 changes: 81 additions & 0 deletions cmd/odek/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/WEBUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
14 changes: 14 additions & 0 deletions internal/config/loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading