mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 04:56:20 +02:00
* fix(kiro): require positive proof for -32603 completion rescue (MUL-4860, #5509) Addresses the #5511 review (Elon): Must-fix 1 — a missing result is not proof of success. A mid-command crash / internal session failure produces the same 'tool use, no result, -32603' shape as a genuine completion, so the previous 'saw the use but never saw a result' rescue could mark an unfinished task completed. The guard now rescues only on POSITIVE proof: a terminal ToolResult with status=='completed' for a finishing tool (goal_complete / comment-add). Must-fix 2 — the previous sticky booleans only ever wrote true, so a later failed delivery could not override an earlier success, and call ordering was ignored. Completion state is now the status of the MOST RECENT finishing-tool result (keyed per CallID): 'progress completed → final failed → -32603' stays failed; 'first failed → retry completed → -32603' is a real completion. Comment-add is still recognized by its command payload regardless of the tool's display title (the #5509 core ask), so a completed comment-add carrying a non-terminal title is preserved through the close handshake. Tests: result-less running tool stays failed (both goal_complete and comment-add), completed non-terminal-title comment-add is preserved, failed-final-overrides-earlier-success, and completed-retry-after-failure. Co-authored-by: multica-agent <github@multica.ai> * fix(acp): don't drop completed tool_call_update when rawOutput is an object (MUL-4860, #5509) Captured a live kiro-cli 2.12.3 + gpt-5.6-sol ACP trace and found the true root cause: the completed tool_call_update sends rawOutput as an OBJECT ({"items":[{"Json":{...}}]}), but handleToolCallUpdate typed rawOutput/output as Go strings. json.Unmarshal then failed with 'cannot unmarshal object into Go struct field .rawOutput of type string' and the handler returned early — silently dropping the ENTIRE update, status:"completed" included. So no completion signal ever reached the kiro completion-preservation guard, and the durable-but-closed task was marked failed. This also explains the issue's 'shell tool recorded as running': the tool_call title is literally 'Running: <cmd>' (kind execute), which normalizes to 'running', not 'terminal'. Fix: type rawOutput/output as json.RawMessage and render them via a new acpRawText helper that accepts both a JSON string and a structured value. This is a shared ACP-layer fix (hermes/kimi/kiro). Combined with the earlier payload-based comment-add recognition and the positive-proof completion guard, the completed result now flows through and the -32603 close handshake correctly preserves completion. Adds TestKiroBackendPreservesCompletionOnRealGPT56SolFrames (the exact captured wire shape end-to-end) and TestACPRawText. Verified the end-to-end test fails (status=failed) when rawOutput is typed as string and passes after the fix. Co-authored-by: multica-agent <github@multica.ai> * fix(kiro): remove duplicate stale-session branch that faked completion (MUL-4860, #5509) Addresses Elon's 3rd-round review of #5515. The -32603 guard had two consecutive, identically-conditioned else-if branches for a stale resumed session (opts.ResumeSessionID != "" && isACPSessionNotFound(err)). The first wrongly forced finalStatus="completed" and kept the stale SessionID; the second — the correct handler that clears SessionID so the daemon's fresh-session retry fires — was unreachable. So a recovery whose resumed session was gone at session/prompt time was reported as a fake success AND skipped the retry. Remove the bogus first branch, keeping the positive-proof completion branch and the original stale-session (SessionID="") branch. Adds TestKiroBackendClearsSessionIDWhenPromptSessionNotFound (the prompt path; existing coverage only exercised session/set_model). Verified it returns completed+ses_stale on the pre-fix code and failed+empty SessionID after. Also gofmt on the touched files. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
1106 lines
42 KiB
Go
1106 lines
42 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,"cacheReadTokens":7,"cacheWriteTokens":3}}}\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")
|
|
}
|
|
}
|
|
|
|
func TestKiroBackendAttributesUsageToCurrentModel(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{
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
|
|
result := <-session.Result
|
|
if result.Status != "completed" {
|
|
t.Fatalf("expected completed result, got status=%q error=%q", result.Status, result.Error)
|
|
}
|
|
if _, ok := result.Usage["unknown"]; ok {
|
|
t.Fatalf("usage should use Kiro current model, got unknown entry: %+v", result.Usage)
|
|
}
|
|
usage, ok := result.Usage["auto"]
|
|
if !ok {
|
|
t.Fatalf("expected usage under current model auto, got %+v", result.Usage)
|
|
}
|
|
if usage.InputTokens != 2 || usage.OutputTokens != 1 || usage.CacheReadTokens != 7 || usage.CacheWriteTokens != 3 {
|
|
t.Fatalf("usage = %+v, want input=2 output=1 cache_read=7 cache_write=3", usage)
|
|
}
|
|
}
|
|
|
|
func fakeKiroACPGoalCompleteCloseErrorScript(goalStatus string) 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/new"'*)
|
|
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_goal_done"}}\n' "$id"
|
|
;;
|
|
*'"method":"session/prompt"'*)
|
|
printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_goal_done","update":{"type":"ToolCall","toolCallId":"tc-goal","name":"goal_complete","status":"pending","parameters":{}}}}\n'
|
|
printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_goal_done","update":{"type":"ToolCallUpdate","toolCallId":"tc-goal","status":"` + goalStatus + `","name":"goal_complete","output":"ok"}}}\n'
|
|
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32603,"message":"Internal error","data":"Kiro failed to generate a response"}}\n' "$id"
|
|
exit 0
|
|
;;
|
|
esac
|
|
done
|
|
`
|
|
}
|
|
|
|
func TestKiroBackendTreatsGoalCompleteCloseErrorAsCompleted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fakePath := filepath.Join(t.TempDir(), "kiro-cli")
|
|
writeTestExecutable(t, fakePath, []byte(fakeKiroACPGoalCompleteCloseErrorScript("completed")))
|
|
|
|
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,
|
|
})
|
|
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 != "completed" {
|
|
t.Fatalf("expected status=completed after goal_complete close error, got %q (error=%q)", result.Status, result.Error)
|
|
}
|
|
if result.Error != "" {
|
|
t.Fatalf("expected close-handshake error to be suppressed, got %q", result.Error)
|
|
}
|
|
if result.SessionID != "ses_goal_done" {
|
|
t.Fatalf("session id = %q, want ses_goal_done", result.SessionID)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("timeout waiting for result")
|
|
}
|
|
}
|
|
|
|
func TestKiroBackendDoesNotCompleteAfterFailedGoalComplete(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fakePath := filepath.Join(t.TempDir(), "kiro-cli")
|
|
writeTestExecutable(t, fakePath, []byte(fakeKiroACPGoalCompleteCloseErrorScript("failed")))
|
|
|
|
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,
|
|
})
|
|
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 after failed goal_complete, got %q (error=%q)", result.Status, result.Error)
|
|
}
|
|
if !strings.Contains(result.Error, "Kiro failed to generate a response") {
|
|
t.Fatalf("expected original prompt error to be preserved, got %q", result.Error)
|
|
}
|
|
if result.SessionID != "ses_goal_done" {
|
|
t.Fatalf("session id = %q, want ses_goal_done", result.SessionID)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("timeout waiting for result")
|
|
}
|
|
}
|
|
|
|
func fakeKiroACPIssueCommentCloseErrorScript() 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/new"'*)
|
|
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_comment_done"}}\n' "$id"
|
|
;;
|
|
*'"method":"session/prompt"'*)
|
|
printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_comment_done","update":{"type":"ToolCall","toolCallId":"tc-comment","name":"Shell","status":"pending","parameters":{"command":"multica issue comment add issue-1 --content-file ./reply.md"}}}}\n'
|
|
printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_comment_done","update":{"type":"ToolCallUpdate","toolCallId":"tc-comment","status":"completed","name":"Shell","output":"created comment"}}}\n'
|
|
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32603,"message":"Internal error","data":"Kiro failed to generate a response"}}\n' "$id"
|
|
exit 0
|
|
;;
|
|
esac
|
|
done
|
|
`
|
|
}
|
|
|
|
func TestKiroBackendTreatsCommentAddCloseErrorAsCompleted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fakePath := filepath.Join(t.TempDir(), "kiro-cli")
|
|
writeTestExecutable(t, fakePath, []byte(fakeKiroACPIssueCommentCloseErrorScript()))
|
|
|
|
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,
|
|
})
|
|
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)
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case result, ok := <-session.Result:
|
|
<-messagesDone
|
|
if !ok {
|
|
t.Fatal("result channel closed without a value")
|
|
}
|
|
if result.Status != "completed" {
|
|
t.Fatalf("expected status=completed after issue comment close error, got %q (error=%q, messages=%+v)", result.Status, result.Error, messages)
|
|
}
|
|
if result.Error != "" {
|
|
t.Fatalf("expected close-handshake error to be suppressed, got %q", result.Error)
|
|
}
|
|
if result.SessionID != "ses_comment_done" {
|
|
t.Fatalf("session id = %q, want ses_comment_done", result.SessionID)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("timeout waiting for result")
|
|
}
|
|
}
|
|
|
|
func TestKiroIssueCommentAddCommand(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
command string
|
|
want bool
|
|
}{
|
|
{"multica issue comment add issue-1 --content-file ./reply.md", true},
|
|
{"./multica issue comment add issue-1 --content-file ./reply.md", true},
|
|
{"/usr/local/bin/multica issue comment add issue-1 --content-file ./reply.md", true},
|
|
{"MULTICA_TOKEN=x multica issue comment add issue-1 --content-file ./reply.md", true},
|
|
{"FOO=1 BAR=2 ./multica issue comment add issue-1", true},
|
|
{`sh -c "multica issue comment add issue-1 --content-file ./reply.md"`, true},
|
|
{`bash -c 'multica issue comment add issue-1'`, true},
|
|
{`/bin/sh -c "multica issue comment add issue-1"`, true},
|
|
{"multica issue get issue-1", false},
|
|
{"echo multica issue comment add issue-1", false},
|
|
{`sh -c "echo multica issue comment add issue-1"`, false},
|
|
{"FOO=bar", false},
|
|
{"", false},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := isKiroIssueCommentAddCommand(tt.command); got != tt.want {
|
|
t.Errorf("isKiroIssueCommentAddCommand(%q) = %v, want %v", tt.command, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestKiroIssueCommentAddToolIgnoresToolTitle pins the #5509 decoupling:
|
|
// `multica issue comment add` must be recognized from its command payload
|
|
// regardless of the tool's normalized name. The GPT-5.6 Sol adapter can title
|
|
// the shell tool something that doesn't fold into "terminal", and the old
|
|
// msg.Tool=="terminal" gate silently dropped those.
|
|
func TestKiroIssueCommentAddToolIgnoresToolTitle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
tool string
|
|
want bool
|
|
}{
|
|
{"terminal", "terminal", true},
|
|
{"gpt-style execute", "execute_bash", true},
|
|
{"gpt-style run", "run", true},
|
|
{"empty tool name", "", true},
|
|
{"tool name is irrelevant when command matches", "read_file", true},
|
|
}
|
|
for _, tt := range tests {
|
|
msg := Message{
|
|
Type: MessageToolUse,
|
|
Tool: tt.tool,
|
|
Input: map[string]any{"command": "multica issue comment add issue-1 --content-file ./reply.md"},
|
|
}
|
|
if got := isKiroIssueCommentAddTool(msg); got != tt.want {
|
|
t.Errorf("%s: isKiroIssueCommentAddTool(tool=%q) = %v, want %v", tt.name, tt.tool, got, tt.want)
|
|
}
|
|
}
|
|
|
|
// A non-comment-add command must still be rejected regardless of title.
|
|
notComment := Message{Type: MessageToolUse, Tool: "terminal", Input: map[string]any{"command": "ls -la"}}
|
|
if isKiroIssueCommentAddTool(notComment) {
|
|
t.Errorf("isKiroIssueCommentAddTool should reject a non-comment-add command")
|
|
}
|
|
}
|
|
|
|
// runKiroCloseErrorScript executes a fake-kiro script that ends in the -32603
|
|
// close handshake and returns the final Result.
|
|
func runKiroCloseErrorScript(t *testing.T, script string) Result {
|
|
t.Helper()
|
|
fakePath := filepath.Join(t.TempDir(), "kiro-cli")
|
|
writeTestExecutable(t, fakePath, []byte(script))
|
|
|
|
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})
|
|
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")
|
|
}
|
|
return result
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("timeout waiting for result")
|
|
return Result{}
|
|
}
|
|
}
|
|
|
|
// closeErrorFrame is the -32603 "failed to generate a response" close
|
|
// handshake Kiro raises after the task has already finished its work.
|
|
const closeErrorFrame = `printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32603,"message":"Internal error","data":"Kiro failed to generate a response"}}\n' "$id"`
|
|
|
|
// fakeKiroACPRunningToolCloseErrorScript reproduces the GPT-5.6 Sol shape from
|
|
// #5509: the finishing tool is invoked (ToolCall notification) but the adapter
|
|
// never emits a completed/failed ToolCallUpdate — the tool stays parked at
|
|
// "running", so no ToolResult is ever produced. Kiro then raises the -32603
|
|
// close handshake. `toolName` picks a title that does NOT normalize to
|
|
// "terminal"; `command` selects goal_complete (empty) vs comment-add.
|
|
func fakeKiroACPRunningToolCloseErrorScript(toolName, command string) string {
|
|
params := `{}`
|
|
if command != "" {
|
|
params = `{"command":"` + command + `"}`
|
|
}
|
|
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/new"'*)
|
|
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_running"}}\n' "$id"
|
|
;;
|
|
*'"method":"session/prompt"'*)
|
|
printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_running","update":{"type":"ToolCall","toolCallId":"tc-running","name":"` + toolName + `","status":"pending","parameters":` + params + `}}}\n'
|
|
` + closeErrorFrame + `
|
|
exit 0
|
|
;;
|
|
esac
|
|
done
|
|
`
|
|
}
|
|
|
|
// TestKiroBackendDoesNotCompleteRunningCommentAddWithoutResult is the core
|
|
// safety property from the #5511 review (Must-fix #1): a finishing tool that
|
|
// was only ever seen invoked — no terminal ToolResult — is NOT proof the work
|
|
// succeeded (a mid-command crash produces the same shape). It must stay failed.
|
|
func TestKiroBackendDoesNotCompleteRunningCommentAddWithoutResult(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
result := runKiroCloseErrorScript(t, fakeKiroACPRunningToolCloseErrorScript(
|
|
"execute_bash",
|
|
"multica issue comment add issue-1 --content-file ./reply.md",
|
|
))
|
|
if result.Status != "failed" {
|
|
t.Fatalf("expected status=failed for a result-less running comment-add, got %q (error=%q)", result.Status, result.Error)
|
|
}
|
|
if !strings.Contains(result.Error, "Kiro failed to generate a response") {
|
|
t.Fatalf("expected original prompt error to be preserved, got %q", result.Error)
|
|
}
|
|
}
|
|
|
|
// TestKiroBackendDoesNotCompleteRunningGoalCompleteWithoutResult is the
|
|
// goal_complete sibling of the result-less safety property.
|
|
func TestKiroBackendDoesNotCompleteRunningGoalCompleteWithoutResult(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
result := runKiroCloseErrorScript(t, fakeKiroACPRunningToolCloseErrorScript("goal_complete", ""))
|
|
if result.Status != "failed" {
|
|
t.Fatalf("expected status=failed for a result-less running goal_complete, got %q (error=%q)", result.Status, result.Error)
|
|
}
|
|
}
|
|
|
|
// fakeKiroACPCompletedToolCloseErrorScript is the positive-proof path: the
|
|
// finishing tool emits a completed ToolCallUpdate (the real completion signal)
|
|
// before the -32603 close handshake. `toolName` deliberately uses a title that
|
|
// does NOT normalize to "terminal" to prove recognition is title-independent.
|
|
func fakeKiroACPCompletedToolCloseErrorScript(toolName, command string) string {
|
|
params := `{}`
|
|
if command != "" {
|
|
params = `{"command":"` + command + `"}`
|
|
}
|
|
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/new"'*)
|
|
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_completed"}}\n' "$id"
|
|
;;
|
|
*'"method":"session/prompt"'*)
|
|
printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_completed","update":{"type":"ToolCall","toolCallId":"tc-done","name":"` + toolName + `","status":"pending","parameters":` + params + `}}}\n'
|
|
printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_completed","update":{"type":"ToolCallUpdate","toolCallId":"tc-done","status":"completed","name":"` + toolName + `","output":"ok"}}}\n'
|
|
` + closeErrorFrame + `
|
|
exit 0
|
|
;;
|
|
esac
|
|
done
|
|
`
|
|
}
|
|
|
|
// TestKiroBackendTreatsCompletedCommentAddWithNonTerminalTitleAsCompleted is
|
|
// the #5509 fix proper: a comment-add whose shell tool is titled with a
|
|
// non-terminal name but DID complete (positive proof) must be preserved as
|
|
// completed through the -32603 close handshake.
|
|
func TestKiroBackendTreatsCompletedCommentAddWithNonTerminalTitleAsCompleted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
result := runKiroCloseErrorScript(t, fakeKiroACPCompletedToolCloseErrorScript(
|
|
"execute_bash",
|
|
"multica issue comment add issue-1 --content-file ./reply.md",
|
|
))
|
|
if result.Status != "completed" {
|
|
t.Fatalf("expected status=completed for a completed comment-add + close error, got %q (error=%q)", result.Status, result.Error)
|
|
}
|
|
if result.Error != "" {
|
|
t.Fatalf("expected close-handshake error to be suppressed, got %q", result.Error)
|
|
}
|
|
}
|
|
|
|
// fakeKiroACPRealGPT56SolCloseErrorScript reproduces the EXACT ACP frame shape
|
|
// captured from a live kiro-cli 2.12.3 + gpt-5.6-sol session (see #5509 /
|
|
// MUL-4860). Three things about this shape broke the older guards:
|
|
// - the shell tool_call title is "Running: <cmd>" with kind "execute", which
|
|
// normalizes to "running" — NOT "terminal";
|
|
// - the command lives in rawInput.command alongside __tool_use_purpose;
|
|
// - the completed tool_call_update sends rawOutput as an OBJECT
|
|
// ({"items":[{"Json":{...}}]}), which — when rawOutput was typed as a Go
|
|
// string — made json.Unmarshal fail and drop the whole update, status and
|
|
// all, so no completion signal ever reached the guard.
|
|
//
|
|
// Frames use the real wire form: method "session/update" + "sessionUpdate".
|
|
func fakeKiroACPRealGPT56SolCloseErrorScript(command string) 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/new"'*)
|
|
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_real","models":{"currentModelId":"gpt-5.6-sol"}}}\n' "$id"
|
|
;;
|
|
*'"method":"session/prompt"'*)
|
|
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_real","update":{"sessionUpdate":"tool_call","toolCallId":"call_x","title":"Running: ` + command + `","kind":"execute","rawInput":{"command":"` + command + `","__tool_use_purpose":"deliver the final result"},"_meta":{"kiro":{"toolName":"shell"}}}}}\n'
|
|
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_real","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_x","content":[{"type":"content","content":{"type":"text","text":"created comment\\n"}}]}}}\n'
|
|
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_real","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_x","kind":"execute","status":"completed","title":"Running: ` + command + `","rawInput":{"command":"` + command + `"},"rawOutput":{"items":[{"Json":{"exit_status":"exit status: 0","stdout":"created comment\\n","stderr":""}}]}}}}\n'
|
|
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_real","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}\n'
|
|
` + closeErrorFrame + `
|
|
exit 0
|
|
;;
|
|
esac
|
|
done
|
|
`
|
|
}
|
|
|
|
// TestKiroBackendPreservesCompletionOnRealGPT56SolFrames is the end-to-end
|
|
// #5509 regression against the real captured wire shape: a `multica issue
|
|
// comment add` run through GPT-5.6 Sol's shell tool (title "Running: ...",
|
|
// object rawOutput) that completes, then hits the -32603 close handshake, must
|
|
// stay completed. This fails without BOTH the object-rawOutput parse fix in
|
|
// hermes.go and the payload-based comment-add recognition in kiro.go.
|
|
func TestKiroBackendPreservesCompletionOnRealGPT56SolFrames(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
result := runKiroCloseErrorScript(t, fakeKiroACPRealGPT56SolCloseErrorScript(
|
|
"multica issue comment add issue-1 --content-file ./reply.md",
|
|
))
|
|
if result.Status != "completed" {
|
|
t.Fatalf("expected status=completed for the real GPT-5.6 Sol frame shape, got %q (error=%q)", result.Status, result.Error)
|
|
}
|
|
if result.Error != "" {
|
|
t.Fatalf("expected close-handshake error to be suppressed, got %q", result.Error)
|
|
}
|
|
}
|
|
|
|
// fakeKiroACPTwoCommentCloseErrorScript emits two comment-add tool calls with
|
|
// distinct CallIDs and configurable terminal statuses (in order), then the
|
|
// -32603 close handshake. This exercises ordering: only the most recent
|
|
// finishing-tool result should decide completion.
|
|
func fakeKiroACPTwoCommentCloseErrorScript(firstStatus, secondStatus string) 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/new"'*)
|
|
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_two"}}\n' "$id"
|
|
;;
|
|
*'"method":"session/prompt"'*)
|
|
printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_two","update":{"type":"ToolCall","toolCallId":"tc-1","name":"terminal","status":"pending","parameters":{"command":"multica issue comment add issue-1 --content-file ./progress.md"}}}}\n'
|
|
printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_two","update":{"type":"ToolCallUpdate","toolCallId":"tc-1","status":"` + firstStatus + `","name":"terminal","output":"first"}}}\n'
|
|
printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_two","update":{"type":"ToolCall","toolCallId":"tc-2","name":"terminal","status":"pending","parameters":{"command":"multica issue comment add issue-1 --content-file ./final.md"}}}}\n'
|
|
printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_two","update":{"type":"ToolCallUpdate","toolCallId":"tc-2","status":"` + secondStatus + `","name":"terminal","output":"second"}}}\n'
|
|
` + closeErrorFrame + `
|
|
exit 0
|
|
;;
|
|
esac
|
|
done
|
|
`
|
|
}
|
|
|
|
// TestKiroBackendFailedFinalCommentOverridesEarlierSuccess is the #5511 review
|
|
// Must-fix #2: "progress comment completed → final comment failed → -32603"
|
|
// must stay failed. An earlier success must not mask a later failure.
|
|
func TestKiroBackendFailedFinalCommentOverridesEarlierSuccess(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
result := runKiroCloseErrorScript(t, fakeKiroACPTwoCommentCloseErrorScript("completed", "failed"))
|
|
if result.Status != "failed" {
|
|
t.Fatalf("expected status=failed when the final comment failed after an earlier success, got %q (error=%q)", result.Status, result.Error)
|
|
}
|
|
if !strings.Contains(result.Error, "Kiro failed to generate a response") {
|
|
t.Fatalf("expected original prompt error to be preserved, got %q", result.Error)
|
|
}
|
|
}
|
|
|
|
// TestKiroBackendCompletedRetryAfterEarlierFailureIsCompleted is the reverse
|
|
// ordering the review flagged: "first attempt failed → retry completed →
|
|
// -32603" is a real completion and must be preserved.
|
|
func TestKiroBackendCompletedRetryAfterEarlierFailureIsCompleted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
result := runKiroCloseErrorScript(t, fakeKiroACPTwoCommentCloseErrorScript("failed", "completed"))
|
|
if result.Status != "completed" {
|
|
t.Fatalf("expected status=completed when a retry completed after an earlier failure, got %q (error=%q)", result.Status, result.Error)
|
|
}
|
|
if result.Error != "" {
|
|
t.Fatalf("expected close-handshake error to be suppressed, got %q", result.Error)
|
|
}
|
|
}
|
|
|
|
// fakeKiroACPFailedCommentAddCloseErrorScript: the finishing tool emits a
|
|
// failed ToolResult before the close error — a genuine failure that must stay
|
|
// failed.
|
|
func fakeKiroACPFailedCommentAddCloseErrorScript() 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/new"'*)
|
|
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_failed"}}\n' "$id"
|
|
;;
|
|
*'"method":"session/prompt"'*)
|
|
printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_failed","update":{"type":"ToolCall","toolCallId":"tc-fail","name":"execute_bash","status":"pending","parameters":{"command":"multica issue comment add issue-1 --content-file ./reply.md"}}}}\n'
|
|
printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_failed","update":{"type":"ToolCallUpdate","toolCallId":"tc-fail","status":"failed","name":"execute_bash","output":"exit status 1"}}}\n'
|
|
` + closeErrorFrame + `
|
|
exit 0
|
|
;;
|
|
esac
|
|
done
|
|
`
|
|
}
|
|
|
|
// TestKiroBackendDoesNotCompleteAfterFailedCommentAddResult guards the case
|
|
// where the finishing tool explicitly failed: it must stay failed.
|
|
func TestKiroBackendDoesNotCompleteAfterFailedCommentAddResult(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
result := runKiroCloseErrorScript(t, fakeKiroACPFailedCommentAddCloseErrorScript())
|
|
if result.Status != "failed" {
|
|
t.Fatalf("expected status=failed after a failed comment-add result, got %q (error=%q)", result.Status, result.Error)
|
|
}
|
|
if !strings.Contains(result.Error, "Kiro failed to generate a response") {
|
|
t.Fatalf("expected original prompt error to be preserved, got %q", result.Error)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
// fakeKiroACPStalePromptScript impersonates kiro when a resumed session is
|
|
// gone and there is NO model override: session/load returns an empty result
|
|
// (so the requested id is kept), then session/prompt — not set_model — is the
|
|
// call that surfaces the dead session, with kiro's observed -32603 +
|
|
// "No session found with id ..." wording.
|
|
func fakeKiroACPStalePromptScript() 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/prompt"'*)
|
|
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
|
|
`
|
|
}
|
|
|
|
// TestKiroBackendClearsSessionIDWhenPromptSessionNotFound pins the prompt-path
|
|
// sibling of the stale-session fix. Without a model override, session/prompt
|
|
// (not session/set_model) is where a dead resumed session surfaces. The result
|
|
// MUST be failed with an empty SessionID so the daemon's fresh-session retry
|
|
// (gated on SessionID == "") fires. A regression in the -32603 guard once
|
|
// turned this into status="completed" + a preserved stale SessionID, which
|
|
// both faked success and skipped the retry.
|
|
func TestKiroBackendClearsSessionIDWhenPromptSessionNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fakePath := filepath.Join(t.TempDir(), "kiro-cli")
|
|
writeTestExecutable(t, fakePath, []byte(fakeKiroACPStalePromptScript()))
|
|
|
|
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",
|
|
})
|
|
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 for a stale resumed session at prompt time, got %q (error=%q)", result.Status, 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 != 7 || usage.CacheWriteTokens != 3 {
|
|
t.Fatalf("usage = %+v, want input=2 output=1 cache_read=7 cache_write=3", 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)
|
|
}
|
|
}
|