package main import ( "context" "strings" "sync" "testing" acp "github.com/coder/acp-go-sdk" "github.com/mark3labs/mcp-go/mcp" ) func TestIsScriptMode(t *testing.T) { tests := []struct { cmd string want bool }{ {"e2e:message(\"hello\")", true}, {"e2e:thinking(\"thought\")", true}, {"e2e:delay(100)", true}, {"e2e:mcp:kandev:create_task_plan_kandev({})", true}, {"/e2e:simple-message", false}, {"hello world", false}, {"", false}, {"/all", false}, } for _, tt := range tests { t.Run(tt.cmd, func(t *testing.T) { if got := isScriptMode(tt.cmd); got != tt.want { t.Errorf("isScriptMode(%q) = %v, want %v", tt.cmd, got, tt.want) } }) } } func TestExtractStringArg(t *testing.T) { tests := []struct { name string line string prefix string want string }{ {"simple string", `e2e:message("hello world")`, "e2e:message(", "hello world"}, {"escaped quotes", `e2e:message("say \"hi\"")`, "e2e:message(", `say "hi"`}, {"newline escape", `e2e:message("line1\nline2")`, "e2e:message(", "line1\nline2"}, {"tab escape", `e2e:message("col1\tcol2")`, "e2e:message(", "col1\tcol2"}, {"escaped backslash", `e2e:message("path\\file")`, "e2e:message(", `path\file`}, {"thinking command", `e2e:thinking("deep thought")`, "e2e:thinking(", "deep thought"}, {"tool_result command", `e2e:tool_result("success")`, "e2e:tool_result(", "success"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := extractStringArg(tt.line, tt.prefix) if got != tt.want { t.Errorf("extractStringArg(%q, %q) = %q, want %q", tt.line, tt.prefix, got, tt.want) } }) } } func TestExtractIntArg(t *testing.T) { tests := []struct { name string line string prefix string want int }{ {"simple int", "e2e:delay(100)", "e2e:delay(", 100}, {"zero", "e2e:delay(0)", "e2e:delay(", 0}, {"large number", "e2e:delay(5000)", "e2e:delay(", 5000}, {"invalid returns 0", "e2e:delay(abc)", "e2e:delay(", 0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := extractIntArg(tt.line, tt.prefix) if got != tt.want { t.Errorf("extractIntArg(%q, %q) = %d, want %d", tt.line, tt.prefix, got, tt.want) } }) } } func TestExtractParenContent(t *testing.T) { tests := []struct { name string line string prefix string want string }{ {"simple content", `e2e:tool_use("Read", {"path":"/tmp"})`, "e2e:tool_use(", `"Read", {"path":"/tmp"}`}, {"no trailing paren", `e2e:tool_use("Read"`, "e2e:tool_use(", `"Read"`}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := extractParenContent(tt.line, tt.prefix) if got != tt.want { t.Errorf("extractParenContent(%q, %q) = %q, want %q", tt.line, tt.prefix, got, tt.want) } }) } } func TestExtractFirstStringArg(t *testing.T) { tests := []struct { name string input string wantVal string wantRest string }{ {"simple string", `"hello", rest`, "hello", ", rest"}, {"string with escaped quote", `"say \"hi\"", more`, `say "hi"`, ", more"}, {"just a string", `"only"`, "only", ""}, {"empty input", "", "", ""}, {"non-quoted input", "bare", "bare", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { val, rest := extractFirstStringArg(tt.input) if val != tt.wantVal { t.Errorf("extractFirstStringArg(%q) val = %q, want %q", tt.input, val, tt.wantVal) } if rest != tt.wantRest { t.Errorf("extractFirstStringArg(%q) rest = %q, want %q", tt.input, rest, tt.wantRest) } }) } } func TestUnescapeString(t *testing.T) { tests := []struct { name string input string want string }{ {"no escapes", "hello", "hello"}, {"newline", `line1\nline2`, "line1\nline2"}, {"tab", `col1\tcol2`, "col1\tcol2"}, {"escaped quote", `say \"hi\"`, `say "hi"`}, {"escaped backslash", `path\\file`, `path\file`}, {"mixed escapes", `a\\b\nc\"d`, "a\\b\nc\"d"}, {"empty", "", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := unescapeString(tt.input) if got != tt.want { t.Errorf("unescapeString(%q) = %q, want %q", tt.input, got, tt.want) } }) } } func TestSubstituteContextPlaceholders(t *testing.T) { fullPrompt := ` Kandev Task ID: task-abc-123 Kandev Session ID: sess-xyz-789 e2e:mcp:kandev:create_task_plan_kandev({"task_id":"{task_id}"})` t.Run("replaces task_id", func(t *testing.T) { args := map[string]any{toolKeyTaskID: "{task_id}"} substituteContextPlaceholders(args, fullPrompt) if args[toolKeyTaskID] != "task-abc-123" { t.Errorf("task_id = %q, want %q", args[toolKeyTaskID], "task-abc-123") } }) t.Run("replaces session_id", func(t *testing.T) { args := map[string]any{"session_id": "{session_id}"} substituteContextPlaceholders(args, fullPrompt) if args["session_id"] != "sess-xyz-789" { t.Errorf("session_id = %q, want %q", args["session_id"], "sess-xyz-789") } }) t.Run("replaces both in same map", func(t *testing.T) { args := map[string]any{ toolKeyTaskID: "{task_id}", "session_id": "{session_id}", "other": "untouched", } substituteContextPlaceholders(args, fullPrompt) if args[toolKeyTaskID] != "task-abc-123" { t.Errorf("task_id = %q, want %q", args[toolKeyTaskID], "task-abc-123") } if args["session_id"] != "sess-xyz-789" { t.Errorf("session_id = %q, want %q", args["session_id"], "sess-xyz-789") } if args["other"] != "untouched" { t.Errorf("other = %q, want %q", args["other"], "untouched") } }) t.Run("no placeholders means empty substitution", func(t *testing.T) { args := map[string]any{toolKeyTaskID: "{task_id}"} substituteContextPlaceholders(args, "no kandev system block") if args[toolKeyTaskID] != "" { t.Errorf("task_id should be empty when no context, got %q", args[toolKeyTaskID]) } }) t.Run("skips non-string values", func(t *testing.T) { args := map[string]any{ "count": 42, "flag": true, } substituteContextPlaceholders(args, fullPrompt) if args["count"] != 42 { t.Errorf("count changed unexpectedly") } if args["flag"] != true { t.Errorf("flag changed unexpectedly") } }) } func TestExtractRegexMatch(t *testing.T) { tests := []struct { name string input string want string }{ {"task ID found", "Kandev Task ID: abc-123", "abc-123"}, {"task ID with extra whitespace", "Kandev Task ID: def-456", "def-456"}, {"no match", "no task id here", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := extractRegexMatch(taskIDRegex, tt.input) if got != tt.want { t.Errorf("extractRegexMatch(taskIDRegex, %q) = %q, want %q", tt.input, got, tt.want) } }) } } // --- Mock sessionUpdater for testing --- // recordedUpdate stores a captured ACP session notification. type recordedUpdate struct { notification acp.SessionNotification } // mockUpdater implements sessionUpdater for tests, collecting all updates. type mockUpdater struct { mu sync.Mutex updates []recordedUpdate } func (m *mockUpdater) SessionUpdate(_ context.Context, n acp.SessionNotification) error { m.mu.Lock() defer m.mu.Unlock() m.updates = append(m.updates, recordedUpdate{notification: n}) return nil } func (m *mockUpdater) RequestPermission(_ context.Context, _ acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) { return acp.RequestPermissionResponse{ Outcome: acp.RequestPermissionOutcome{ Selected: &acp.RequestPermissionOutcomeSelected{OptionId: "allow"}, }, }, nil } func (m *mockUpdater) getUpdates() []recordedUpdate { m.mu.Lock() defer m.mu.Unlock() return append([]recordedUpdate{}, m.updates...) } func newTestEmitter() (*emitter, *mockUpdater) { mock := &mockUpdater{} e := &emitter{ ctx: context.Background(), conn: mock, sid: "test-session", } return e, mock } // --- Update assertion helpers --- func isTextUpdate(u recordedUpdate) bool { return u.notification.Update.AgentMessageChunk != nil } func isThoughtUpdate(u recordedUpdate) bool { return u.notification.Update.AgentThoughtChunk != nil } func isToolCallUpdate(u recordedUpdate) bool { return u.notification.Update.ToolCall != nil } func isToolCallCompleteUpdate(u recordedUpdate) bool { return u.notification.Update.ToolCallUpdate != nil } func getTextContent(u recordedUpdate) string { if u.notification.Update.AgentMessageChunk == nil { return "" } if u.notification.Update.AgentMessageChunk.Content.Text == nil { return "" } return u.notification.Update.AgentMessageChunk.Content.Text.Text } func getThoughtContent(u recordedUpdate) string { if u.notification.Update.AgentThoughtChunk == nil { return "" } if u.notification.Update.AgentThoughtChunk.Content.Text == nil { return "" } return u.notification.Update.AgentThoughtChunk.Content.Text.Text } func getToolCallTitle(u recordedUpdate) string { if u.notification.Update.ToolCall == nil { return "" } return u.notification.Update.ToolCall.Title } func getToolCallID(u recordedUpdate) acp.ToolCallId { if u.notification.Update.ToolCall != nil { return u.notification.Update.ToolCall.ToolCallId } if u.notification.Update.ToolCallUpdate != nil { return u.notification.Update.ToolCallUpdate.ToolCallId } return "" } // --- Script execution tests --- // TestExecuteScriptMessage verifies e2e:message emits an agent text update. func TestExecuteScriptMessage(t *testing.T) { e, mock := newTestEmitter() resetState() executeScript(e, "", `e2e:message("hello from script")`) updates := mock.getUpdates() if len(updates) != 1 { t.Fatalf("expected 1 update, got %d", len(updates)) } if !isTextUpdate(updates[0]) { t.Fatal("expected text update") } if got := getTextContent(updates[0]); got != "hello from script" { t.Errorf("text = %q, want %q", got, "hello from script") } } // TestExecuteScriptThinking verifies e2e:thinking emits a thought update. func TestExecuteScriptThinking(t *testing.T) { e, mock := newTestEmitter() resetState() executeScript(e, "", `e2e:thinking("deep thought")`) updates := mock.getUpdates() if len(updates) != 1 { t.Fatalf("expected 1 update, got %d", len(updates)) } if !isThoughtUpdate(updates[0]) { t.Fatal("expected thought update") } if got := getThoughtContent(updates[0]); got != "deep thought" { t.Errorf("thought = %q, want %q", got, "deep thought") } } // TestExecuteScriptMultiLine verifies multiple commands are processed sequentially. func TestExecuteScriptMultiLine(t *testing.T) { e, mock := newTestEmitter() resetState() script := "e2e:thinking(\"step 1\")\ne2e:message(\"step 2\")\ne2e:message(\"step 3\")" executeScript(e, "", script) updates := mock.getUpdates() if len(updates) != 3 { t.Fatalf("expected 3 updates, got %d", len(updates)) } if got := getThoughtContent(updates[0]); got != "step 1" { t.Errorf("thought = %q, want %q", got, "step 1") } if got := getTextContent(updates[1]); got != "step 2" { t.Errorf("text = %q, want %q", got, "step 2") } if got := getTextContent(updates[2]); got != "step 3" { t.Errorf("text = %q, want %q", got, "step 3") } } // TestExecuteScriptSkipsEmptyAndComments verifies blank lines and comments are skipped. func TestExecuteScriptSkipsEmptyAndComments(t *testing.T) { e, mock := newTestEmitter() resetState() script := "e2e:message(\"one\")\n\n# this is a comment\n \ne2e:message(\"two\")" executeScript(e, "", script) updates := mock.getUpdates() if len(updates) != 2 { t.Fatalf("expected 2 updates, got %d", len(updates)) } if got := getTextContent(updates[0]); got != "one" { t.Errorf("text = %q, want %q", got, "one") } if got := getTextContent(updates[1]); got != "two" { t.Errorf("text = %q, want %q", got, "two") } } func TestExecuteScriptStopsWhenPromptContextIsCancelled(t *testing.T) { e, mock := newTestEmitter() resetState() ctx, cancel := context.WithCancel(context.Background()) cancel() e.ctx = ctx executeScript(e, "", "e2e:message(\"before cancellation\")") if updates := mock.getUpdates(); len(updates) != 0 { t.Fatalf("expected no updates after prompt cancellation, got %d", len(updates)) } } // TestExecuteScriptToolUse verifies e2e:tool_use emits a tool_call update. func TestExecuteScriptToolUse(t *testing.T) { e, mock := newTestEmitter() resetState() executeScript(e, "", `e2e:tool_use("Read", {"file_path":"/tmp/test.go"})`) updates := mock.getUpdates() if len(updates) != 1 { t.Fatalf("expected 1 update, got %d", len(updates)) } if !isToolCallUpdate(updates[0]) { t.Fatal("expected tool_call update") } if got := getToolCallTitle(updates[0]); got != "Read" { t.Errorf("title = %q, want %q", got, "Read") } } // TestExecuteScriptToolUseAndResult verifies tool_use + tool_result pair. func TestExecuteScriptToolUseAndResult(t *testing.T) { e, mock := newTestEmitter() resetState() script := "e2e:tool_use(\"Read\", {\"file_path\":\"/tmp/f.go\"})\ne2e:tool_result(\"file contents here\")" executeScript(e, "", script) updates := mock.getUpdates() if len(updates) != 2 { t.Fatalf("expected 2 updates, got %d", len(updates)) } // First: tool_call start if !isToolCallUpdate(updates[0]) { t.Fatal("expected tool_call update") } toolID := getToolCallID(updates[0]) if toolID == "" { t.Fatal("tool_call should have an id") } // Second: tool_call_update (completion) referencing same ID if !isToolCallCompleteUpdate(updates[1]) { t.Fatal("expected tool_call_update") } if got := getToolCallID(updates[1]); got != toolID { t.Errorf("tool_call_update id = %q, want %q", got, toolID) } } // TestExecuteScriptShellResult verifies e2e:shell_result emits an // execute-kind tool call whose completion carries the given raw stdout, // exercising the same wire shape a real provider whose captured stdout // echoes the command would produce (see the ACP shell-output normalizer's // leading-echo stripping in shell_output.go). func TestExecuteScriptShellResult(t *testing.T) { e, mock := newTestEmitter() resetState() executeScript(e, "", `e2e:shell_result("cat file.txt", "cat file.txt=== marker ===\n")`) updates := mock.getUpdates() if len(updates) != 2 { t.Fatalf("expected 2 updates, got %d", len(updates)) } if !isToolCallUpdate(updates[0]) { t.Fatal("expected tool_call update") } tc := updates[0].notification.Update.ToolCall if tc.Kind != acp.ToolKindExecute { t.Errorf("kind = %q, want %q", tc.Kind, acp.ToolKindExecute) } input, ok := tc.RawInput.(map[string]any) if !ok || input["command"] != "cat file.txt" { t.Errorf("raw input = %#v, want command %q", tc.RawInput, "cat file.txt") } if !isToolCallCompleteUpdate(updates[1]) { t.Fatal("expected tool_call_update") } tcu := updates[1].notification.Update.ToolCallUpdate output, ok := tcu.RawOutput.(map[string]any) if !ok || output["output"] != "cat file.txt=== marker ===\n" { t.Errorf("raw output = %#v, want the echoed stdout", tcu.RawOutput) } } // TestExecuteScriptShellResultWithCwd verifies the optional third // e2e:shell_result argument populates rawInput["cwd"], reproducing a // provider that resolves a relative file-path argument in the command to // an absolute (cwd-joined) one before actually invoking the real terminal // (see the ACP shell-output normalizer's workDir-aware echo stripping in // shell_output.go). func TestExecuteScriptShellResultWithCwd(t *testing.T) { e, mock := newTestEmitter() resetState() executeScript(e, "", `e2e:shell_result("cat notes.txt", "$ cat /work/notes.txt\nhello\n", "/work")`) updates := mock.getUpdates() if len(updates) != 2 { t.Fatalf("expected 2 updates, got %d", len(updates)) } tc := updates[0].notification.Update.ToolCall input, ok := tc.RawInput.(map[string]any) if !ok || input["command"] != "cat notes.txt" || input["cwd"] != "/work" { t.Errorf("raw input = %#v, want command %q and cwd %q", tc.RawInput, "cat notes.txt", "/work") } tcu := updates[1].notification.Update.ToolCallUpdate output, ok := tcu.RawOutput.(map[string]any) if !ok || output["output"] != "$ cat /work/notes.txt\nhello\n" { t.Errorf("raw output = %#v, want the echoed stdout", tcu.RawOutput) } } // TestExecuteScriptShellResultWithoutCwdOmitsCwdKey confirms the // two-argument form (no cwd) keeps working exactly as before: rawInput // carries no "cwd" key at all. func TestExecuteScriptShellResultWithoutCwdOmitsCwdKey(t *testing.T) { e, mock := newTestEmitter() resetState() executeScript(e, "", `e2e:shell_result("cat file.txt", "cat file.txt=== marker ===\n")`) updates := mock.getUpdates() if len(updates) != 2 { t.Fatalf("expected 2 updates, got %d", len(updates)) } tc := updates[0].notification.Update.ToolCall input, ok := tc.RawInput.(map[string]any) if !ok { t.Fatalf("raw input = %#v, want a map", tc.RawInput) } if _, present := input["cwd"]; present { t.Errorf("raw input = %#v, want no cwd key", tc.RawInput) } } // TestExecuteScriptToolUseNoInput verifies tool_use with no input arg. func TestExecuteScriptToolUseNoInput(t *testing.T) { e, mock := newTestEmitter() resetState() executeScript(e, "", `e2e:tool_use("Glob")`) updates := mock.getUpdates() if len(updates) != 1 { t.Fatalf("expected 1 update, got %d", len(updates)) } if got := getToolCallTitle(updates[0]); got != "Glob" { t.Errorf("title = %q, want %q", got, "Glob") } } func TestParseMCPConfigFromArgs(t *testing.T) { tests := []struct { name string args []string want string }{ {"no flag", []string{"mock-agent"}, ""}, {"separate flag and value", []string{"mock-agent", "--mcp-config", `{"mcpServers":{"kandev":{"url":"http://localhost:10005/sse"}}}`}, `{"mcpServers":{"kandev":{"url":"http://localhost:10005/sse"}}}`}, {"equals syntax", []string{"mock-agent", `--mcp-config={"mcpServers":{}}`}, `{"mcpServers":{}}`}, {"with other flags", []string{"mock-agent", "--model", "fast", "--mcp-config", `{"mcpServers":{}}`}, `{"mcpServers":{}}`}, {"dangling flag", []string{"mock-agent", "--mcp-config"}, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := parseMCPConfigFromArgs(tt.args) if got != tt.want { t.Errorf("parseMCPConfigFromArgs(%v) = %q, want %q", tt.args, got, tt.want) } }) } } // TestExtractMCPResultText verifies the MCP result text extraction. func TestExtractMCPResultText(t *testing.T) { t.Run("single text content", func(t *testing.T) { result := &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{Type: "text", Text: "hello"}, }, } got := extractMCPResultText(result) if got != "hello" { t.Errorf("got %q, want %q", got, "hello") } }) t.Run("multiple text contents joined with newline", func(t *testing.T) { result := &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{Type: "text", Text: "line1"}, mcp.TextContent{Type: "text", Text: "line2"}, }, } got := extractMCPResultText(result) if got != "line1\nline2" { t.Errorf("got %q, want %q", got, "line1\nline2") } }) t.Run("empty content returns empty string", func(t *testing.T) { result := &mcp.CallToolResult{ Content: []mcp.Content{}, } got := extractMCPResultText(result) if got != "" { t.Errorf("got %q, want empty", got) } }) } // TestExecuteScriptToolUseBadJSON verifies that malformed JSON emits a script error. func TestExecuteScriptToolUseBadJSON(t *testing.T) { e, mock := newTestEmitter() resetState() executeScript(e, "", `e2e:tool_use("Read", {bad json})`) updates := mock.getUpdates() if len(updates) != 1 { t.Fatalf("expected 1 update (error), got %d", len(updates)) } if !isTextUpdate(updates[0]) { t.Fatal("expected text update for error") } text := getTextContent(updates[0]) if !strings.Contains(text, "Script error") { t.Errorf("expected script error message, got %q", text) } } // TestExecuteScriptMonitorStart asserts e2e:monitor_start emits the two-frame // claude-acp wire pattern: a pending tool_call followed by a registration // tool_call_update whose rawOutput banner carries the taskID and whose Meta // is tagged with claudeCode.toolName=Monitor. func TestExecuteScriptMonitorStart(t *testing.T) { e, mock := newTestEmitter() resetState() executeScript(e, "", `e2e:monitor_start("task-7", "tail -f /var/log/x")`) updates := mock.getUpdates() if len(updates) != 2 { t.Fatalf("expected 2 updates (start + registration), got %d", len(updates)) } if !isToolCallUpdate(updates[0]) { t.Fatal("first update should be tool_call (pending)") } tc := updates[0].notification.Update.ToolCall if tc.Title != "Monitor" { t.Errorf("title = %q, want Monitor", tc.Title) } if !hasMonitorMeta(tc.Meta) { t.Errorf("first update missing claudeCode.toolName=Monitor meta: %+v", tc.Meta) } if !isToolCallCompleteUpdate(updates[1]) { t.Fatal("second update should be tool_call_update (registration)") } tcu := updates[1].notification.Update.ToolCallUpdate if tcu.Status == nil || string(*tcu.Status) != "completed" { t.Errorf("registration status = %v, want completed", tcu.Status) } out, _ := tcu.RawOutput.(string) if !strings.HasPrefix(out, "Monitor started (task task-7,") { t.Errorf("rawOutput = %q, want banner starting with 'Monitor started (task task-7,'", out) } // taskID must be remembered for subsequent monitor_event / monitor_end. if _, ok := state.monitorTools["task-7"]; !ok { t.Error("monitorTools missing entry for task-7") } } // TestExecuteScriptMonitorEvent asserts e2e:monitor_event emits an // agent_message_chunk whose text matches the `` envelope // pattern the kandev adapter parses. func TestExecuteScriptMonitorEvent(t *testing.T) { e, mock := newTestEmitter() resetState() executeScript(e, "", `e2e:monitor_event("task-7", "first event line")`) updates := mock.getUpdates() if len(updates) != 1 { t.Fatalf("expected 1 update, got %d", len(updates)) } if !isTextUpdate(updates[0]) { t.Fatal("expected text update for monitor event envelope") } text := getTextContent(updates[0]) if !strings.Contains(text, "task-7") || !strings.Contains(text, "first event line") { t.Errorf("envelope text = %q, want it to contain task-id and event tags", text) } } // TestExecuteScriptMonitorEndUsesStoredToolCallID asserts e2e:monitor_end // resolves the taskID via state.monitorTools and emits a terminal // tool_call_update against the same toolCallID the start created. func TestExecuteScriptMonitorEndUsesStoredToolCallID(t *testing.T) { e, mock := newTestEmitter() resetState() executeScript(e, "", "e2e:monitor_start(\"task-7\", \"x\")\n"+ "e2e:monitor_end(\"task-7\")") updates := mock.getUpdates() if len(updates) != 3 { t.Fatalf("expected 3 updates (start, registration, end), got %d", len(updates)) } startTCID := updates[0].notification.Update.ToolCall.ToolCallId endUpd := updates[2].notification.Update.ToolCallUpdate if endUpd == nil { t.Fatal("third update should be tool_call_update (terminal)") } if endUpd.ToolCallId != startTCID { t.Errorf("end ToolCallId = %q, want %q (same as start)", endUpd.ToolCallId, startTCID) } if endUpd.Status == nil || string(*endUpd.Status) != "completed" { t.Errorf("end status = %v, want completed", endUpd.Status) } if _, ok := state.monitorTools["task-7"]; ok { t.Error("monitorTools should drop entry after monitor_end") } } // TestExecuteScriptMonitorEndUnknownTaskIDReportsError asserts e2e:monitor_end // for a taskID that was never started emits a "Script error" text update // (matches the existing handling for malformed directives). func TestExecuteScriptMonitorEndUnknownTaskIDReportsError(t *testing.T) { e, mock := newTestEmitter() resetState() executeScript(e, "", `e2e:monitor_end("does-not-exist")`) updates := mock.getUpdates() if len(updates) != 1 { t.Fatalf("expected 1 update, got %d", len(updates)) } text := getTextContent(updates[0]) if !strings.Contains(text, "Script error") || !strings.Contains(text, "does-not-exist") { t.Errorf("expected script error referencing unknown taskId, got %q", text) } } // hasMonitorMeta returns true if the SDK Meta map carries the // claudeCode.toolName=Monitor marker. func hasMonitorMeta(meta map[string]any) bool { if meta == nil { return false } cc, ok := meta["claudeCode"].(map[string]any) if !ok { return false } name, _ := cc["toolName"].(string) return name == "Monitor" } // --- /bulk command tests --- // TestParseBulkCmd verifies the /bulk command parsing and count defaulting/capping. func TestParseBulkCmd(t *testing.T) { tests := []struct { prompt string wantCount int wantOK bool }{ {"/bulk", bulkDefaultCount, true}, {"/bulk:5", 5, true}, {"/bulk 5", 5, true}, {"/e2e:bulk:42", 42, true}, {"/BULK:7", 7, true}, {"/bulk:99999", bulkMaxCount, true}, // capped {"/bulk:0", bulkDefaultCount, true}, // non-positive falls back to default {"/bulkish", 0, false}, {"hello", 0, false}, {"", 0, false}, } for _, tt := range tests { t.Run(tt.prompt, func(t *testing.T) { count, ok := parseBulkCmd(tt.prompt) if ok != tt.wantOK || count != tt.wantCount { t.Errorf("parseBulkCmd(%q) = (%d, %v), want (%d, %v)", tt.prompt, count, ok, tt.wantCount, tt.wantOK) } }) } } // TestEmitBulk verifies emitBulk emits count+2 text messages (intro + N + outro) // and one tool call per message (each flushing its text into a distinct row). func TestEmitBulk(t *testing.T) { e, mock := newTestEmitter() const count = 4 emitBulk(e, count) var texts, toolCalls int for _, u := range mock.getUpdates() { switch { case isTextUpdate(u): texts++ case isToolCallUpdate(u): toolCalls++ } } if texts != count+2 { t.Errorf("text updates = %d, want %d (intro + %d + outro)", texts, count+2, count) } if toolCalls != count { t.Errorf("tool calls = %d, want %d", toolCalls, count) } }