package acp import ( "bufio" "context" "encoding/json" "errors" "io" "strings" "testing" "time" "mm/internal/config" history "mm/internal/session" sdk "github.com/coder/acp-go-sdk" "github.com/firebase/genkit/go/ai" ) // A prompt mixes text and attached files (resource_link): the text is kept // as-is and the attachment becomes a path the model can read with its tools — // bob has no embeddedContext capability, so the content itself never travels. func TestFlattenTextAndResourceLink(t *testing.T) { got := flatten([]sdk.ContentBlock{ sdk.TextBlock("fix the greeting"), sdk.ResourceLinkBlock("main.go", "file:///work/project/hello/main.go"), }) want := "fix the greeting\n[attached file: /work/project/hello/main.go]" if got != want { t.Errorf("flatten:\n got %q\nwant %q", got, want) } } // Unknown kinds must render, not fail: the kind only drives the client's // display, so "execute" is the safe default. func TestToolKindDefaultsToExecute(t *testing.T) { cases := map[string]sdk.ToolKind{ "read": sdk.ToolKindRead, "edit": sdk.ToolKindEdit, "execute": sdk.ToolKindExecute, "": sdk.ToolKindExecute, "exotic": sdk.ToolKindExecute, } for in, want := range cases { if got := toolKind(in); got != want { t.Errorf("toolKind(%q) = %q, want %q", in, got, want) } } } // The list the editor is shown must be exactly what session/prompt // intercepts — no more (a /quit or /abort promised here would reach the model // as a question; the editor's own stop key cancels a turn) and no less (a // command that works but is not listed is one the user never finds). Names // are bare: ACP clients add the slash. func TestAvailableCommandsMatchWhatPromptIntercepts(t *testing.T) { want := []string{history.NewCommandName, "compact"} cmds := availableCommands() if len(cmds) != len(want) { t.Fatalf("availableCommands: %d command(s), want %d: %+v", len(cmds), len(want), cmds) } for i, c := range cmds { if c.Name != want[i] { t.Errorf("command %d is %q, want %q", i, c.Name, want[i]) } if c.Description == "" { t.Errorf("%q has no description: the editor would show a bare name", c.Name) } if c.Input != nil { t.Errorf("%q takes no input, but an input spec is advertised", c.Name) } // Each advertised name must be recognised when it comes back as "/name". typed := "/" + c.Name if !history.IsNewCommand(typed) && !isCommand(typed, compactCommand) { t.Errorf("%q is advertised but session/prompt would hand it to the model", typed) } } } // Same rule as /new: the command is the whole line, spaces aside. func TestIsCommandTrimsAndRequiresExactMatch(t *testing.T) { cases := map[string]bool{ "/compact": true, " /compact\n": true, "/compact now": false, "/compacts": false, "please /compact": false, } for in, want := range cases { if got := isCommand(in, compactCommand); got != want { t.Errorf("isCommand(%q) = %v, want %v", in, got, want) } } } // resetSession is what /new does to a session: the history goes back to the // system prompt alone, the count says how much was dropped, and everything // that is NOT the history — cwd, the "allow always" grants — survives, because // the editor still sees the same session. func TestResetSessionKeepsIdentityDropsHistory(t *testing.T) { s := &session{ cwd: "/work/project", messages: history.Fresh("you are bob"), allowAlways: map[string]bool{"bash": true}, } s.messages = append(s.messages, ai.NewUserTextMessage("ls"), ai.NewModelTextMessage("main.go")) if got := resetSession(s, "you are bob"); got != 2 { t.Errorf("forgotten = %d, want 2", got) } if len(s.messages) != 1 || s.messages[0].Role != ai.RoleSystem || s.messages[0].Text() != "you are bob" { t.Errorf("history after reset: %+v, want the system prompt alone", s.messages) } if s.cwd != "/work/project" { t.Errorf("cwd changed to %q", s.cwd) } if !s.allowAlways["bash"] { t.Error("the 'allow always' grant for bash was dropped") } // A second /new on a fresh session forgets nothing — and says so. if got := resetSession(s, "you are bob"); got != 0 { t.Errorf("forgotten on a fresh session = %d, want 0", got) } } // The command is recognised from a flattened prompt — the editor sends it as // a text block, exactly as typed. func TestNewCommandIsRecognisedFromPromptBlocks(t *testing.T) { if !history.IsNewCommand(flatten([]sdk.ContentBlock{sdk.TextBlock("/new")})) { t.Error("a text block '/new' is not recognised as the command") } if history.IsNewCommand(flatten([]sdk.ContentBlock{sdk.TextBlock("/new"), sdk.ResourceLinkBlock("main.go", "file:///w/main.go")})) { t.Error("'/new' with an attached file is a question about that file, not the command") } } // responseSessionID must recognise exactly the line the commands hook waits // for: a response whose result carries a sessionId. A notification (no id) // or a prompt response (no sessionId) must not fire the hook — the first // would announce the commands before the editor knows the session, the // second would never come from session/new at all. func TestResponseSessionIDMatchesOnlySessionResponses(t *testing.T) { cases := map[string]struct { line string want sdk.SessionId }{ "session/new response": {`{"jsonrpc":"2.0","id":1,"result":{"sessionId":"bob-1-1"}}`, "bob-1-1"}, "string id": {`{"jsonrpc":"2.0","id":"a","result":{"sessionId":"bob-1-2"}}`, "bob-1-2"}, "prompt response": {`{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}`, ""}, "session/update": {`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"bob-1-1","update":{}}}`, ""}, "request from the agent": {`{"jsonrpc":"2.0","id":3,"method":"session/request_permission","params":{"sessionId":"bob-1-1"}}`, ""}, "garbage": {`not json`, ""}, } for name, c := range cases { got, ok := responseSessionID([]byte(c.line)) if ok != (c.want != "") || got != c.want { t.Errorf("%s: responseSessionID = (%q, %v), want (%q, %v)", name, got, ok, c.want, c.want != "") } } } // The order on the wire is the whole point: the editor must read the // session/new response — and so learn the session id — before the // available_commands_update that names it. This drives the real SDK over // pipes, playing the client by hand, and reads the raw lines back. func TestAvailableCommandsAreSentAfterTheSessionNewResponse(t *testing.T) { clientToAgentR, clientToAgentW := io.Pipe() agentToClientR, agentToClientW := io.Pipe() t.Cleanup(func() { _ = clientToAgentW.Close() _ = agentToClientR.Close() }) newFront(nil, "system prompt", nil, agentToClientW, clientToAgentR) go func() { _, _ = io.WriteString(clientToAgentW, `{"jsonrpc":"2.0","id":7,"method":"session/new","params":{"cwd":"/work","mcpServers":[]}}`+"\n") }() lines := make(chan string) go func() { sc := bufio.NewScanner(agentToClientR) for sc.Scan() { lines <- sc.Text() } close(lines) }() next := func() string { select { case l, ok := <-lines: if !ok { t.Fatal("agent closed its output before sending both lines") } return l case <-time.After(5 * time.Second): t.Fatal("timed out waiting for the agent's next line") } return "" } first, second := next(), next() sid, ok := responseSessionID([]byte(first)) if !ok { t.Fatalf("first line is not the session/new response:\n%s", first) } var upd struct { Method string `json:"method"` Params struct { SessionId sdk.SessionId `json:"sessionId"` Update struct { SessionUpdate string `json:"sessionUpdate"` AvailableCommands []sdk.AvailableCommand `json:"availableCommands"` } `json:"update"` } `json:"params"` } if err := json.Unmarshal([]byte(second), &upd); err != nil { t.Fatalf("second line is not JSON: %v\n%s", err, second) } if upd.Method != "session/update" || upd.Params.Update.SessionUpdate != "available_commands_update" { t.Fatalf("second line is not an available_commands_update:\n%s", second) } if upd.Params.SessionId != sid { t.Errorf("commands announced for session %q, response created %q", upd.Params.SessionId, sid) } want := availableCommands() if len(upd.Params.Update.AvailableCommands) != len(want) { t.Fatalf("announced %d command(s), want %d: %+v", len(upd.Params.Update.AvailableCommands), len(want), upd.Params.Update.AvailableCommands) } for i, c := range upd.Params.Update.AvailableCommands { if c.Name != want[i].Name { t.Errorf("announced command %d is %q, want %q", i, c.Name, want[i].Name) } } } // --- /compact and /abort -------------------------------------------------------- // oneTurn is a question answered after one bash command: the smallest unit // compaction cuts on. func oneTurn(q string) []*ai.Message { return []*ai.Message{ ai.NewUserTextMessage(q), ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Ref: q, Name: "bash", Input: map[string]any{"command": "echo " + q}})), ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Ref: q, Name: "bash", Output: q})), ai.NewModelMessage(ai.NewTextPart("done " + q)), } } func sessionWithTurns(qs ...string) *session { s := &session{cwd: "/work", messages: history.Fresh("You are Bob."), allowAlways: map[string]bool{"bash": true}} for _, q := range qs { s.messages = append(s.messages, oneTurn(q)...) } return s } // The three outcomes of /compact, in the REPL's words. Only the first one // touches the history; the two others leave it — and the server's token // count, which the caller resets on `compressed` alone — exactly as they were. func TestCompactHistoryOutcomes(t *testing.T) { cfg := config.ContextConfig{Enabled: true, KeepLastTurns: 1, Threshold: 75, SummaryMaxTokens: 100} summary := func(context.Context, []*ai.Message) (string, error) { return "the user ran a and b", nil } explain := func(err error) string { return "explained: " + err.Error() } t.Run("compresses the turns older than the kept ones", func(t *testing.T) { s := sessionWithTurns("a", "b", "c") before := len(s.messages) line, compressed := compactHistory(context.Background(), s, cfg, summary, explain) if !compressed { t.Fatalf("compressed = false, line %q", line) } if !strings.HasPrefix(line, "🗜️ compressed 8 messages → 1 summary + 4 kept") { t.Errorf("report %q does not open with the REPL's words and the right counts", line) } if len(s.messages) >= before { t.Errorf("history has %d messages after compaction, %d before", len(s.messages), before) } if s.messages[0].Role != ai.RoleSystem || s.allowAlways["bash"] != true || s.cwd != "/work" { t.Error("compaction touched something other than the turns") } }) t.Run("nothing to compact is said, not swallowed", func(t *testing.T) { s := sessionWithTurns("a") before := len(s.messages) line, compressed := compactHistory(context.Background(), s, cfg, summary, explain) if compressed || len(s.messages) != before { t.Errorf("compressed = %v, %d messages (was %d): a single kept turn must not move", compressed, len(s.messages), before) } if line != "🗜️ nothing to compact: 5 message(s), no turn older than the last 1" { t.Errorf("line %q", line) } }) t.Run("a failed summary keeps the history and names the cause", func(t *testing.T) { s := sessionWithTurns("a", "b", "c") before := len(s.messages) failing := func(context.Context, []*ai.Message) (string, error) { return "", errors.New("server down") } line, compressed := compactHistory(context.Background(), s, cfg, failing, explain) if compressed || len(s.messages) != before { t.Errorf("compressed = %v, %d messages (was %d): a failure must leave the history alone", compressed, len(s.messages), before) } if line != "[compact: failed, history kept: explained: server down]" { t.Errorf("line %q", line) } }) }