Files
multica/server/pkg/agent/kiro_test.go
Truffle 6acca84c28 fix(agent): clear stale session id when a resumed ACP session is gone [MUL-3216] (#4015)
* fix(agent): clear stale session id when a resumed ACP session is gone

When an agent's stored ACP session no longer exists on the runtime side,
session/resume still succeeds — hermes echoes the requested sessionId
back — so the failure only surfaces when session/prompt returns JSON-RPC
-32603 "Session not found". The backend then reported Status=failed with
the stale SessionID still set, which kept the daemon's resume-failure
fallback (gated on SessionID == "") from ever firing. The failed task
never updates the stored session, so every future mention on the same
(agent, issue) dispatched against the same dead id, forever (#4010).

handleResponse now returns a structured acpRPCError instead of a flat
string (rendered text unchanged), and the hermes/kimi/kiro prompt-error
paths clear the session id when the error is session-not-found class on
a resumed session. The daemon's existing retry then re-executes with a
fresh session and stores the replacement id, healing the mapping.

* fix(agent): clear stale session id when set_model hits a dead resumed session

With a model override, session/set_model runs before session/prompt,
so a resumed session that is gone on the agent side surfaces there
instead of at the prompt — and the error branch returned the stale
SessionID, so the daemon's fresh-session retry (gated on
SessionID == "") never fired. Apply the same clear-the-id fix in the
set_model error branch of all three backends.

Also relax isACPSessionNotFound to accept -32602: kimi-cli raises
RequestError.invalid_params({"session_id": "Session not found"}) for
every unknown-session path (src/kimi_cli/acp/server.py), so pinning
-32603 made the fix dead code for kimi. The wording gate keeps
unrelated invalid_params errors (e.g. "model not available") on the
preserve-the-id path.

Regression tests for all three backends: resumed session + model
override + set_model failing with each runtime's observed
session-not-found shape must yield status=failed with an empty
SessionID.
2026-06-11 14:54:56 +08:00

442 lines
15 KiB
Go

package agent
import (
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestNewReturnsKiroBackend(t *testing.T) {
t.Parallel()
b, err := New("kiro", Config{ExecutablePath: "/nonexistent/kiro-cli"})
if err != nil {
t.Fatalf("New(kiro) error: %v", err)
}
if _, ok := b.(*kiroBackend); !ok {
t.Fatalf("expected *kiroBackend, got %T", b)
}
}
func TestKiroToolNameFromTitle(t *testing.T) {
t.Parallel()
tests := []struct {
title string
want string
}{
{"Read file: /tmp/foo.go", "read_file"},
{"Write: /tmp/bar.go", "write_file"},
{"Patch: /tmp/x", "edit_file"},
{"Shell: ls -la", "terminal"},
{"Run command: pwd", "terminal"},
{"grep", "search_files"},
{"Glob: *.go", "glob"},
{"Code", "code"},
{"Todo List", "todo_write"},
{"Custom Thing", "custom_thing"},
{"", ""},
}
for _, tt := range tests {
got := kiroToolNameFromTitle(tt.title)
if got != tt.want {
t.Errorf("kiroToolNameFromTitle(%q) = %q, want %q", tt.title, got, tt.want)
}
}
}
func fakeKiroACPScript() string {
return `#!/bin/sh
if [ -n "$KIRO_ARGS_FILE" ]; then
for arg in "$@"; do
printf '%s\n' "$arg" >> "$KIRO_ARGS_FILE"
done
fi
while IFS= read -r line; do
if [ -n "$KIRO_REQUESTS_FILE" ]; then
printf '%s\n' "$line" >> "$KIRO_REQUESTS_FILE"
fi
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p')
case "$line" in
*'"method":"initialize"'*)
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true}}}\n' "$id"
;;
*'"method":"session/new"'*)
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_new","models":{"currentModelId":"auto","availableModels":[{"modelId":"auto","name":"auto"}]}}}\n' "$id"
;;
*'"method":"session/load"'*)
printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"AgentMessageChunk","content":{"type":"text","text":"history should be ignored"}}}}\n'
printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"UsageUpdate","usage":{"inputTokens":1000,"outputTokens":1000,"cachedReadTokens":100}}}}\n'
printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"ToolCall","toolCallId":"tc-current","name":"Shell","status":"pending","parameters":{"command":"echo replay"}}}}\n'
printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id"
;;
*'"method":"session/resume"'*)
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32601,"message":"session/resume should not be used for kiro"}}\n' "$id"
;;
*'"method":"session/set_model"'*)
case "$line" in
*bogus-model*)
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"model not available: bogus-model"}}\n' "$id"
exit 0
;;
*)
printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id"
;;
esac
;;
*'"method":"session/prompt"'*)
case "$line" in
*'"content":'*)
;;
*)
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"session/prompt must send content and prompt"}}\n' "$id"
exit 0
;;
esac
case "$line" in
*'"prompt":'*)
;;
*)
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"session/prompt must send content and prompt"}}\n' "$id"
exit 0
;;
esac
printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"ToolCallUpdate","toolCallId":"tc-current","status":"completed","name":"Shell","parameters":{"command":"echo current"},"output":"current tool output\\n"}}}\n'
printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"AgentMessageChunk","content":{"type":"text","text":"loaded"}}}}\n'
printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn","usage":{"inputTokens":2,"outputTokens":1}}}\n' "$id"
exit 0
;;
esac
done
`
}
func TestKiroBackendSetModelFailureFailsTask(t *testing.T) {
t.Parallel()
fakePath := filepath.Join(t.TempDir(), "kiro-cli")
writeTestExecutable(t, fakePath, []byte(fakeKiroACPScript()))
backend, err := New("kiro", Config{ExecutablePath: fakePath, Logger: slog.Default()})
if err != nil {
t.Fatalf("new kiro backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
Model: "bogus-model",
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result, ok := <-session.Result:
if !ok {
t.Fatal("result channel closed without a value")
}
if result.Status != "failed" {
t.Fatalf("expected status=failed, got %q (error=%q)", result.Status, result.Error)
}
if !strings.Contains(result.Error, `could not switch to model "bogus-model"`) {
t.Errorf("expected error to name the requested model, got %q", result.Error)
}
if !strings.Contains(result.Error, "model not available") {
t.Errorf("expected error to surface upstream message, got %q", result.Error)
}
if result.SessionID != "ses_new" {
t.Errorf("expected session id to be preserved on failure, got %q", result.SessionID)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}
// fakeKiroACPStaleLoadSetModelScript impersonates kiro when a resumed
// session is gone and the caller picked a model: session/load returns
// an empty result (so the requested id is kept), then
// session/set_model rejects the unknown session with kiro's observed
// wording — -32603 with "No session found with id ..." in data.
func fakeKiroACPStaleLoadSetModelScript() string {
return `#!/bin/sh
while IFS= read -r line; do
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p')
case "$line" in
*'"method":"initialize"'*)
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true}}}\n' "$id"
;;
*'"method":"session/load"'*)
printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id"
;;
*'"method":"session/set_model"'*)
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32603,"message":"Internal error","data":"No session found with id ses_stale"}}\n' "$id"
exit 0
;;
esac
done
`
}
// TestKiroBackendClearsSessionIDWhenSetModelSessionNotFound pins the
// set_model sibling of the resumed-session fix: with a model override,
// session/set_model runs before session/prompt, so a dead resumed
// session surfaces there. The Result must carry an empty SessionID so
// the daemon's fresh-session retry (gated on SessionID == "") fires.
func TestKiroBackendClearsSessionIDWhenSetModelSessionNotFound(t *testing.T) {
t.Parallel()
fakePath := filepath.Join(t.TempDir(), "kiro-cli")
writeTestExecutable(t, fakePath, []byte(fakeKiroACPStaleLoadSetModelScript()))
backend, err := New("kiro", Config{ExecutablePath: fakePath, Logger: slog.Default()})
if err != nil {
t.Fatalf("new kiro backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
Timeout: 5 * time.Second,
ResumeSessionID: "ses_stale",
Model: "auto",
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result, ok := <-session.Result:
if !ok {
t.Fatal("result channel closed without a value")
}
if result.Status != "failed" {
t.Fatalf("expected status=failed, got %q (error=%q)", result.Status, result.Error)
}
if !strings.Contains(result.Error, `could not switch to model "auto"`) {
t.Errorf("expected error to name the requested model, got %q", result.Error)
}
if result.SessionID != "" {
t.Errorf("expected empty session id so the daemon's fresh-session retry fires, got %q", result.SessionID)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}
func TestKiroBackendInvokesACPWithTrustAllTools(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
argsFile := filepath.Join(tempDir, "argv.txt")
fakePath := filepath.Join(tempDir, "kiro-cli")
writeTestExecutable(t, fakePath, []byte(fakeKiroACPScript()))
backend, err := New("kiro", Config{
ExecutablePath: fakePath,
Logger: slog.Default(),
Env: map[string]string{"KIRO_ARGS_FILE": argsFile},
})
if err != nil {
t.Fatalf("new kiro backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
Model: "bogus-model",
Timeout: 5 * time.Second,
CustomArgs: []string{"acp", "--trust-tools", "shell", "-a", "--agent", "multica"},
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
<-session.Result
raw, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("read args file: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
wantPrefix := []string{"acp", "--trust-all-tools"}
if len(lines) < len(wantPrefix) {
t.Fatalf("expected at least %d args, got %d: %q", len(wantPrefix), len(lines), lines)
}
for i, want := range wantPrefix {
if lines[i] != want {
t.Fatalf("arg[%d] = %q, want %q (full: %q)", i, lines[i], want, lines)
}
}
for _, blocked := range []string{"--trust-tools", "shell", "-a"} {
for _, got := range lines {
if got == blocked {
t.Errorf("protocol-critical custom arg %q was not filtered: %q", blocked, lines)
}
}
}
if strings.Join(lines, "\n") != strings.Join([]string{"acp", "--trust-all-tools", "--agent", "multica"}, "\n") {
t.Errorf("unexpected argv after filtering: %q", lines)
}
}
func TestKiroBackendUsesSessionLoadForResume(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
requestsFile := filepath.Join(tempDir, "requests.jsonl")
fakePath := filepath.Join(tempDir, "kiro-cli")
writeTestExecutable(t, fakePath, []byte(fakeKiroACPScript()))
backend, err := New("kiro", Config{
ExecutablePath: fakePath,
Logger: slog.Default(),
Env: map[string]string{"KIRO_REQUESTS_FILE": requestsFile},
})
if err != nil {
t.Fatalf("new kiro backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "continue", ExecOptions{
ResumeSessionID: "ses_existing",
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("execute: %v", err)
}
var messages []Message
messagesDone := make(chan struct{})
go func() {
defer close(messagesDone)
for msg := range session.Messages {
messages = append(messages, msg)
}
}()
result := <-session.Result
<-messagesDone
if result.Status != "completed" {
t.Fatalf("expected completed result, got status=%q error=%q", result.Status, result.Error)
}
if result.Output != "loaded" {
t.Fatalf("output = %q, want loaded", result.Output)
}
if usage := result.Usage["unknown"]; usage.InputTokens != 2 || usage.OutputTokens != 1 || usage.CacheReadTokens != 0 {
t.Fatalf("usage = %+v, want input=2 output=1 cache_read=0", usage)
}
if len(messages) != 3 {
t.Fatalf("messages = %+v, want current tool use, tool result, and text only", messages)
}
if messages[0].Type != MessageToolUse {
t.Fatalf("messages[0].Type = %v, want MessageToolUse", messages[0].Type)
}
if messages[0].Tool != "terminal" {
t.Fatalf("messages[0].Tool = %q, want terminal", messages[0].Tool)
}
if command, _ := messages[0].Input["command"].(string); command != "echo current" {
t.Fatalf("messages[0].Input[command] = %q, want echo current", command)
}
if messages[1].Type != MessageToolResult {
t.Fatalf("messages[1].Type = %v, want MessageToolResult", messages[1].Type)
}
if messages[1].Output != "current tool output\n" {
t.Fatalf("messages[1].Output = %q, want current tool output", messages[1].Output)
}
if messages[2].Type != MessageText || messages[2].Content != "loaded" {
t.Fatalf("messages[2] = %+v, want text loaded", messages[2])
}
if result.SessionID != "ses_existing" {
t.Fatalf("session id = %q, want ses_existing", result.SessionID)
}
raw, err := os.ReadFile(requestsFile)
if err != nil {
t.Fatalf("read requests file: %v", err)
}
requests := string(raw)
if !strings.Contains(requests, `"method":"session/load"`) {
t.Fatalf("expected session/load request, got:\n%s", requests)
}
if strings.Contains(requests, `"method":"session/resume"`) {
t.Fatalf("kiro backend must not call session/resume, got:\n%s", requests)
}
if !strings.Contains(requests, `"mcpServers":[]`) {
t.Fatalf("session/load must include mcpServers, got:\n%s", requests)
}
// Kiro docs use content, but Kiro CLI 2.1.1 still requires prompt.
if !strings.Contains(requests, `"content":[`) {
t.Fatalf("session/prompt must send Kiro content field, got:\n%s", requests)
}
if !strings.Contains(requests, `"prompt":[`) {
t.Fatalf("session/prompt must send standard ACP prompt field for Kiro 2.1.1 compatibility, got:\n%s", requests)
}
}
// TestKiroLoadIncludesMcpServersFromConfig pins that the agent's managed
// MCP set actually reaches the wire on session/load — the resume path is
// otherwise indistinguishable from the no-config case, which is how the
// missing-on-resume bug got past the first round of review.
func TestKiroLoadIncludesMcpServersFromConfig(t *testing.T) {
t.Parallel()
recordPath := filepath.Join(t.TempDir(), "frames.jsonl")
fakePath := filepath.Join(t.TempDir(), "kiro-cli")
writeTestExecutable(t, fakePath, []byte(fakeACPRecordingScript(recordPath, "ses_load", `{}`)))
backend, err := New("kiro", Config{ExecutablePath: fakePath, Logger: slog.Default()})
if err != nil {
t.Fatalf("new kiro backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
Timeout: 5 * time.Second,
ResumeSessionID: "ses_load",
McpConfig: json.RawMessage(`{"mcpServers":{"fetch":{"command":"uvx"}}}`),
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case <-session.Result:
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
frame := findRecordedFrame(t, recordPath, "session/load")
params := frame["params"].(map[string]any)
servers, ok := params["mcpServers"].([]any)
if !ok {
t.Fatalf("session/load.mcpServers: got %T, want []any", params["mcpServers"])
}
if len(servers) != 1 || servers[0].(map[string]any)["name"] != "fetch" {
t.Fatalf("session/load.mcpServers: got %v, want one entry named fetch", servers)
}
}