package agent import ( "encoding/json" "testing" ) func TestNewReturnsHermesBackend(t *testing.T) { t.Parallel() b, err := New("hermes", Config{ExecutablePath: "/nonexistent/hermes"}) if err != nil { t.Fatalf("New(hermes) error: %v", err) } if _, ok := b.(*hermesBackend); !ok { t.Fatalf("expected *hermesBackend, got %T", b) } } // ── extractHermesSessionID ── func TestExtractHermesSessionID(t *testing.T) { t.Parallel() raw := json.RawMessage(`{"sessionId":"20260410_141145_47260c"}`) got := extractHermesSessionID(raw) if got != "20260410_141145_47260c" { t.Errorf("got %q, want %q", got, "20260410_141145_47260c") } } func TestExtractHermesSessionIDEmpty(t *testing.T) { t.Parallel() raw := json.RawMessage(`{}`) got := extractHermesSessionID(raw) if got != "" { t.Errorf("got %q, want empty", got) } } func TestExtractHermesSessionIDInvalidJSON(t *testing.T) { t.Parallel() raw := json.RawMessage(`not json`) got := extractHermesSessionID(raw) if got != "" { t.Errorf("got %q, want empty", got) } } // ── hermesToolNameFromTitle ── func TestHermesToolNameFromTitle(t *testing.T) { t.Parallel() tests := []struct { title string kind string want string }{ {"terminal: ls -la", "execute", "terminal"}, {"read: /tmp/foo.go", "read", "read_file"}, {"write: /tmp/bar.go", "edit", "write_file"}, {"patch (replace): /tmp/baz.go", "edit", "patch"}, {"search: *.go", "search", "search_files"}, {"web search: golang acp protocol", "fetch", "web_search"}, {"extract: https://example.com", "fetch", "web_extract"}, {"delegate: fix the bug", "execute", "delegate_task"}, {"analyze image: what is this?", "read", "vision_analyze"}, {"execute code", "execute", "execute_code"}, // Fallback to kind when no colon in title. {"unknownTool", "read", "read_file"}, {"unknownTool", "edit", "write_file"}, {"unknownTool", "execute", "terminal"}, {"unknownTool", "search", "search_files"}, {"unknownTool", "fetch", "web_search"}, {"unknownTool", "think", "thinking"}, {"unknownTool", "other", "other"}, // Tool with colon but not in known map. {"custom_tool: args", "other", "custom_tool"}, } for _, tt := range tests { got := hermesToolNameFromTitle(tt.title, tt.kind) if got != tt.want { t.Errorf("hermesToolNameFromTitle(%q, %q) = %q, want %q", tt.title, tt.kind, got, tt.want) } } } // ── handleLine routing ── func TestHermesClientHandleLineResponse(t *testing.T) { t.Parallel() c := &hermesClient{ pending: make(map[int]*pendingRPC), } pr := &pendingRPC{ch: make(chan rpcResult, 1), method: "session/new"} c.pending[1] = pr c.handleLine(`{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_abc"}}`) res := <-pr.ch if res.err != nil { t.Fatalf("unexpected error: %v", res.err) } sid := extractHermesSessionID(res.result) if sid != "ses_abc" { t.Errorf("sessionId: got %q, want %q", sid, "ses_abc") } } func TestHermesClientHandleLineError(t *testing.T) { t.Parallel() c := &hermesClient{ pending: make(map[int]*pendingRPC), } pr := &pendingRPC{ch: make(chan rpcResult, 1), method: "initialize"} c.pending[0] = pr c.handleLine(`{"jsonrpc":"2.0","id":0,"error":{"code":-32600,"message":"bad request"}}`) res := <-pr.ch if res.err == nil { t.Fatal("expected error") } if got := res.err.Error(); got != "initialize: bad request (code=-32600)" { t.Errorf("error: got %q", got) } } // ── session/update notification handling ── func TestHermesClientHandleAgentMessage(t *testing.T) { t.Parallel() var got Message c := &hermesClient{ pending: make(map[int]*pendingRPC), onMessage: func(msg Message) { got = msg }, } line := `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Hello world"}}}}` c.handleLine(line) if got.Type != MessageText { t.Errorf("type: got %v, want MessageText", got.Type) } if got.Content != "Hello world" { t.Errorf("content: got %q, want %q", got.Content, "Hello world") } } func TestHermesClientHandleAgentThought(t *testing.T) { t.Parallel() var got Message c := &hermesClient{ pending: make(map[int]*pendingRPC), onMessage: func(msg Message) { got = msg }, } line := `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let me think..."}}}}` c.handleLine(line) if got.Type != MessageThinking { t.Errorf("type: got %v, want MessageThinking", got.Type) } if got.Content != "Let me think..." { t.Errorf("content: got %q, want %q", got.Content, "Let me think...") } } func TestHermesClientHandleToolCallStart(t *testing.T) { t.Parallel() var got Message c := &hermesClient{ pending: make(map[int]*pendingRPC), onMessage: func(msg Message) { got = msg }, } line := `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"tool_call","toolCallId":"tc-abc123","title":"terminal: ls -la","kind":"execute","status":"pending","rawInput":{"command":"ls -la"}}}}` c.handleLine(line) if got.Type != MessageToolUse { t.Errorf("type: got %v, want MessageToolUse", got.Type) } if got.Tool != "terminal" { t.Errorf("tool: got %q, want %q", got.Tool, "terminal") } if got.CallID != "tc-abc123" { t.Errorf("callID: got %q, want %q", got.CallID, "tc-abc123") } if cmd, ok := got.Input["command"].(string); !ok || cmd != "ls -la" { t.Errorf("input.command: got %v", got.Input["command"]) } } func TestHermesClientHandleToolCallComplete(t *testing.T) { t.Parallel() var got Message c := &hermesClient{ pending: make(map[int]*pendingRPC), onMessage: func(msg Message) { got = msg }, } line := `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"tool_call_update","toolCallId":"tc-abc123","status":"completed","kind":"execute","rawOutput":"file1.go\nfile2.go\n"}}}` c.handleLine(line) if got.Type != MessageToolResult { t.Errorf("type: got %v, want MessageToolResult", got.Type) } if got.CallID != "tc-abc123" { t.Errorf("callID: got %q, want %q", got.CallID, "tc-abc123") } if got.Output != "file1.go\nfile2.go\n" { t.Errorf("output: got %q", got.Output) } } func TestHermesClientHandleToolCallInProgressIgnored(t *testing.T) { t.Parallel() called := false c := &hermesClient{ pending: make(map[int]*pendingRPC), onMessage: func(msg Message) { called = true }, } line := `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"tool_call_update","toolCallId":"tc-abc123","status":"in_progress"}}}` c.handleLine(line) if called { t.Error("expected in_progress tool_call_update to be ignored") } } func TestHermesClientHandleUsageUpdate(t *testing.T) { t.Parallel() c := &hermesClient{ pending: make(map[int]*pendingRPC), } line := `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"usage_update","usage":{"inputTokens":500,"outputTokens":200,"cachedReadTokens":100}}}}` c.handleLine(line) c.usageMu.Lock() defer c.usageMu.Unlock() if c.usage.InputTokens != 500 { t.Errorf("inputTokens: got %d, want 500", c.usage.InputTokens) } if c.usage.OutputTokens != 200 { t.Errorf("outputTokens: got %d, want 200", c.usage.OutputTokens) } if c.usage.CacheReadTokens != 100 { t.Errorf("cacheReadTokens: got %d, want 100", c.usage.CacheReadTokens) } } func TestHermesClientHandleUsageUpdateCumulative(t *testing.T) { t.Parallel() c := &hermesClient{ pending: make(map[int]*pendingRPC), } // First usage update. c.handleLine(`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"usage_update","usage":{"inputTokens":100,"outputTokens":50}}}}`) // Second usage update with higher values (should take the max). c.handleLine(`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"usage_update","usage":{"inputTokens":300,"outputTokens":120}}}}`) c.usageMu.Lock() defer c.usageMu.Unlock() if c.usage.InputTokens != 300 { t.Errorf("inputTokens: got %d, want 300", c.usage.InputTokens) } if c.usage.OutputTokens != 120 { t.Errorf("outputTokens: got %d, want 120", c.usage.OutputTokens) } } // ── extractPromptResult ── func TestHermesClientExtractPromptResult(t *testing.T) { t.Parallel() var got hermesPromptResult c := &hermesClient{ pending: make(map[int]*pendingRPC), onPromptDone: func(result hermesPromptResult) { got = result }, } data := json.RawMessage(`{"stopReason":"end_turn","usage":{"inputTokens":1000,"outputTokens":200,"cachedReadTokens":50}}`) c.extractPromptResult(data) if got.stopReason != "end_turn" { t.Errorf("stopReason: got %q, want %q", got.stopReason, "end_turn") } if got.usage.InputTokens != 1000 { t.Errorf("inputTokens: got %d, want 1000", got.usage.InputTokens) } if got.usage.OutputTokens != 200 { t.Errorf("outputTokens: got %d, want 200", got.usage.OutputTokens) } if got.usage.CacheReadTokens != 50 { t.Errorf("cacheReadTokens: got %d, want 50", got.usage.CacheReadTokens) } } func TestHermesClientExtractPromptResultNoUsage(t *testing.T) { t.Parallel() var got hermesPromptResult c := &hermesClient{ pending: make(map[int]*pendingRPC), onPromptDone: func(result hermesPromptResult) { got = result }, } data := json.RawMessage(`{"stopReason":"cancelled"}`) c.extractPromptResult(data) if got.stopReason != "cancelled" { t.Errorf("stopReason: got %q, want %q", got.stopReason, "cancelled") } if got.usage.InputTokens != 0 { t.Errorf("inputTokens: got %d, want 0", got.usage.InputTokens) } } func TestHermesClientIgnoresUnknownNotification(t *testing.T) { t.Parallel() called := false c := &hermesClient{ pending: make(map[int]*pendingRPC), onMessage: func(msg Message) { called = true }, } // Unknown method should be silently ignored. c.handleLine(`{"jsonrpc":"2.0","method":"unknown/event","params":{}}`) if called { t.Error("expected unknown notification to be ignored") } } func TestHermesClientIgnoresInvalidJSON(t *testing.T) { t.Parallel() c := &hermesClient{ pending: make(map[int]*pendingRPC), } // Should not panic. c.handleLine("not json at all") c.handleLine("") c.handleLine("{}") }