package loop import ( "context" "errors" "path/filepath" "strings" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" ) // 本文件是「路标识 → 工具层」这座桥的回归(design/15 §2.5.7、主人 2026-10-05 // 裁决 3):网关按 loop.WithParallelLeg 把并发路标识挂在 ctx 上,工具执行点 // (tools.go 的 invokeOne)把它转成 tool.WithLegID,于是同一文件的第二条路拿到 // tool.ErrFileBusy,并以**回灌给模型的工具结果文本**形式浮出来(显式、可消化)。 // // 单路口径同样钉住:ctx 上没有路标识(普通回合 / 子代理 / 后台思考)时,同文件 // 的两条调用照跑 —— 完全不做互斥,行为逐字不变。 // legFileProbeTimeout 是「第二路必须立刻拿到判忙结果」的上界(只用来把阻塞式 // 实现暴露成失败,不参与竞态)。 const legFileProbeTimeout = 5 * time.Second // legFileTool 是实现了 tool.FileTargeter 的 mock 工具:声明本次要写的文件, // 可阻塞执行,并记录它在 Execute 里读到的路标识(用来直接钉住桥的存在)。 type legFileTool struct { name string file string entered chan struct{} hold chan struct{} once sync.Once mu sync.Mutex legs []string } func (m *legFileTool) Name() string { return m.name } func (m *legFileTool) Description() string { return "leg file mock tool" } func (m *legFileTool) Parameters() (map[string]any, error) { return map[string]any{ "type": "object", "properties": map[string]any{"path": map[string]any{"type": "string"}}, "required": []any{"path"}, }, nil } // FileTargets 实现 tool.FileTargeter:本次调用要写 file。 func (m *legFileTool) FileTargets(any) []tool.FileTarget { return []tool.FileTarget{{Op: tool.FileOpWrite, Path: m.file}} } func (m *legFileTool) Execute(ctx context.Context, _ any) (any, error) { m.mu.Lock() m.legs = append(m.legs, tool.LegIDFrom(ctx)) m.mu.Unlock() if m.entered != nil { m.once.Do(func() { close(m.entered) }) } if m.hold != nil { select { case <-m.hold: case <-ctx.Done(): return nil, ctx.Err() } } return "ok", nil } // seenLegs 返回 Execute 里读到的路标识快照。 func (m *legFileTool) seenLegs() []string { m.mu.Lock() defer m.mu.Unlock() return append([]string(nil), m.legs...) } // awaitEntered 等工具进入 Execute(确定性同步,不靠 sleep 抢时序)。 func awaitEntered(t *testing.T, ch <-chan struct{}, msg string) { t.Helper() select { case <-ch: case <-time.After(legFileProbeTimeout): t.Fatal(msg) } } // TestInvokeOne_LegIDReachesTool 直接钉住桥:ctx 上挂了并发路标识 ⇒ 工具在 // Execute 里读到同一个值;没挂(单路)⇒ 读到空串(⇒ 完全不做互斥)。 func TestInvokeOne_LegIDReachesTool(t *testing.T) { t.Parallel() holder := &legFileTool{name: "patch", file: filepath.Join(t.TempDir(), "notes.txt")} d := dispatcherWith(t, holder) l := New(Options{Tools: d}) call := agent.ToolCall{Name: "patch", Args: map[string]any{"path": "notes.txt"}} if out := l.invokeOne(WithParallelLeg(context.Background(), "turn-x"), d, call); out.Err != nil { t.Fatalf("并行路调用: %v", out.Err) } if out := l.invokeOne(context.Background(), d, call); out.Err != nil { t.Fatalf("单路调用: %v", out.Err) } if legs := holder.seenLegs(); len(legs) != 2 || legs[0] != "turn-x" || legs[1] != "" { t.Fatalf("工具读到的路标识 = %v, want [turn-x ](空串 = 单路不参与互斥)", legs) } } // TestInvokeOne_ParallelLegSameFileBusySurfacesToModel 钉住端到端的核心行为: // 两路(不同路标识)同文件 ⇒ 第二路**不执行**、**不等待**,拿到 tool.ErrFileBusy, // 且回灌给模型的文本里带中文文件状态(模型据此自己决定稍后重试)。 func TestInvokeOne_ParallelLegSameFileBusySurfacesToModel(t *testing.T) { t.Parallel() file := filepath.Join(t.TempDir(), "notes.txt") holder := &legFileTool{name: "patch-a", file: file, entered: make(chan struct{}), hold: make(chan struct{})} peer := &legFileTool{name: "patch-b", file: file} d := dispatcherWith(t, holder, peer) l := New(Options{Tools: d}) callA := agent.ToolCall{Name: "patch-a", Args: map[string]any{"path": file}} callB := agent.ToolCall{Name: "patch-b", Args: map[string]any{"path": file}} first := make(chan ToolOutcome, 1) go func() { first <- l.invokeOne(WithParallelLeg(context.Background(), "turn-a"), d, callA) }() awaitEntered(t, holder.entered, "第一路没有进入工具执行") done := make(chan ToolOutcome, 1) go func() { done <- l.invokeOne(WithParallelLeg(context.Background(), "turn-b"), d, callB) }() var outB ToolOutcome select { case outB = <-done: case <-time.After(legFileProbeTimeout): t.Fatal("第二路阻塞等待了 —— 口径是不排队死等,判忙即返回") } if !errors.Is(outB.Err, tool.ErrFileBusy) { t.Fatalf("第二路错误 = %v, want tool.ErrFileBusy", outB.Err) } if outB.ErrKind != tool.KindExecution { t.Fatalf("判忙必须归类为执行面失败才能被回灌,得到 %q", outB.ErrKind) } text := toolResultText(callB, outB) for _, want := range []string{"请稍后再试", "正在被另一路写入", "notes.txt"} { if !strings.Contains(text, want) { t.Fatalf("回灌给模型的文本缺少 %q: %s", want, text) } } close(holder.hold) select { case outA := <-first: if outA.Err != nil { t.Fatalf("持锁那一路应正常完成: %v", outA.Err) } case <-time.After(legFileProbeTimeout): t.Fatal("第一路没有结束") } } // TestInvokeOne_SingleLegSameFileRunsConcurrently 钉住兼容硬要求③:**无路标识 // ⇒ 完全不做互斥**(单路 / 既有路径逐字不变)—— 同文件的两条调用同时执行都成功。 func TestInvokeOne_SingleLegSameFileRunsConcurrently(t *testing.T) { t.Parallel() file := filepath.Join(t.TempDir(), "notes.txt") blocker := &legFileTool{name: "patch-a", file: file, entered: make(chan struct{}), hold: make(chan struct{})} peer := &legFileTool{name: "patch-b", file: file} d := dispatcherWith(t, blocker, peer) l := New(Options{Tools: d}) ctx := context.Background() first := make(chan ToolOutcome, 1) go func() { first <- l.invokeOne(ctx, d, agent.ToolCall{Name: "patch-a", Args: map[string]any{"path": file}}) }() awaitEntered(t, blocker.entered, "无路标识的调用没有进入工具执行") if out := l.invokeOne(ctx, d, agent.ToolCall{Name: "patch-b", Args: map[string]any{"path": file}}); out.Err != nil { t.Fatalf("无路标识时完全不做互斥(同文件也应放行),得到 %v", out.Err) } close(blocker.hold) if out := <-first; out.Err != nil { t.Fatalf("无路标识的调用应正常完成: %v", out.Err) } }