| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "strings" |
| 6 | "sync" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "reasonix/internal/agent" |
| 11 | "reasonix/internal/event" |
| 12 | ) |
| 13 | |
| 14 | func TestIsNonTurnHTTPInput(t *testing.T) { |
| 15 | for _, tc := range []struct { |
| 16 | input string |
| 17 | want bool |
| 18 | }{ |
| 19 | {"", true}, // empty |
| 20 | {" ", true}, // blank |
| 21 | {"# note text", true}, // memory quick-add (# + space) |
| 22 | {"/remember MiMo", true}, // remember command note |
| 23 | {"/compact", true}, // slash command |
| 24 | {"/model qwen3", true}, // management verb |
| 25 | {"/new", true}, // slash command |
| 26 | {"!ls", true}, // shell commands rejected by submitHTTP (403) before any turn |
| 27 | {"hello", false}, // ordinary turn |
| 28 | {"explain this code", false}, |
| 29 | } { |
| 30 | if got := isNonTurnHTTPInput(tc.input); got != tc.want { |
| 31 | t.Errorf("isNonTurnHTTPInput(%q) = %v, want %v", tc.input, got, tc.want) |
| 32 | } |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | type observedTurnFormat struct { |
| 37 | input string |
| 38 | format string |
| 39 | } |
| 40 | |
| 41 | type formatRecordingRunner struct { |
| 42 | observed chan<- observedTurnFormat |
| 43 | } |
| 44 | |
| 45 | func (r formatRecordingRunner) Run(ctx context.Context, input string) error { |
| 46 | format := "" |
| 47 | if responseFormat := agent.ResponseFormatFromRequest(ctx); responseFormat != nil { |
| 48 | format = responseFormat.Type |
| 49 | } |
| 50 | r.observed <- observedTurnFormat{input: input, format: format} |
| 51 | return nil |
| 52 | } |
| 53 | |
| 54 | type formatTurnDoneGate struct { |
| 55 | mu sync.Mutex |
| 56 | turns int |
| 57 | firstEntered chan struct{} |
| 58 | releaseFirst chan struct{} |
| 59 | allDone chan struct{} |
| 60 | } |
| 61 | |
| 62 | func (g *formatTurnDoneGate) Emit(e event.Event) { |
| 63 | if e.Kind != event.TurnDone { |
| 64 | return |
| 65 | } |
| 66 | g.mu.Lock() |
| 67 | g.turns++ |
| 68 | turn := g.turns |
| 69 | g.mu.Unlock() |
| 70 | |
| 71 | if turn == 1 { |
| 72 | close(g.firstEntered) |
| 73 | <-g.releaseFirst |
| 74 | } |
| 75 | if turn == 2 { |
| 76 | close(g.allDone) |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | func receiveObservedTurnFormat(t *testing.T, observed <-chan observedTurnFormat) observedTurnFormat { |
| 81 | t.Helper() |
| 82 | select { |
| 83 | case got := <-observed: |
| 84 | return got |
| 85 | case <-time.After(5 * time.Second): |
| 86 | t.Fatal("timed out waiting for submitted turn") |
| 87 | return observedTurnFormat{} |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | func waitForFormatTestSignal(t *testing.T, signal <-chan struct{}, message string) { |
| 92 | t.Helper() |
| 93 | select { |
| 94 | case <-signal: |
| 95 | case <-time.After(5 * time.Second): |
| 96 | t.Fatal(message) |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | // TestSubmitHTTPFormatBindsToTurn holds the first turn's finishing window open, |
| 101 | // submits a second turn with a different format, and proves the parked closure |
| 102 | // preserves each accepted turn's format. This deterministically exercises the |
| 103 | // interleaving that a controller-global one-shot slot could cross-wire. |
| 104 | func TestSubmitHTTPFormatBindsToTurn(t *testing.T) { |
| 105 | observed := make(chan observedTurnFormat, 2) |
| 106 | gate := &formatTurnDoneGate{ |
| 107 | firstEntered: make(chan struct{}), |
| 108 | releaseFirst: make(chan struct{}), |
| 109 | allDone: make(chan struct{}), |
| 110 | } |
| 111 | c := New(Options{Runner: formatRecordingRunner{observed: observed}, Sink: gate}) |
| 112 | |
| 113 | c.SubmitHTTPFormat("first turn", "format-a") |
| 114 | first := receiveObservedTurnFormat(t, observed) |
| 115 | waitForFormatTestSignal(t, gate.firstEntered, "first turn did not enter the finishing window") |
| 116 | |
| 117 | c.SubmitHTTPFormat("second turn", "format-b") |
| 118 | close(gate.releaseFirst) |
| 119 | second := receiveObservedTurnFormat(t, observed) |
| 120 | waitForFormatTestSignal(t, gate.allDone, "second turn did not finish") |
| 121 | |
| 122 | if !strings.Contains(first.input, "first turn") || first.format != "format-a" { |
| 123 | t.Fatalf("first turn = %+v, want first input with format-a", first) |
| 124 | } |
| 125 | if !strings.Contains(second.input, "second turn") || second.format != "format-b" { |
| 126 | t.Fatalf("second turn = %+v, want second input with format-b", second) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | // TestWithTurnFormatInjectsFormatIntoContext:format 绑定 turn 的实际效果 |
| 131 | // ——withTurnFormat 注入后 agent 请求路径能读到(不是全局槽)。 |
| 132 | func TestWithTurnFormatInjectsFormatIntoContext(t *testing.T) { |
| 133 | c := New(Options{}) |
| 134 | ctx := context.Background() |
| 135 | if got := agent.ResponseFormatFromRequest(c.withTurnFormat(ctx, "")); got != nil { |
| 136 | t.Fatalf("empty format must be no-op, got %+v", got) |
| 137 | } |
| 138 | if got := agent.ResponseFormatFromRequest(c.withTurnFormat(ctx, "json_object")); got == nil || got.Type != "json_object" { |
| 139 | t.Fatalf("turn format must reach agent request, got %+v", got) |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | // TestRefTurnFormatBound:@reference turn 同样绑定 format(统一架构—— |
| 144 | // format 是每个被接纳 turn 的属性,非 runGoalLoop 特例)。 |
| 145 | func TestRefTurnFormatBound(t *testing.T) { |
| 146 | c := New(Options{}) |
| 147 | ctx := context.Background() |
| 148 | // runRefTurnWithFormat 注入后 agent 请求路径读到 json_object |
| 149 | if got := agent.ResponseFormatFromRequest(c.withTurnFormat(ctx, "json_object")); got == nil || got.Type != "json_object" { |
| 150 | t.Fatalf("ref-turn format must bind to ctx, got %+v", got) |
| 151 | } |
| 152 | // isRefTurnInput 识别 @引用 turn(format 经 wrapper 绑定,不再丢弃) |
| 153 | // ref-turn 输入识别(SlashCodeCommentLine 不依赖文件系统) |
| 154 | for _, input := range []string{"// comment line", "//src/main.go:12"} { |
| 155 | if !SlashCodeCommentLine(input) { |
| 156 | t.Errorf("SlashCodeCommentLine(%q) = false, want true", input) |
| 157 | } |
| 158 | } |
| 159 | } |
| 160 |