Files
kandev/apps/backend/cmd/mock-agent/script_test.go
T

854 lines
25 KiB
Go

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-system>
Kandev Task ID: task-abc-123
Kandev Session ID: sess-xyz-789
</kandev-system>
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 `<task-notification>` 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-id>task-7</task-id>") ||
!strings.Contains(text, "<event>first event line</event>") {
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)
}
}