mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 14:09:22 +02:00
* fix: pass model to Hermes ACP session/new and add hermes to InjectRuntimeConfig - hermes.go: include opts.Model in session/new params so Hermes uses the configured model instead of its default (fixes local LLM failures) - runtime_config.go: add "hermes" to the AGENTS.md provider list so Hermes receives the Multica runtime instructions and skill discovery Fixes: https://github.com/multica-ai/multica/issues/1195 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(hermes): drop false native-skill claim and add regression tests The previous change added 'hermes' to the 'skills discovered automatically' branch of buildMetaSkillContent, but resolveSkillsDir has no Hermes case so skills still land in the .agent_context/skills/ fallback. AGENTS.md ended up claiming native discovery while the files were somewhere else, which would mislead Hermes (and future debuggers). - Move 'hermes' to the fallback branch alongside 'gemini' so AGENTS.md points Hermes at .agent_context/skills/ — matching where writeContextFiles actually writes them. - Extract buildHermesSessionParams so the session/new payload is unit-testable. - Add regression tests covering: * buildHermesSessionParams includes/omits 'model' correctly * InjectRuntimeConfig('hermes') writes AGENTS.md with the fallback hint * writeContextFiles('hermes') writes skills to .agent_context/skills/ Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: CC-Girl <cc-girl@multica.ai>
793 lines
25 KiB
Go
793 lines
25 KiB
Go
package agent
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"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)
|
|
}
|
|
}
|
|
|
|
// ── extractACPSessionID ──
|
|
|
|
func TestExtractACPSessionID(t *testing.T) {
|
|
t.Parallel()
|
|
raw := json.RawMessage(`{"sessionId":"20260410_141145_47260c"}`)
|
|
got := extractACPSessionID(raw)
|
|
if got != "20260410_141145_47260c" {
|
|
t.Errorf("got %q, want %q", got, "20260410_141145_47260c")
|
|
}
|
|
}
|
|
|
|
func TestExtractACPSessionIDEmpty(t *testing.T) {
|
|
t.Parallel()
|
|
raw := json.RawMessage(`{}`)
|
|
got := extractACPSessionID(raw)
|
|
if got != "" {
|
|
t.Errorf("got %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractACPSessionIDInvalidJSON(t *testing.T) {
|
|
t.Parallel()
|
|
raw := json.RawMessage(`not json`)
|
|
got := extractACPSessionID(raw)
|
|
if got != "" {
|
|
t.Errorf("got %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
// ── buildHermesSessionParams ──
|
|
|
|
func TestBuildHermesSessionParamsIncludesModel(t *testing.T) {
|
|
t.Parallel()
|
|
params := buildHermesSessionParams("/tmp/work", "gpt-4o")
|
|
if params["cwd"] != "/tmp/work" {
|
|
t.Errorf("cwd: got %v, want /tmp/work", params["cwd"])
|
|
}
|
|
if _, ok := params["mcpServers"]; !ok {
|
|
t.Error("mcpServers missing")
|
|
}
|
|
if got, ok := params["model"].(string); !ok || got != "gpt-4o" {
|
|
t.Errorf("model: got %v, want gpt-4o", params["model"])
|
|
}
|
|
}
|
|
|
|
func TestBuildHermesSessionParamsOmitsEmptyModel(t *testing.T) {
|
|
t.Parallel()
|
|
params := buildHermesSessionParams("/tmp/work", "")
|
|
if _, present := params["model"]; present {
|
|
t.Error("expected model key to be omitted when model is empty")
|
|
}
|
|
}
|
|
|
|
// ── 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 but kind is known.
|
|
{"unknownTool", "read", "read_file"},
|
|
{"unknownTool", "edit", "write_file"},
|
|
{"unknownTool", "execute", "terminal"},
|
|
{"unknownTool", "search", "search_files"},
|
|
{"unknownTool", "fetch", "web_search"},
|
|
{"unknownTool", "think", "thinking"},
|
|
// Bare title (no colon, no known kind) — preserve the title
|
|
// itself rather than falling back to an unclassified kind.
|
|
// Matters for kimi: its ACP `tool_call` updates emit a bare
|
|
// `title: "Shell"` with no `kind`, and we need downstream
|
|
// normalisation (kimiToolNameFromTitle) to see "Shell" rather
|
|
// than an empty string.
|
|
{"Shell", "", "Shell"},
|
|
{"Read file", "", "Read file"},
|
|
{"unknownTool", "other", "unknownTool"},
|
|
// Empty title falls back to kind, even when kind isn't known.
|
|
{"", "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 := extractACPSessionID(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)
|
|
}
|
|
}
|
|
|
|
// ── agent → client request handling ──
|
|
|
|
// bufferWriter is a test stand-in for cmd.StdinPipe that captures
|
|
// writes in-memory so we can assert what handleAgentRequest emitted.
|
|
type bufferWriter struct {
|
|
mu sync.Mutex
|
|
buf strings.Builder
|
|
}
|
|
|
|
func (b *bufferWriter) Write(p []byte) (int, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.WriteString(string(p))
|
|
}
|
|
|
|
func (b *bufferWriter) String() string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.String()
|
|
}
|
|
|
|
// TestHermesClientAutoApprovesPermissionRequest asserts that when an
|
|
// ACP agent sends us `session/request_permission` (kimi does this on
|
|
// every Shell / file-mutating tool call), the client replies with
|
|
// `approve_for_session` — without this the agent blocks 300s and the
|
|
// task hangs. The id in the reply must match the agent's request id
|
|
// so its in-flight future resolves.
|
|
func TestHermesClientAutoApprovesPermissionRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := &bufferWriter{}
|
|
c := &hermesClient{
|
|
cfg: Config{Logger: slog.Default()},
|
|
stdin: w,
|
|
pending: make(map[int]*pendingRPC),
|
|
}
|
|
|
|
c.handleLine(`{"jsonrpc":"2.0","id":42,"method":"session/request_permission","params":{"sessionId":"ses_1","options":[{"optionId":"approve","name":"Approve once","kind":"allow_once"},{"optionId":"approve_for_session","name":"Approve for this session","kind":"allow_always"},{"optionId":"reject","name":"Reject","kind":"reject_once"}],"toolCall":{"toolCallId":"tc_1","title":"Shell","content":[]}}}`)
|
|
|
|
got := w.String()
|
|
var resp struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID int `json:"id"`
|
|
Result struct {
|
|
Outcome struct {
|
|
Outcome string `json:"outcome"`
|
|
OptionID string `json:"optionId"`
|
|
} `json:"outcome"`
|
|
} `json:"result"`
|
|
}
|
|
if err := json.Unmarshal([]byte(strings.TrimSpace(got)), &resp); err != nil {
|
|
t.Fatalf("reply is not valid JSON: %q err=%v", got, err)
|
|
}
|
|
if resp.JSONRPC != "2.0" {
|
|
t.Errorf("jsonrpc: got %q, want 2.0", resp.JSONRPC)
|
|
}
|
|
if resp.ID != 42 {
|
|
t.Errorf("id: got %d, want 42 (must echo agent's request id)", resp.ID)
|
|
}
|
|
if resp.Result.Outcome.Outcome != "selected" {
|
|
t.Errorf("outcome.outcome: got %q, want %q", resp.Result.Outcome.Outcome, "selected")
|
|
}
|
|
if resp.Result.Outcome.OptionID != "approve_for_session" {
|
|
t.Errorf("outcome.optionId: got %q, want %q", resp.Result.Outcome.OptionID, "approve_for_session")
|
|
}
|
|
}
|
|
|
|
// TestHermesClientReplesMethodNotFoundForUnknownAgentRequest ensures
|
|
// that any agent → client request we don't explicitly handle gets a
|
|
// proper JSON-RPC error back, not silence. Silence would block the
|
|
// agent for however long its internal timeout is, same as the
|
|
// session/request_permission hang this change fixes.
|
|
func TestHermesClientReplesMethodNotFoundForUnknownAgentRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := &bufferWriter{}
|
|
c := &hermesClient{
|
|
cfg: Config{Logger: slog.Default()},
|
|
stdin: w,
|
|
pending: make(map[int]*pendingRPC),
|
|
}
|
|
c.handleLine(`{"jsonrpc":"2.0","id":7,"method":"fs/read_text_file","params":{"path":"/tmp/x"}}`)
|
|
|
|
got := w.String()
|
|
var resp struct {
|
|
ID int `json:"id"`
|
|
Error struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal([]byte(strings.TrimSpace(got)), &resp); err != nil {
|
|
t.Fatalf("reply not valid JSON: %q err=%v", got, err)
|
|
}
|
|
if resp.ID != 7 {
|
|
t.Errorf("id echo: got %d, want 7", resp.ID)
|
|
}
|
|
if resp.Error.Code != -32601 {
|
|
t.Errorf("error code: got %d, want -32601 (method not found)", resp.Error.Code)
|
|
}
|
|
if !strings.Contains(resp.Error.Message, "fs/read_text_file") {
|
|
t.Errorf("error message should name the unhandled method, got %q", resp.Error.Message)
|
|
}
|
|
}
|
|
|
|
// ── 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)
|
|
}
|
|
}
|
|
|
|
// TestHermesClientKimiStreamingToolCall walks the real kimi frame
|
|
// sequence for a single Shell call:
|
|
// 1. tool_call with empty content (LLM hasn't started emitting args yet)
|
|
// 2. tool_call_update status=in_progress carrying the cumulative args
|
|
// JSON character-by-character ("{", "{\"command", …)
|
|
// 3. tool_call_update status=completed carrying the command's stdout
|
|
//
|
|
// The client must defer MessageToolUse until we have the full args so
|
|
// the UI doesn't show a command like `{"comma` — and the MessageToolUse
|
|
// must carry the parsed args as the Input map (`{"command": "echo hi"}`
|
|
// → Input["command"] = "echo hi") rather than a raw string.
|
|
func TestHermesClientKimiStreamingToolCall(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var got []Message
|
|
c := &hermesClient{
|
|
pending: make(map[int]*pendingRPC),
|
|
onMessage: func(msg Message) {
|
|
got = append(got, msg)
|
|
},
|
|
}
|
|
|
|
// 1. tool_call: empty content (classic kimi start frame).
|
|
c.handleLine(`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"tool_call","toolCallId":"tc-kimi-1","title":"Shell","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":""}}]}}}`)
|
|
if len(got) != 0 {
|
|
t.Fatalf("expected nothing emitted yet (args empty), got %+v", got)
|
|
}
|
|
|
|
// 2. Streaming updates — cumulative args JSON.
|
|
partials := []string{
|
|
`{"`,
|
|
`{"command`,
|
|
`{"command":`,
|
|
`{"command":"echo `,
|
|
`{"command":"echo hi"}`,
|
|
}
|
|
for _, args := range partials {
|
|
// JSON-encode args so embedded quotes are escaped properly.
|
|
argsJSON, _ := json.Marshal(args)
|
|
line := `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"tool_call_update","toolCallId":"tc-kimi-1","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":` + string(argsJSON) + `}}]}}}`
|
|
c.handleLine(line)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Fatalf("expected nothing emitted mid-stream, got %+v", got)
|
|
}
|
|
|
|
// 3. Completed — stdout.
|
|
c.handleLine(`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"tool_call_update","toolCallId":"tc-kimi-1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"hi\n"}}]}}}`)
|
|
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected [MessageToolUse, MessageToolResult], got %d: %+v", len(got), got)
|
|
}
|
|
if got[0].Type != MessageToolUse {
|
|
t.Errorf("first message: got %v, want MessageToolUse", got[0].Type)
|
|
}
|
|
if got[0].CallID != "tc-kimi-1" {
|
|
t.Errorf("first.callID: got %q", got[0].CallID)
|
|
}
|
|
if cmd, _ := got[0].Input["command"].(string); cmd != "echo hi" {
|
|
t.Errorf("first.Input.command: got %v, want %q", got[0].Input["command"], "echo hi")
|
|
}
|
|
if got[1].Type != MessageToolResult {
|
|
t.Errorf("second message: got %v, want MessageToolResult", got[1].Type)
|
|
}
|
|
if got[1].Output != "hi\n" {
|
|
t.Errorf("second.output: got %q, want %q", got[1].Output, "hi\n")
|
|
}
|
|
}
|
|
|
|
// TestHermesClientKimiMalformedArgsFallback: if the accumulated args
|
|
// aren't valid JSON (streaming glitch, tool with non-JSON args), we
|
|
// still surface the text under Input.text rather than silently
|
|
// dropping it.
|
|
func TestHermesClientKimiMalformedArgsFallback(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var got []Message
|
|
c := &hermesClient{
|
|
pending: make(map[int]*pendingRPC),
|
|
onMessage: func(msg Message) {
|
|
got = append(got, msg)
|
|
},
|
|
}
|
|
|
|
c.handleLine(`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"tool_call","toolCallId":"tc","title":"Shell","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":"not-json"}}]}}}`)
|
|
c.handleLine(`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"tool_call_update","toolCallId":"tc","status":"completed","content":[{"type":"content","content":{"type":"text","text":"output"}}]}}}`)
|
|
|
|
if len(got) < 1 {
|
|
t.Fatalf("expected ToolUse+ToolResult, got %+v", got)
|
|
}
|
|
if text, _ := got[0].Input["text"].(string); text != "not-json" {
|
|
t.Errorf("fallback Input.text: got %v", got[0].Input["text"])
|
|
}
|
|
}
|
|
|
|
// TestHermesClientHandleToolCallCompleteOrphan: if a completion frame
|
|
// arrives without a preceding tool_call (out-of-order / missed frame),
|
|
// still emit ToolUse synthesised from the update's own title/rawInput
|
|
// before ToolResult. Keeps the UI from showing a bare result with no
|
|
// header.
|
|
func TestHermesClientHandleToolCallCompleteOrphan(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var got []Message
|
|
c := &hermesClient{
|
|
pending: make(map[int]*pendingRPC),
|
|
onMessage: func(msg Message) {
|
|
got = append(got, msg)
|
|
},
|
|
}
|
|
|
|
c.handleLine(`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"tool_call_update","toolCallId":"tc","status":"completed","title":"terminal: ls","kind":"execute","rawInput":{"command":"ls"},"content":[{"type":"content","content":{"type":"text","text":"file.go\n"}}]}}}`)
|
|
|
|
if len(got) != 2 || got[0].Type != MessageToolUse || got[1].Type != MessageToolResult {
|
|
t.Fatalf("expected [ToolUse, ToolResult], got %+v", got)
|
|
}
|
|
if got[0].Tool != "terminal" {
|
|
t.Errorf("orphan ToolUse tool: got %q", got[0].Tool)
|
|
}
|
|
if cmd, _ := got[0].Input["command"].(string); cmd != "ls" {
|
|
t.Errorf("orphan ToolUse input.command: got %v", got[0].Input["command"])
|
|
}
|
|
if got[1].Output != "file.go\n" {
|
|
t.Errorf("ToolResult output: got %q", got[1].Output)
|
|
}
|
|
}
|
|
|
|
// TestHermesClientHandleToolCallRawOutputTakesPrecedence keeps hermes
|
|
// behaviour unchanged: when the update has both `rawOutput` (hermes
|
|
// convention) and `content` (would be ambiguous), honour rawOutput.
|
|
func TestHermesClientHandleToolCallRawOutputTakesPrecedence(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","status":"completed","rawOutput":"raw wins","content":[{"type":"content","content":{"type":"text","text":"ignored"}}]}}}`
|
|
c.handleLine(line)
|
|
|
|
if got.Output != "raw wins" {
|
|
t.Errorf("output: got %q, want %q", got.Output, "raw wins")
|
|
}
|
|
}
|
|
|
|
func TestExtractACPToolCallText(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
json string
|
|
want string
|
|
}{
|
|
{
|
|
name: "single text block",
|
|
json: `[{"type":"content","content":{"type":"text","text":"hello"}}]`,
|
|
want: "hello",
|
|
},
|
|
{
|
|
name: "multiple text blocks join with newline",
|
|
json: `[{"type":"content","content":{"type":"text","text":"a"}},{"type":"content","content":{"type":"text","text":"b"}}]`,
|
|
want: "a\nb",
|
|
},
|
|
{
|
|
name: "terminal blocks skipped",
|
|
json: `[{"type":"terminal","terminalId":"t1"},{"type":"content","content":{"type":"text","text":"shell out"}}]`,
|
|
want: "shell out",
|
|
},
|
|
{
|
|
name: "diff block renders as mini header",
|
|
json: `[{"type":"diff","path":"foo.go","oldText":"abc","newText":"abcdef"}]`,
|
|
want: "--- foo.go\n+++ foo.go\n(edited: 3 → 6 bytes)",
|
|
},
|
|
{
|
|
name: "new-file diff (no oldText)",
|
|
json: `[{"type":"diff","path":"new.go","oldText":"","newText":"hi"}]`,
|
|
want: "--- new.go\n+++ new.go\n(new file, 2 bytes)",
|
|
},
|
|
{
|
|
name: "empty array returns empty",
|
|
json: `[]`,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "no text content",
|
|
json: `[{"type":"terminal","terminalId":"t1"}]`,
|
|
want: "",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var blocks []json.RawMessage
|
|
if err := json.Unmarshal([]byte(tt.json), &blocks); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if got := extractACPToolCallText(blocks); got != tt.want {
|
|
t.Errorf("got %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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("{}")
|
|
}
|
|
|
|
func TestHermesProviderErrorSniffer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Real sample of the stderr hermes emits when the configured
|
|
// LLM endpoint rejects the requested model. We verify the
|
|
// sniffer extracts the `Error: ...` line so the task error
|
|
// tells the user *why* it failed.
|
|
s := newACPProviderErrorSniffer("hermes")
|
|
lines := []string{
|
|
"2026-04-20 23:41:47 [INFO] acp_adapter.server: Prompt on session abc",
|
|
`⚠️ API call failed (attempt 1/3): BadRequestError [HTTP 400]`,
|
|
` 🔌 Provider: openai-codex Model: gpt-5.1-codex-mini`,
|
|
` 📝 Error: HTTP 400: Error code: 400 - {'detail': "The 'gpt-5.1-codex-mini' model is not supported when using Codex with a ChatGPT account."}`,
|
|
`⏱️ Elapsed: 1.17s`,
|
|
}
|
|
for _, line := range lines {
|
|
if _, err := s.Write([]byte(line + "\n")); err != nil {
|
|
t.Fatalf("Write: %v", err)
|
|
}
|
|
}
|
|
msg := s.message()
|
|
if msg == "" {
|
|
t.Fatal("expected a non-empty error message")
|
|
}
|
|
if !strings.Contains(msg, "model is not supported") {
|
|
t.Errorf("expected detail about model support, got %q", msg)
|
|
}
|
|
}
|
|
|
|
func TestHermesProviderErrorSnifferIgnoresInfoLines(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newACPProviderErrorSniffer("hermes")
|
|
s.Write([]byte("2026-04-20 23:41:45 [INFO] acp_adapter.entry: Loaded env\n"))
|
|
s.Write([]byte("2026-04-20 23:41:47 [INFO] agent.auxiliary_client: Vision auto-detect...\n"))
|
|
if msg := s.message(); msg != "" {
|
|
t.Errorf("info lines should produce no error, got %q", msg)
|
|
}
|
|
}
|
|
|
|
func TestHermesProviderErrorSnifferHandlesPartialLines(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Writer may be called mid-line; the sniffer must buffer until
|
|
// it sees a newline so the regex doesn't miss the header.
|
|
s := newACPProviderErrorSniffer("hermes")
|
|
s.Write([]byte(`⚠️ API call failed (attempt 1/3):`))
|
|
s.Write([]byte(` BadRequestError [HTTP 400]` + "\n"))
|
|
s.Write([]byte(` 📝 Error: something went wrong` + "\n"))
|
|
msg := s.message()
|
|
if !strings.Contains(msg, "something went wrong") {
|
|
t.Errorf("expected buffered line to be captured, got %q", msg)
|
|
}
|
|
}
|
|
|
|
func TestHermesProviderErrorSnifferBoundedBuffer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newACPProviderErrorSniffer("hermes")
|
|
for i := 0; i < 20; i++ {
|
|
// Each line differs so dedup doesn't merge them.
|
|
s.Write([]byte(`⚠️ API call failed (HTTP 400) attempt ` + string(rune('a'+i%26)) + `: Non-retryable error` + "\n"))
|
|
}
|
|
if len(s.lines) > acpMaxErrorLines {
|
|
t.Errorf("sniffer kept %d lines, limit is %d", len(s.lines), acpMaxErrorLines)
|
|
}
|
|
}
|