package acp_test import ( "errors" "strings" "testing" "rickub.com/turbo-editors/turbo-core/acp" ) // start returns a session talking to a fake agent over an in-memory pipe. func start(t *testing.T, options acp.Options) (*acp.Session, *fakeAgent) { t.Helper() agent, stream := newFakeAgent(t) session := acp.NewSession(stream, acp.Agent{Name: "Bob"}, t.TempDir(), options) t.Cleanup(func() { _ = session.Close() }) return session, agent } func TestTheHandshakeMakesASessionReady(t *testing.T) { session, agent := start(t, acp.Options{}) agent.handshake() waitFor(t, "the session to be ready", session.Ready) if err := session.Err(); err != nil { t.Fatalf("Err() = %v", err) } // The configured name wins: docker agent reports "docker agent", which is // the runtime rather than the assistant, and the window is titled with what // the user chose. Two names for one thing is worse than one. if got := session.AgentName(); got != "Bob" { t.Errorf("AgentName() = %q, want the configured name", got) } if got := session.AgentInfo().Version; got != "v1.139.0" { t.Errorf("AgentInfo().Version = %q; the handshake's own details are still kept", got) } } func TestTheClientSendsTheProtocolVersionAndItsCapabilities(t *testing.T) { _, agent := start(t, acp.Options{Client: acp.Implementation{Name: "turbo-test"}}) initialize := agent.read() params, _ := initialize["params"].(map[string]any) if params["protocolVersion"] != float64(acp.ProtocolVersion) { t.Errorf("the client asked for version %v, want %d", params["protocolVersion"], acp.ProtocolVersion) } capabilities, _ := params["clientCapabilities"].(map[string]any) files, _ := capabilities["fs"].(map[string]any) if files["readTextFile"] != true || files["writeTextFile"] != true { t.Errorf("the client offered %v, want both file operations", capabilities) } // Terminal is deliberately not offered, so a conforming agent never asks. if _, offered := capabilities["terminal"]; offered { t.Errorf("the client offered a terminal capability it does not implement: %v", capabilities) } if info, _ := params["clientInfo"].(map[string]any); info["name"] != "turbo-test" { t.Errorf("the client called itself %v", info) } } func TestAnAgentSpeakingAnotherVersionIsRefusedWithAReadableReason(t *testing.T) { session, agent := start(t, acp.Options{}) initialize := agent.read() agent.reply(initialize["id"], `{"protocolVersion":99,"agentInfo":{"name":"future"}}`) waitFor(t, "the session to fail", func() bool { return session.Err() != nil }) if got := session.Err().Error(); !strings.Contains(got, "99") { t.Errorf("Err() = %q, want both versions named", got) } if notice := textOf(session.Entries(), acp.EntryNotice); !strings.Contains(notice, "99") { t.Errorf("the window says %q; the reason must be visible in it", notice) } } func TestAnAgentWantingALoginSaysSoRatherThanHanging(t *testing.T) { session, agent := start(t, acp.Options{}) initialize := agent.read() agent.reply(initialize["id"], `{"protocolVersion":1,"agentInfo":{"name":"paid"},"authMethods":[{"id":"oauth","name":"Log in with OAuth"}]}`) waitFor(t, "the session to fail", func() bool { return session.Err() != nil }) if got := session.Err().Error(); !strings.Contains(got, "Log in with OAuth") { t.Errorf("Err() = %q, want the method named so a user knows what to run", got) } } func TestWhatIsTypedBeforeTheAgentIsReadyIsSentWhenItIs(t *testing.T) { // The handshake may mean reaching a model, which takes seconds. Losing // what somebody typed in that window would be the most annoying possible // version of this. session, agent := start(t, acp.Options{}) session.Prompt("early") waitFor(t, "the prompt to appear in the window", func() bool { return strings.Contains(textOf(session.Entries(), acp.EntryUser), "early") }) id := agent.handshake() prompt := agent.read() if prompt["method"] != acp.MethodPrompt { t.Fatalf("the client sent %v, want the held prompt", prompt["method"]) } params, _ := prompt["params"].(map[string]any) if params["sessionId"] != id { t.Errorf("the prompt quotes session %v, want %q", params["sessionId"], id) } blocks, _ := params["prompt"].([]any) first, _ := blocks[0].(map[string]any) if first["text"] != "early" || first["type"] != "text" { t.Errorf("the prompt carried %v", blocks) } } func TestAReplyArrivesChunkByChunkAndReadsAsOneMessage(t *testing.T) { // This is exactly what a real agent does: one update per token. An entry // per chunk could be neither wrapped nor told apart from a code fence. session, agent := start(t, acp.Options{}) id := agent.handshake() waitFor(t, "ready", session.Ready) session.Prompt("hello") prompt := agent.read() for _, chunk := range []string{"I", " found", " three", " files", "."} { agent.update(id, `{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`+chunk+`"}}`) } agent.reply(prompt["id"], `{"stopReason":"end_turn"}`) waitFor(t, "the whole reply", func() bool { return textOf(session.Entries(), acp.EntryAgent) == "I found three files." }) agentEntries := 0 for _, entry := range session.Entries() { if entry.Kind == acp.EntryAgent { agentEntries++ } } if agentEntries != 1 { t.Errorf("five chunks became %d entries, want 1", agentEntries) } } func TestAThoughtIsNotFoldedIntoTheReplyBesideIt(t *testing.T) { session, agent := start(t, acp.Options{}) id := agent.handshake() waitFor(t, "ready", session.Ready) agent.update(id, `{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"let me look"}}`) agent.update(id, `{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"here it is"}}`) waitFor(t, "both", func() bool { return textOf(session.Entries(), acp.EntryAgent) == "here it is" }) if got := textOf(session.Entries(), acp.EntryThought); got != "let me look" { t.Errorf("the thought reads %q", got) } } func TestAToolCallIsOneLineThatFillsInAsItRuns(t *testing.T) { // The real order, recorded from docker agent: the tool_call arrives, then // a tool_call_update carrying the output, both naming the same id. session, agent := start(t, acp.Options{}) id := agent.handshake() waitFor(t, "ready", session.Ready) agent.update(id, `{"sessionUpdate":"tool_call","toolCallId":"call_1","title":"Shell","kind":"execute","status":"pending","rawInput":{"cmd":"ls -1","cwd":".","timeout":30}}`) waitFor(t, "the tool call", func() bool { return toolEntry(session, "call_1") != nil }) tool := toolEntry(session, "call_1") if tool.Speaker != "Shell" { t.Errorf("the tool is called %q", tool.Speaker) } if tool.Detail != "ls -1" { t.Errorf("the detail is %q, want the command rather than the whole input object", tool.Detail) } if tool.Done() { t.Error("a pending tool call reports itself as finished") } agent.update(id, `{"sessionUpdate":"tool_call_update","toolCallId":"call_1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"agent.yaml\nprobe.py"}}]}`) waitFor(t, "the output", func() bool { tool := toolEntry(session, "call_1") return tool != nil && tool.Done() }) tool = toolEntry(session, "call_1") if !strings.Contains(tool.Text, "agent.yaml") { t.Errorf("the output is %q", tool.Text) } if tool.Failed() { t.Error("a completed tool call reports itself as failed") } if count := toolEntries(session); count != 1 { t.Errorf("the update made a second entry: %d tool entries, want 1", count) } } func TestAPlanIsReplacedRatherThanRepeated(t *testing.T) { // An agent republishes the whole plan every time a step changes, so // appending would leave five copies of a four-line plan in the window. session, agent := start(t, acp.Options{}) id := agent.handshake() waitFor(t, "ready", session.Ready) agent.update(id, `{"sessionUpdate":"plan","entries":[{"content":"look","status":"pending"}]}`) agent.update(id, `{"sessionUpdate":"plan","entries":[{"content":"look","status":"completed"},{"content":"report","status":"pending"}]}`) waitFor(t, "the second plan", func() bool { for _, entry := range session.Entries() { if entry.Kind == acp.EntryPlan && len(entry.Plan) == 2 { return true } } return false }) plans := 0 for _, entry := range session.Entries() { if entry.Kind == acp.EntryPlan { plans++ } } if plans != 1 { t.Errorf("there are %d plans in the window, want 1", plans) } } func TestAnUpdateThisClientDoesNotKnowIsCountedAndIgnored(t *testing.T) { // The protocol grows. An editor that stopped talking to an agent because // it learnt a new message would be wrong more often than it was right. session, agent := start(t, acp.Options{}) id := agent.handshake() waitFor(t, "ready", session.Ready) agent.update(id, `{"sessionUpdate":"something_invented_next_year","whatever":1}`) agent.update(id, `{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"still here"}}`) waitFor(t, "the message after it", func() bool { return textOf(session.Entries(), acp.EntryAgent) == "still here" }) if session.Unknown() != 1 { t.Errorf("Unknown() = %d, want 1", session.Unknown()) } if err := session.Err(); err != nil { t.Errorf("an unknown update ended the session: %v", err) } } func TestUsageAndCommandsAreKeptWithoutBeingDrawn(t *testing.T) { session, agent := start(t, acp.Options{}) id := agent.handshake() waitFor(t, "ready", session.Ready) agent.update(id, `{"sessionUpdate":"usage_update","used":1617,"size":262144}`) agent.update(id, `{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"squash the history"},{"name":"web","description":"Search the web","input":{"hint":"query to search for"}}]}`) waitFor(t, "the usage", func() bool { used, size := session.Usage() return used == 1617 && size == 262144 }) waitFor(t, "the commands", func() bool { return len(session.Commands()) == 2 }) // The shape is the protocol's own: input is present exactly when the // command wants something after its name, and carries the hint. commands := session.Commands() if commands[0].TakesInput() || commands[0].Hint() != "" { t.Errorf("compact takes input: %+v", commands[0]) } if !commands[1].TakesInput() || commands[1].Hint() != "query to search for" { t.Errorf("web lost its hint: %+v", commands[1]) } if !session.EmbedsContext() { t.Error("EmbedsContext() = false after a handshake that declared embeddedContext") } if session.Unknown() != 0 { t.Errorf("Unknown() = %d; these two are understood", session.Unknown()) } for _, entry := range session.Entries() { if strings.Contains(entry.Text, "262144") { t.Errorf("the usage was drawn into the conversation: %q", entry.Text) } } } func TestCancellingATurnNotifiesTheAgent(t *testing.T) { session, agent := start(t, acp.Options{}) id := agent.handshake() waitFor(t, "ready", session.Ready) session.Prompt("something long") agent.read() // the prompt waitFor(t, "the turn to start", session.Running) session.Cancel() cancel := agent.read() if cancel["method"] != acp.MethodCancel { t.Fatalf("the client sent %v, want session/cancel", cancel["method"]) } if _, carries := cancel["id"]; carries { t.Error("session/cancel was sent as a request; it is a notification") } params, _ := cancel["params"].(map[string]any) if params["sessionId"] != id { t.Errorf("the cancellation quotes session %v", params["sessionId"]) } } func TestCancellingWithNoTurnRunningSendsNothing(t *testing.T) { session, agent := start(t, acp.Options{}) agent.handshake() waitFor(t, "ready", session.Ready) session.Cancel() // Nothing to read means nothing was sent; a stray cancel would confuse an // agent that is between turns. if got := agent.methods(); len(got) != 2 { t.Errorf("the client has sent %v, want only the handshake", got) } } func TestATurnThatEndsUnusuallySaysSo(t *testing.T) { session, agent := start(t, acp.Options{}) agent.handshake() waitFor(t, "ready", session.Ready) session.Prompt("go on") prompt := agent.read() agent.reply(prompt["id"], `{"stopReason":"max_tokens"}`) waitFor(t, "the notice", func() bool { return strings.Contains(textOf(session.Entries(), acp.EntryNotice), "max_tokens") }) } func TestAnOrdinaryEndOfTurnSaysNothing(t *testing.T) { // A notice after every successful turn would be noise in every // conversation. session, agent := start(t, acp.Options{}) agent.handshake() waitFor(t, "ready", session.Ready) session.Prompt("go on") prompt := agent.read() agent.reply(prompt["id"], `{"stopReason":"end_turn"}`) waitFor(t, "the turn to finish", func() bool { return !session.Running() }) if notice := textOf(session.Entries(), acp.EntryNotice); notice != "" { t.Errorf("an ordinary turn left %q in the window", notice) } } func TestAPromptIsRecordedBeforeItIsSent(t *testing.T) { // The window has to show what you typed the moment you press Enter, not // when the agent gets round to answering. session, agent := start(t, acp.Options{}) agent.handshake() waitFor(t, "ready", session.Ready) session.Prompt("what does this do?") if got := textOf(session.Entries(), acp.EntryUser); !strings.Contains(got, "what does this do?") { t.Errorf("the window says %q straight after Prompt", got) } } func TestAnEmptyPromptDoesNothing(t *testing.T) { session, agent := start(t, acp.Options{}) agent.handshake() waitFor(t, "ready", session.Ready) session.Prompt("") if len(session.Entries()) != 0 { t.Errorf("an empty prompt left %v in the window", session.Entries()) } } func TestClosingASessionTwiceIsHarmless(t *testing.T) { session, agent := start(t, acp.Options{}) agent.handshake() if err := session.Close(); err != nil && !errors.Is(err, acp.ErrMessageTooLarge) { t.Fatalf("Close() error = %v", err) } if err := session.Close(); err != nil { t.Errorf("the second Close() error = %v", err) } } // toolEntry returns the tool entry with an id, or nil. func toolEntry(session *acp.Session, id string) *acp.Entry { for _, entry := range session.Entries() { if entry.Kind == acp.EntryTool && entry.ToolCallID == id { found := entry return &found } } return nil } // toolEntries counts the tool calls in a conversation. func toolEntries(session *acp.Session) int { count := 0 for _, entry := range session.Entries() { if entry.Kind == acp.EntryTool { count++ } } return count }