From cff69355f757a8057e63ce710cc234d632692d50 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 22 Oct 2026 01:52:12 +0000 Subject: [PATCH] Carry tool-call identity and real outcome in process events Why: F14 source half - exec.ProcessEvent lacked CallID and outcome fields, forcing consumers to pair events by tool name and mark every end ok; cancellations and failures displayed as success downstream. Evidence: loop/adapter tests pass (verify-u11a.json); pairing, denial, cancellation and concurrent-callback cases added. Impact: new omitempty fields only; legacy events unchanged. Refs: feature/agent-stack-audit-remediation-b2 U11a --- adapters/native/backend.go | 3 + adapters/native/event_fidelity_test.go | 111 ++++++++++ exec/backend.go | 8 + exec/process_event_fidelity_test.go | 47 ++++ runtime/loop.go | 56 ++++- runtime/loop_callid_test.go | 3 + runtime/loop_event_fidelity_test.go | 286 +++++++++++++++++++++++++ 7 files changed, 506 insertions(+), 8 deletions(-) create mode 100644 adapters/native/event_fidelity_test.go create mode 100644 exec/process_event_fidelity_test.go create mode 100644 runtime/loop_event_fidelity_test.go diff --git a/adapters/native/backend.go b/adapters/native/backend.go index ca4f573..1a2122f 100644 --- a/adapters/native/backend.go +++ b/adapters/native/backend.go @@ -475,6 +475,9 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget, Text: ev.Text, Tool: ev.Tool, Detail: ev.Detail, + CallID: ev.CallID, + Status: ev.Status, + ErrorReason: ev.ErrorReason, PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens, LastPromptTokens: ev.LastPromptTokens, diff --git a/adapters/native/event_fidelity_test.go b/adapters/native/event_fidelity_test.go new file mode 100644 index 0000000..9f25238 --- /dev/null +++ b/adapters/native/event_fidelity_test.go @@ -0,0 +1,111 @@ +package native + +import ( + "context" + "encoding/json" + "testing" + + "agent-native/exec" + "agent-native/runtime/llm" + "agent-native/runtime/tools" +) + +type fidelityEcho struct { + fail bool +} + +func (f fidelityEcho) Name() string { return "echo" } +func (f fidelityEcho) Description() string { return "echo" } +func (f fidelityEcho) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) } +func (f fidelityEcho) Execute(context.Context, json.RawMessage) (tools.Result, error) { + if f.fail { + return tools.Result{Content: "native-boom", IsError: true}, nil + } + return tools.Result{Content: "ok"}, nil +} + +func TestNativeProcessEventPassesCallIDAndStatus(t *testing.T) { + b := New(Options{ + ModelOptions: ModelOptions{ + Model: "fake", + Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{ + {ID: "n-a", Name: "echo", Arguments: `{}`}, + {ID: "n-b", Name: "echo", Arguments: `{}`}, + }}, + llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}, + ), + }, + ToolOptions: ToolOptions{ + HostTools: []tools.Tool{fidelityEcho{}}, + }, + }) + var starts, ends []exec.ProcessEvent + _, err := b.Execute(context.Background(), exec.Request{ + WorkDir: t.TempDir(), Prompt: "go", Targets: []string{"output.md"}, + OnEvent: func(ev exec.ProcessEvent) { + switch ev.Type { + case "tool_execution_start": + starts = append(starts, ev) + case "tool_execution_end": + ends = append(ends, ev) + } + }, + }) + if err != nil { + t.Fatal(err) + } + if len(starts) != 2 || len(ends) != 2 { + t.Fatalf("starts=%d ends=%d", len(starts), len(ends)) + } + ids := map[string]bool{} + for _, ev := range starts { + if ev.CallID == "" { + t.Fatalf("start missing CallID: %+v", ev) + } + ids[ev.CallID] = true + } + if !ids["n-a"] || !ids["n-b"] { + t.Fatalf("expected distinct CallIDs n-a/n-b, got %v", ids) + } + for _, ev := range ends { + if ev.CallID == "" || !ids[ev.CallID] { + t.Fatalf("end CallID unpaired: %+v", ev) + } + if ev.Status != "ok" { + t.Fatalf("end Status=%q want ok: %+v", ev.Status, ev) + } + } +} + +func TestNativeProcessEventFailingToolStatusError(t *testing.T) { + b := New(Options{ + ModelOptions: ModelOptions{ + Model: "fake", + Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "n-fail", Name: "echo", Arguments: `{}`, + }}}, + llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}, + ), + }, + ToolOptions: ToolOptions{ + HostTools: []tools.Tool{fidelityEcho{fail: true}}, + }, + }) + var end exec.ProcessEvent + _, err := b.Execute(context.Background(), exec.Request{ + WorkDir: t.TempDir(), Prompt: "go", Targets: []string{"output.md"}, + OnEvent: func(ev exec.ProcessEvent) { + if ev.Type == "tool_execution_end" { + end = ev + } + }, + }) + if err != nil { + t.Fatal(err) + } + if end.CallID != "n-fail" || end.Status != "error" || end.ErrorReason != "native-boom" { + t.Fatalf("end = %+v", end) + } +} diff --git a/exec/backend.go b/exec/backend.go index 2bff501..737241c 100644 --- a/exec/backend.go +++ b/exec/backend.go @@ -54,6 +54,14 @@ type ProcessEvent struct { Text string // delta 文本(Type 为 *_delta 时) Tool string // 工具名(tool_* 时) Detail string // 工具参数/结果摘要(tool_execution_* 时) + // CallID 是提供方 tool-call id;在 tool_call_delta / tool_execution_start / + // tool_execution_end 时填写。零值 = 旧行为(消费方仍可按 Tool 名启发式配对)。 + CallID string `json:"call_id,omitempty"` + // Status 在 tool_execution_end 时填写:ok | error | denied | canceled。 + // 空串 = legacy/unknown,不得臆造。 + Status string `json:"status,omitempty"` + // ErrorReason 在 Status 为 error/denied/canceled 时携带真实失败文本。 + ErrorReason string `json:"error_reason,omitempty"` // PromptTokens / CompletionTokens 在 Type=usage 时填写。 PromptTokens int CompletionTokens int diff --git a/exec/process_event_fidelity_test.go b/exec/process_event_fidelity_test.go new file mode 100644 index 0000000..ab5e20e --- /dev/null +++ b/exec/process_event_fidelity_test.go @@ -0,0 +1,47 @@ +package exec + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestProcessEventLegacyOmitempty:零值新字段不出现在 JSON 中,旧消费方 +// 忽略未知字段时看到的遗留载荷与仅填 Type/Tool/Text/Detail 一致。 +func TestProcessEventLegacyOmitempty(t *testing.T) { + legacy := ProcessEvent{Type: "tool_execution_end", Tool: "echo", Text: "ran", Detail: "{}"} + withZeros := ProcessEvent{ + Type: "tool_execution_end", Tool: "echo", Text: "ran", Detail: "{}", + CallID: "", Status: "", ErrorReason: "", + } + a, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + b, err := json.Marshal(withZeros) + if err != nil { + t.Fatal(err) + } + if string(a) != string(b) { + t.Fatalf("zero new fields changed JSON:\n legacy=%s\n zeros=%s", a, b) + } + if strings.Contains(string(a), "call_id") || strings.Contains(string(a), "status") || + strings.Contains(string(a), "error_reason") { + t.Fatalf("omitempty leaked zero fields: %s", a) + } + + populated := ProcessEvent{ + Type: "tool_execution_end", Tool: "echo", Text: "boom", + CallID: "c1", Status: "error", ErrorReason: "boom", + } + raw, err := json.Marshal(populated) + if err != nil { + t.Fatal(err) + } + s := string(raw) + for _, want := range []string{`"call_id":"c1"`, `"status":"error"`, `"error_reason":"boom"`} { + if !strings.Contains(s, want) { + t.Fatalf("missing %s in %s", want, s) + } + } +} diff --git a/runtime/loop.go b/runtime/loop.go index 02925a9..3d58159 100644 --- a/runtime/loop.go +++ b/runtime/loop.go @@ -43,7 +43,13 @@ type Event struct { ToolIndex int // CallID 在 tool_execution_start / tool_execution_end 时携带工具调用的 // ID;tool_call_delta 也携带同一 ID。消费方按 ID 配对而非按顺序。 + // 不得从 ToolIndex 反推;ToolIndex 语义不变。 CallID string + // Status 在 tool_execution_end 时填写:ok | error | denied | canceled。 + // 空串 = legacy/unknown,不得臆造。 + Status string + // ErrorReason 在 Status 为 error/denied/canceled 时携带真实失败文本。 + ErrorReason string // Detail carries tool arguments/results or reasoning deltas for // process-visualization consumers; log writers may ignore it. Detail string @@ -598,6 +604,9 @@ func appendSkippedToolResults(cfg Config, calls []llm.ToolCall, messages []llm.M } // serializeHostCallbacks 让同一次 Run 的宿主回调不会并发进入。 +// 并行工具执行下各 goroutine 各自构造 Event 值(无跨调用共享缓冲); +// 此处再串行化 OnEvent,避免消费方在回调内聚合时字段交错。回调须保持 +// 同步、非阻塞(与 Config.OnEvent 文档一致)。 func serializeHostCallbacks(cfg *Config) { var mu sync.Mutex if cfg.OnEvent != nil { @@ -636,7 +645,9 @@ func executeTools(ctx context.Context, cfg Config, assistant llm.Message) []llm. out := make([]llm.Message, 0, len(assistant.ToolCalls)) for _, call := range assistant.ToolCalls { if err := ctx.Err(); err != nil { - out = append(out, cancelledToolMessage(call, err)) + msg := cancelledToolMessage(call, err) + emitCancelledTool(cfg, call, msg) + out = append(out, msg) continue } out = append(out, runOne(ctx, cfg, call)) @@ -664,7 +675,10 @@ func executeMixed(ctx context.Context, cfg Config, assistant llm.Message) []llm. } for _, i := range serialIdx { if err := ctx.Err(); err != nil { - out[i] = cancelledToolMessage(assistant.ToolCalls[i], err) + call := assistant.ToolCalls[i] + msg := cancelledToolMessage(call, err) + emitCancelledTool(cfg, call, msg) + out[i] = msg continue } out[i] = runOne(ctx, cfg, assistant.ToolCalls[i]) @@ -697,6 +711,22 @@ func cancelledToolMessage(call llm.ToolCall, err error) llm.Message { } } +// emitCancelledTool 发出未执行/中止的 start+end;Status=canceled,不得报 ok。 +func emitCancelledTool(cfg Config, call llm.ToolCall, msg llm.Message) { + emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, CallID: call.ID, Detail: call.Arguments}) + emitToolEnd(cfg, call, msg.Content, "canceled") +} + +// emitToolEnd 投影 tool_execution_end:CallID 取自 call,Status/ErrorReason +// 仅在已知时填写(空 Status = legacy/unknown)。 +func emitToolEnd(cfg Config, call llm.ToolCall, text, status string) { + ev := Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: text, Status: status} + if status == "error" || status == "denied" || status == "canceled" { + ev.ErrorReason = text + } + emit(cfg, ev) +} + func rejectIncompleteToolCall(call llm.ToolCall) string { if strings.TrimSpace(call.Name) == "" { return "工具调用未执行:名称不完整" @@ -790,12 +820,14 @@ func ToolCallFromContext(ctx context.Context) (llm.ToolCall, bool) { func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message { if err := ctx.Err(); err != nil { - return cancelledToolMessage(call, err) + msg := cancelledToolMessage(call, err) + emitCancelledTool(cfg, call, msg) + return msg } if why := rejectIncompleteToolCall(call); why != "" { msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: why, IsError: true} emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, CallID: call.ID, Detail: call.Arguments}) - emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: why}) + emitToolEnd(cfg, call, why, "error") if cfg.AfterTool != nil { cfg.AfterTool(call, msg) } @@ -803,7 +835,7 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message { } emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, CallID: call.ID, Detail: call.Arguments}) if gated, msg, blocked := gateTool(ctx, cfg, call); blocked { - emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: msg.Content}) + emitToolEnd(cfg, call, msg.Content, "denied") noteToolHook(cfg, call, msg) if cfg.AfterTool != nil { cfg.AfterTool(call, msg) @@ -815,7 +847,7 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message { if cfg.BeforeTool != nil { if err := cfg.BeforeTool(call); err != nil { msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: err.Error(), IsError: true} - emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: msg.Content}) + emitToolEnd(cfg, call, msg.Content, "error") noteToolHook(cfg, call, msg) if cfg.AfterTool != nil { cfg.AfterTool(call, msg) @@ -826,7 +858,7 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message { emit(cfg, Event{Type: "tool_execution_update", Tool: call.Name, Detail: call.Arguments}) if err := commitToolIntent(cfg, call); err != nil { msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: err.Error(), IsError: true} - emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: msg.Content}) + emitToolEnd(cfg, call, msg.Content, "error") return msg } endSpan := startSpan(cfg, "execute_tool "+call.Name, map[string]string{ @@ -852,7 +884,15 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message { Content: res.Content, IsError: res.IsError, } - emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: res.Content}) + status := "ok" + switch { + case ctx.Err() != nil: + // 取消优先于工具自报的 ok/error:过程事件不得把中止报成 ok。 + status = "canceled" + case res.IsError: + status = "error" + } + emitToolEnd(cfg, call, res.Content, status) noteToolHook(cfg, call, msg) if cfg.AfterTool != nil { cfg.AfterTool(call, msg) diff --git a/runtime/loop_callid_test.go b/runtime/loop_callid_test.go index cf72ca7..b4cac71 100644 --- a/runtime/loop_callid_test.go +++ b/runtime/loop_callid_test.go @@ -45,5 +45,8 @@ func TestLoopToolEventsCarryCallID(t *testing.T) { if ends[0].CallID != "call-7" || ends[0].Tool != "echo" { t.Fatalf("end = %+v", ends[0]) } + if ends[0].Status != "ok" || ends[0].ErrorReason != "" { + t.Fatalf("successful end must be ok with empty ErrorReason: %+v", ends[0]) + } _ = res } diff --git a/runtime/loop_event_fidelity_test.go b/runtime/loop_event_fidelity_test.go new file mode 100644 index 0000000..9fd7657 --- /dev/null +++ b/runtime/loop_event_fidelity_test.go @@ -0,0 +1,286 @@ +package runtime + +import ( + "context" + "encoding/json" + "strings" + "sync" + "testing" + "time" + + "agent-native/runtime/hooks" + "agent-native/runtime/llm" + "agent-native/runtime/tools" +) + +type failTool struct{ reason string } + +func (f failTool) Name() string { return "echo" } +func (f failTool) Description() string { return "echo that fails" } +func (f failTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) } +func (f failTool) Execute(context.Context, json.RawMessage) (tools.Result, error) { + return tools.Result{Content: f.reason, IsError: true}, nil +} + +// TestLoopSameNameToolsCarryDistinctCallIDs 同名工具连续/并行调用时, +// start/end 的 CallID 必须互异且可按 ID 配对(不得按 Tool 名合并)。 +func TestLoopSameNameToolsCarryDistinctCallIDs(t *testing.T) { + for _, mode := range []string{ExecSequential, ExecParallel} { + t.Run(mode, func(t *testing.T) { + hits := 0 + reg := tools.NewRegistry(t.TempDir()) + reg.Register(hitTool{hits: &hits}) + var starts, ends []Event + var mu sync.Mutex + _, err := Run(context.Background(), Config{ + Model: "fake", + Tools: reg, + ToolExecution: mode, + Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{ + {ID: "call-a", Name: "echo", Arguments: `{}`}, + {ID: "call-b", Name: "echo", Arguments: `{}`}, + }}, + llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}, + ), + OnEvent: func(ev Event) { + mu.Lock() + defer mu.Unlock() + switch ev.Type { + case "tool_execution_start": + starts = append(starts, ev) + case "tool_execution_end": + ends = append(ends, ev) + } + }, + }, llm.Message{Role: llm.RoleUser, Content: "go"}) + if err != nil { + t.Fatal(err) + } + if hits != 2 || len(starts) != 2 || len(ends) != 2 { + t.Fatalf("hits=%d starts=%d ends=%d", hits, len(starts), len(ends)) + } + byStart := map[string]Event{} + for _, ev := range starts { + if ev.CallID == "" || ev.Tool != "echo" { + t.Fatalf("start missing identity: %+v", ev) + } + if _, dup := byStart[ev.CallID]; dup { + t.Fatalf("duplicate start CallID %q", ev.CallID) + } + byStart[ev.CallID] = ev + } + for _, ev := range ends { + if ev.Status != "ok" { + t.Fatalf("end status = %q, want ok: %+v", ev.Status, ev) + } + if _, ok := byStart[ev.CallID]; !ok { + t.Fatalf("end CallID %q has no matching start", ev.CallID) + } + delete(byStart, ev.CallID) + } + if len(byStart) != 0 { + t.Fatalf("unpaired starts: %+v", byStart) + } + if _, ok := map[string]Event{ends[0].CallID: {}, ends[1].CallID: {}}["call-a"]; !ok { + t.Fatalf("missing call-a in ends %+v", ends) + } + if ends[0].CallID == ends[1].CallID { + t.Fatalf("CallIDs must differ, both %q", ends[0].CallID) + } + }) + } +} + +func TestLoopFailingToolEndStatusError(t *testing.T) { + reg := tools.NewRegistry(t.TempDir()) + reg.Register(failTool{reason: "boom-detail"}) + var end Event + _, err := Run(context.Background(), Config{ + Model: "fake", + Tools: reg, + Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c-fail", Name: "echo", Arguments: `{}`, + }}}, + llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}, + ), + OnEvent: func(ev Event) { + if ev.Type == "tool_execution_end" { + end = ev + } + }, + }, llm.Message{Role: llm.RoleUser, Content: "go"}) + if err != nil { + t.Fatal(err) + } + if end.CallID != "c-fail" || end.Status != "error" || end.ErrorReason != "boom-detail" { + t.Fatalf("end = %+v", end) + } +} + +func TestLoopDeniedToolEndStatusDenied(t *testing.T) { + hits := 0 + reg := tools.NewRegistry(t.TempDir()) + reg.Register(hitTool{hits: &hits}) + bus := hooks.New() + bus.On(func(c hooks.Call) hooks.Outcome { + if c.Event == hooks.PreToolUse { + return hooks.Outcome{Decision: hooks.Deny, Reason: "策略拒绝"} + } + return hooks.Outcome{} + }) + var end Event + _, err := Run(context.Background(), Config{ + Model: "fake", + Tools: reg, + Hooks: bus, + Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c-deny", Name: "echo", Arguments: `{}`, + }}}, + llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}, + ), + OnEvent: func(ev Event) { + if ev.Type == "tool_execution_end" { + end = ev + } + }, + }, llm.Message{Role: llm.RoleUser, Content: "go"}) + if err != nil { + t.Fatal(err) + } + if hits != 0 { + t.Fatalf("tool ran %d times", hits) + } + if end.CallID != "c-deny" || end.Status != "denied" || end.ErrorReason != "策略拒绝" { + t.Fatalf("end = %+v", end) + } + if end.Status == "ok" { + t.Fatal("denied must not be reported ok") + } +} + +func TestLoopCanceledToolEndStatusCanceled(t *testing.T) { + bt := &blockingTool{canceled: make(chan struct{})} + reg := tools.NewRegistry(t.TempDir()) + reg.Register(bt) + ctx, cancel := context.WithCancel(context.Background()) + firstCall := true + stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event { + ch := make(chan llm.Event, 4) + go func() { + defer close(ch) + if firstCall { + firstCall = false + ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{ + Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, + ToolCalls: []llm.ToolCall{{ID: "c-cancel", Name: "block_forever", Arguments: `{}`}}, + }} + return + } + select { + case <-ctx.Done(): + ch <- llm.Event{Kind: llm.KindError, Err: ctx.Err(), Message: llm.Message{StopReason: llm.StopAborted}} + case <-time.After(3 * time.Second): + ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}} + } + }() + return ch + } + var ends []Event + var mu sync.Mutex + go func() { + time.Sleep(100 * time.Millisecond) + cancel() + }() + _, err := Run(ctx, Config{ + Model: "fake", + Tools: reg, + Stream: stream, + OnEvent: func(ev Event) { + if ev.Type == "tool_execution_end" { + mu.Lock() + ends = append(ends, ev) + mu.Unlock() + } + }, + }, llm.Message{Role: llm.RoleUser, Content: "block"}) + if err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if len(ends) == 0 { + t.Fatal("expected tool_execution_end") + } + end := ends[0] + if end.CallID != "c-cancel" { + t.Fatalf("CallID = %q", end.CallID) + } + if end.Status != "canceled" { + t.Fatalf("Status = %q, want canceled (not ok); end=%+v", end.Status, end) + } + if end.ErrorReason == "" { + t.Fatalf("ErrorReason empty on canceled: %+v", end) + } +} + +type cancelBatchTool struct { + cancel context.CancelFunc +} + +func (c cancelBatchTool) Name() string { return "cancel_batch" } +func (c cancelBatchTool) Description() string { return "cancels ctx then returns" } +func (c cancelBatchTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) } +func (c cancelBatchTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) { + c.cancel() + return tools.Result{Content: "cancelled-siblings"}, nil +} + +// TestLoopSkippedCancelEmitsCanceledNotOK:串行批内前一个调用取消 ctx 后, +// 后续未执行的调用必须发 Status=canceled,不得报 ok。 +func TestLoopSkippedCancelEmitsCanceledNotOK(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + reg := tools.NewRegistry(t.TempDir()) + reg.Register(cancelBatchTool{cancel: cancel}) + hits := 0 + reg.Register(hitTool{hits: &hits}) + var ends []Event + _, err := Run(ctx, Config{ + Model: "fake", + Tools: reg, + ToolExecution: ExecSequential, + Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{ + {ID: "c-first", Name: "cancel_batch", Arguments: `{}`}, + {ID: "c-skip", Name: "echo", Arguments: `{}`}, + }}, + llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}, + ), + OnEvent: func(ev Event) { + if ev.Type == "tool_execution_end" { + ends = append(ends, ev) + } + }, + }, llm.Message{Role: llm.RoleUser, Content: "go"}) + _ = err + if hits != 0 { + t.Fatalf("echo should not run after cancel, hits=%d", hits) + } + var skip *Event + for i := range ends { + if ends[i].CallID == "c-skip" { + skip = &ends[i] + } + } + if skip == nil { + t.Fatalf("missing end for skipped call: %+v", ends) + } + if skip.Status != "canceled" { + t.Fatalf("skipped end Status=%q want canceled: %+v", skip.Status, skip) + } + if skip.ErrorReason == "" || strings.Contains(skip.Status, "ok") { + t.Fatalf("skipped end malformed: %+v", skip) + } +}