| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "sync" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "reasonix/internal/agent" |
| 15 | "reasonix/internal/agent/testutil" |
| 16 | "reasonix/internal/event" |
| 17 | "reasonix/internal/extension" |
| 18 | "reasonix/internal/extension/dispatch" |
| 19 | "reasonix/internal/extension/protocol" |
| 20 | "reasonix/internal/provider" |
| 21 | "reasonix/internal/tool" |
| 22 | ) |
| 23 | |
| 24 | // Stage 6b1 control wiring tests. The dispatcher under test is real; only its |
| 25 | // sidecar client is faked, so every assertion exercises the actual dispatch |
| 26 | // ruling logic (chain walk, strict replacement decode, slot ownership). |
| 27 | |
| 28 | type recordedExtCall struct { |
| 29 | event protocol.InterceptEvent |
| 30 | payload json.RawMessage |
| 31 | } |
| 32 | |
| 33 | // fakeExtClient is a scriptable dispatch.Client recording every call. |
| 34 | type fakeExtClient struct { |
| 35 | mu sync.Mutex |
| 36 | interceptFn func(event protocol.InterceptEvent, payload json.RawMessage) (protocol.InterceptResult, error) |
| 37 | intercepts []recordedExtCall |
| 38 | notifies []recordedExtCall |
| 39 | } |
| 40 | |
| 41 | func (f *fakeExtClient) Intercept(_ context.Context, event protocol.InterceptEvent, payload json.RawMessage, _ time.Duration) (protocol.InterceptResult, error) { |
| 42 | f.mu.Lock() |
| 43 | f.intercepts = append(f.intercepts, recordedExtCall{event: event, payload: append(json.RawMessage(nil), payload...)}) |
| 44 | fn := f.interceptFn |
| 45 | f.mu.Unlock() |
| 46 | if fn == nil { |
| 47 | return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil |
| 48 | } |
| 49 | return fn(event, payload) |
| 50 | } |
| 51 | |
| 52 | func (f *fakeExtClient) TryNotifyEvent(event protocol.InterceptEvent, payload json.RawMessage) error { |
| 53 | f.mu.Lock() |
| 54 | defer f.mu.Unlock() |
| 55 | f.notifies = append(f.notifies, recordedExtCall{event: event, payload: append(json.RawMessage(nil), payload...)}) |
| 56 | return nil |
| 57 | } |
| 58 | |
| 59 | func (f *fakeExtClient) notifyEvents() []protocol.InterceptEvent { |
| 60 | f.mu.Lock() |
| 61 | defer f.mu.Unlock() |
| 62 | out := make([]protocol.InterceptEvent, len(f.notifies)) |
| 63 | for i, call := range f.notifies { |
| 64 | out[i] = call.event |
| 65 | } |
| 66 | return out |
| 67 | } |
| 68 | |
| 69 | func (f *fakeExtClient) notifyPayloadsFor(event protocol.InterceptEvent) []json.RawMessage { |
| 70 | f.mu.Lock() |
| 71 | defer f.mu.Unlock() |
| 72 | var out []json.RawMessage |
| 73 | for _, call := range f.notifies { |
| 74 | if call.event == event { |
| 75 | out = append(out, call.payload) |
| 76 | } |
| 77 | } |
| 78 | return out |
| 79 | } |
| 80 | |
| 81 | const extensionTestPlugin = "fake" |
| 82 | |
| 83 | // newExtensionTestDispatcher builds a dispatcher whose chain lists the fake |
| 84 | // plugin at every given point and whose slots (slot → plugin ID) are owned as |
| 85 | // given. The fake is optional-class unless it owns a slot. |
| 86 | func newExtensionTestDispatcher(client dispatch.Client, points []extension.InterceptorPoint, slots map[extension.Slot]string) *dispatch.Dispatcher { |
| 87 | chain := map[extension.InterceptorPoint][]extension.Contribution{} |
| 88 | for _, point := range points { |
| 89 | chain[point] = []extension.Contribution{{ |
| 90 | Kind: extension.KindInterceptor, |
| 91 | ID: string(point), |
| 92 | Source: extension.ContributionSource{Scope: extension.ScopePlugin, PluginID: extensionTestPlugin}, |
| 93 | }} |
| 94 | } |
| 95 | replacements := map[extension.Slot]extension.ContributionSource{} |
| 96 | for slot, plugin := range slots { |
| 97 | replacements[slot] = extension.ContributionSource{Scope: extension.ScopePlugin, PluginID: plugin} |
| 98 | } |
| 99 | return dispatch.New(chain, replacements, func(string) dispatch.Client { return client }, nil, dispatch.Options{}) |
| 100 | } |
| 101 | |
| 102 | var sessionPoints = []extension.InterceptorPoint{ |
| 103 | extension.PointSessionStart, extension.PointSessionEnd, extension.PointSessionLoad, |
| 104 | extension.PointSessionSave, extension.PointSessionRotate, |
| 105 | } |
| 106 | |
| 107 | // recordingSink captures emitted events. |
| 108 | type recordingSink struct { |
| 109 | mu sync.Mutex |
| 110 | events []event.Event |
| 111 | } |
| 112 | |
| 113 | func (s *recordingSink) Emit(ev event.Event) { |
| 114 | s.mu.Lock() |
| 115 | defer s.mu.Unlock() |
| 116 | s.events = append(s.events, ev) |
| 117 | } |
| 118 | |
| 119 | func (s *recordingSink) all() []event.Event { |
| 120 | s.mu.Lock() |
| 121 | defer s.mu.Unlock() |
| 122 | return append([]event.Event(nil), s.events...) |
| 123 | } |
| 124 | |
| 125 | func runTestTurn(c *Controller, input string) error { |
| 126 | return newTurnOrchestrator(c).runTurnWithRawDisplay(context.Background(), input, input, "") |
| 127 | } |
| 128 | |
| 129 | func TestInputReceiveContinue(t *testing.T) { |
| 130 | client := &fakeExtClient{} |
| 131 | d := newExtensionTestDispatcher(client, []extension.InterceptorPoint{extension.PointInputReceive}, nil) |
| 132 | runner := &fakeTurnRunner{} |
| 133 | c := New(Options{Runner: runner, Extensions: d}) |
| 134 | |
| 135 | if err := runTestTurn(c, "hello world"); err != nil { |
| 136 | t.Fatal(err) |
| 137 | } |
| 138 | if len(runner.inputs) != 1 || !strings.Contains(runner.inputs[0], "hello world") { |
| 139 | t.Fatalf("runner inputs = %v, want the composed turn", runner.inputs) |
| 140 | } |
| 141 | if len(client.intercepts) != 1 || client.intercepts[0].event != protocol.EventInputReceive { |
| 142 | t.Fatalf("intercepts = %+v, want exactly one input.receive", client.intercepts) |
| 143 | } |
| 144 | if !strings.Contains(string(client.intercepts[0].payload), "hello world") { |
| 145 | t.Fatalf("intercept payload = %s, want the composed text", client.intercepts[0].payload) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | func TestInputReceiveReplace(t *testing.T) { |
| 150 | client := &fakeExtClient{ |
| 151 | interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) { |
| 152 | return protocol.InterceptResult{Decision: protocol.DecisionReplace, Replacement: json.RawMessage(`{"text":"rewritten input"}`)}, nil |
| 153 | }, |
| 154 | } |
| 155 | d := newExtensionTestDispatcher(client, []extension.InterceptorPoint{extension.PointInputReceive}, nil) |
| 156 | runner := &fakeTurnRunner{} |
| 157 | c := New(Options{Runner: runner, Extensions: d}) |
| 158 | |
| 159 | if err := runTestTurn(c, "original"); err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | if len(runner.inputs) != 1 || runner.inputs[0] != "rewritten input" { |
| 163 | t.Fatalf("runner inputs = %v, want the replaced text only", runner.inputs) |
| 164 | } |
| 165 | if !strings.Contains(string(client.intercepts[0].payload), "original") { |
| 166 | t.Fatalf("intercept payload = %s, want the pre-replacement text", client.intercepts[0].payload) |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | func TestInputReceiveBlock(t *testing.T) { |
| 171 | client := &fakeExtClient{ |
| 172 | interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) { |
| 173 | return protocol.InterceptResult{Decision: protocol.DecisionBlock, Reason: "api_key=sk-SECRET refused"}, nil |
| 174 | }, |
| 175 | } |
| 176 | d := newExtensionTestDispatcher(client, []extension.InterceptorPoint{extension.PointInputReceive}, nil) |
| 177 | runner := &fakeTurnRunner{} |
| 178 | sink := &recordingSink{} |
| 179 | c := New(Options{Runner: runner, Sink: sink, Extensions: d}) |
| 180 | |
| 181 | if err := runTestTurn(c, "do something"); err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | if len(runner.inputs) != 0 { |
| 185 | t.Fatalf("blocked turn reached the runner: %v", runner.inputs) |
| 186 | } |
| 187 | var notice *event.Event |
| 188 | for i, ev := range sink.all() { |
| 189 | if ev.Kind == event.Notice { |
| 190 | notice = &sink.all()[i] |
| 191 | } |
| 192 | } |
| 193 | if notice == nil { |
| 194 | t.Fatal("blocked turn surfaced no notice") |
| 195 | } |
| 196 | if strings.Contains(notice.Detail, "sk-SECRET") { |
| 197 | t.Fatalf("block reason was not credential-redacted: %q", notice.Detail) |
| 198 | } |
| 199 | if !strings.Contains(notice.Detail, "refused") { |
| 200 | t.Fatalf("block reason detail = %q, want the extension's reason", notice.Detail) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | func TestInputReceiveNilDispatcherUntouched(t *testing.T) { |
| 205 | runner := &fakeTurnRunner{} |
| 206 | c := New(Options{Runner: runner}) |
| 207 | if _, wrapped := c.sink.(*frontendEventSink); wrapped { |
| 208 | t.Fatal("sink wrapped without a dispatcher — the nil fast path must stay unwrapped") |
| 209 | } |
| 210 | if err := runTestTurn(c, "plain"); err != nil { |
| 211 | t.Fatal(err) |
| 212 | } |
| 213 | if len(runner.inputs) != 1 { |
| 214 | t.Fatalf("runner inputs = %v, want 1", runner.inputs) |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | // TestInputReceiveInterceptedOnHeadlessRun pins the shared seam: the |
| 219 | // synchronous headless Run path composes input outside the turn orchestrator |
| 220 | // and must cross the same input.receive chain. |
| 221 | func TestInputReceiveInterceptedOnHeadlessRun(t *testing.T) { |
| 222 | client := &fakeExtClient{ |
| 223 | interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) { |
| 224 | return protocol.InterceptResult{Decision: protocol.DecisionReplace, Replacement: json.RawMessage(`{"text":"headless rewritten"}`)}, nil |
| 225 | }, |
| 226 | } |
| 227 | d := newExtensionTestDispatcher(client, []extension.InterceptorPoint{extension.PointInputReceive}, nil) |
| 228 | runner := &fakeTurnRunner{} |
| 229 | c := New(Options{Runner: runner, Extensions: d}) |
| 230 | |
| 231 | if err := c.Run(context.Background(), "original"); err != nil { |
| 232 | t.Fatal(err) |
| 233 | } |
| 234 | if len(runner.inputs) != 1 || runner.inputs[0] != "headless rewritten" { |
| 235 | t.Fatalf("runner inputs = %v, want the replaced headless input", runner.inputs) |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | func TestSetExtensionsInstallsDispatcher(t *testing.T) { |
| 240 | client := &fakeExtClient{} |
| 241 | d := newExtensionTestDispatcher(client, []extension.InterceptorPoint{extension.PointInputReceive}, nil) |
| 242 | runner := &fakeTurnRunner{} |
| 243 | c := New(Options{Runner: runner}) |
| 244 | |
| 245 | c.SetExtensions(nil) // no-op |
| 246 | if _, wrapped := c.sink.(*frontendEventSink); wrapped { |
| 247 | t.Fatal("SetExtensions(nil) wrapped the sink") |
| 248 | } |
| 249 | c.SetExtensions(d) |
| 250 | if _, wrapped := c.sink.(*frontendEventSink); !wrapped { |
| 251 | t.Fatal("SetExtensions did not wrap the sink") |
| 252 | } |
| 253 | // The first install wins; a later dispatcher is ignored. |
| 254 | c.SetExtensions(newExtensionTestDispatcher(&fakeExtClient{}, nil, nil)) |
| 255 | if c.extensions != d { |
| 256 | t.Fatal("SetExtensions swapped an installed dispatcher") |
| 257 | } |
| 258 | if err := runTestTurn(c, "hello"); err != nil { |
| 259 | t.Fatal(err) |
| 260 | } |
| 261 | if len(client.intercepts) != 1 { |
| 262 | t.Fatalf("intercepts = %d, want the installed dispatcher to fire once", len(client.intercepts)) |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | // newSessionController builds a controller with a real executor session and |
| 267 | // session file so lifecycle points have something to save/load/rotate. |
| 268 | func newSessionController(t *testing.T, d *dispatch.Dispatcher, sink event.Sink) (*Controller, string) { |
| 269 | t.Helper() |
| 270 | dir := t.TempDir() |
| 271 | sess := agent.NewSession("sys") |
| 272 | sess.Add(provider.Message{Role: provider.RoleUser, Content: "hi"}) |
| 273 | exec := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard) |
| 274 | path := filepath.Join(dir, "s.jsonl") |
| 275 | opts := Options{Runner: &fakeTurnRunner{}, Executor: exec, SessionDir: dir, SessionPath: path, Extensions: d} |
| 276 | if sink != nil { |
| 277 | opts.Sink = sink |
| 278 | } |
| 279 | return New(opts), path |
| 280 | } |
| 281 | |
| 282 | func TestSessionEventsFireAtLifecyclePoints(t *testing.T) { |
| 283 | client := &fakeExtClient{} |
| 284 | d := newExtensionTestDispatcher(client, sessionPoints, nil) |
| 285 | c, path := newSessionController(t, d, nil) |
| 286 | |
| 287 | if err := runTestTurn(c, "hello"); err != nil { |
| 288 | t.Fatal(err) |
| 289 | } |
| 290 | if err := c.Snapshot(); err != nil { |
| 291 | t.Fatalf("Snapshot: %v", err) |
| 292 | } |
| 293 | loaded := agent.NewSession("sys2") |
| 294 | c.Resume(loaded, filepath.Join(filepath.Dir(path), "other.jsonl")) |
| 295 | if err := c.NewSession(); err != nil { |
| 296 | t.Fatalf("NewSession: %v", err) |
| 297 | } |
| 298 | c.Close() |
| 299 | |
| 300 | want := []protocol.InterceptEvent{ |
| 301 | protocol.EventSessionStart, // first turn |
| 302 | protocol.EventSessionSave, // Snapshot |
| 303 | protocol.EventSessionLoad, // Resume |
| 304 | protocol.EventSessionRotate, // NewSession |
| 305 | protocol.EventSessionEnd, // NewSession retiring the old session |
| 306 | protocol.EventSessionStart, // NewSession's fresh session |
| 307 | protocol.EventSessionEnd, // Close |
| 308 | } |
| 309 | got := client.notifyEvents() |
| 310 | if len(got) != len(want) { |
| 311 | t.Fatalf("session notify events = %v, want %v", got, want) |
| 312 | } |
| 313 | for i := range want { |
| 314 | if got[i] != want[i] { |
| 315 | t.Fatalf("session notify events = %v, want %v", got, want) |
| 316 | } |
| 317 | } |
| 318 | // The save event carries the phase payload: the session file and phase. |
| 319 | // Compare typed fields — a Windows path contains backslashes, which JSON |
| 320 | // escapes, so a raw-substring match on the payload would miss it. |
| 321 | payloads := client.notifyPayloadsFor(protocol.EventSessionSave) |
| 322 | if len(payloads) != 1 { |
| 323 | t.Fatalf("session.save payloads = %v, want exactly one", payloads) |
| 324 | } |
| 325 | var savePayload dispatch.SessionPayload |
| 326 | if err := json.Unmarshal(payloads[0], &savePayload); err != nil { |
| 327 | t.Fatalf("session.save payload does not decode: %v (%s)", err, payloads[0]) |
| 328 | } |
| 329 | if savePayload.Phase != "save" || savePayload.SessionPath != path { |
| 330 | t.Fatalf("session.save payload = %+v, want phase=save path=%q", savePayload, path) |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | func TestSessionSaveStrategyVeto(t *testing.T) { |
| 335 | client := &fakeExtClient{ |
| 336 | interceptFn: func(event protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) { |
| 337 | if event == protocol.EventSessionSave { |
| 338 | return protocol.InterceptResult{Decision: protocol.DecisionBlock, Reason: "no saves today"}, nil |
| 339 | } |
| 340 | return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil |
| 341 | }, |
| 342 | } |
| 343 | d := newExtensionTestDispatcher(client, sessionPoints, map[extension.Slot]string{extension.SlotSessionPolicy: extensionTestPlugin}) |
| 344 | c, path := newSessionController(t, d, nil) |
| 345 | |
| 346 | err := c.Snapshot() |
| 347 | if err == nil { |
| 348 | t.Fatal("Snapshot succeeded with a blocking session_policy owner") |
| 349 | } |
| 350 | var blockErr *dispatch.BlockError |
| 351 | if !errors.As(err, &blockErr) { |
| 352 | t.Fatalf("Snapshot error = %v, want a dispatch.BlockError", err) |
| 353 | } |
| 354 | if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { |
| 355 | t.Fatalf("vetoed save still wrote %s", path) |
| 356 | } |
| 357 | if n := len(client.notifyPayloadsFor(protocol.EventSessionSave)); n != 0 { |
| 358 | t.Fatalf("vetoed save broadcast %d events, want none", n) |
| 359 | } |
| 360 | } |
| 361 | |
| 362 | func TestSessionStrategyAdjustsObservedPayload(t *testing.T) { |
| 363 | client := &fakeExtClient{ |
| 364 | interceptFn: func(event protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) { |
| 365 | if event == protocol.EventSessionSave { |
| 366 | return protocol.InterceptResult{Decision: protocol.DecisionReplace, |
| 367 | Replacement: json.RawMessage(`{"sessionPath":"/adjusted.jsonl","phase":"save"}`)}, nil |
| 368 | } |
| 369 | return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil |
| 370 | }, |
| 371 | } |
| 372 | d := newExtensionTestDispatcher(client, sessionPoints, map[extension.Slot]string{extension.SlotSessionPolicy: extensionTestPlugin}) |
| 373 | c, path := newSessionController(t, d, nil) |
| 374 | |
| 375 | if err := c.Snapshot(); err != nil { |
| 376 | t.Fatalf("Snapshot: %v", err) |
| 377 | } |
| 378 | // Host-side decision unchanged: the transcript lands on the original path. |
| 379 | if _, statErr := os.Stat(path); statErr != nil { |
| 380 | t.Fatalf("save did not write the original path: %v", statErr) |
| 381 | } |
| 382 | // Observers receive the owner-adjusted payload. |
| 383 | payloads := client.notifyPayloadsFor(protocol.EventSessionSave) |
| 384 | if len(payloads) != 1 || !strings.Contains(string(payloads[0]), "/adjusted.jsonl") { |
| 385 | t.Fatalf("session.save observed payload = %v, want the adjusted path", payloads) |
| 386 | } |
| 387 | } |
| 388 | |
| 389 | func TestFrontendEventObserved(t *testing.T) { |
| 390 | client := &fakeExtClient{} |
| 391 | d := newExtensionTestDispatcher(client, []extension.InterceptorPoint{extension.PointFrontendEvent}, nil) |
| 392 | c := New(Options{Runner: &fakeTurnRunner{}, Extensions: d}) |
| 393 | |
| 394 | c.notice("hello frontend") |
| 395 | payloads := client.notifyPayloadsFor(protocol.EventFrontendEvent) |
| 396 | if len(payloads) != 1 { |
| 397 | t.Fatalf("frontend.event observations = %d, want 1", len(payloads)) |
| 398 | } |
| 399 | var payload struct { |
| 400 | Kind string `json:"kind"` |
| 401 | Text string `json:"text"` |
| 402 | } |
| 403 | if err := json.Unmarshal(payloads[0], &payload); err != nil { |
| 404 | t.Fatalf("payload decode: %v", err) |
| 405 | } |
| 406 | if payload.Kind != "notice" || payload.Text != "hello frontend" { |
| 407 | t.Fatalf("observed payload = %+v, want notice/hello frontend", payload) |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | func TestFrontendEventStrategyRewrite(t *testing.T) { |
| 412 | client := &fakeExtClient{ |
| 413 | interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) { |
| 414 | return protocol.InterceptResult{Decision: protocol.DecisionReplace, |
| 415 | Replacement: json.RawMessage(`{"kind":"notice","text":"rewritten","detail":"adjusted detail"}`)}, nil |
| 416 | }, |
| 417 | } |
| 418 | d := newExtensionTestDispatcher(client, |
| 419 | []extension.InterceptorPoint{extension.PointFrontendEvent}, |
| 420 | map[extension.Slot]string{extension.SlotFrontendEvents: extensionTestPlugin}) |
| 421 | sink := &recordingSink{} |
| 422 | c := New(Options{Runner: &fakeTurnRunner{}, Sink: sink, Extensions: d}) |
| 423 | |
| 424 | c.noticeDetail("original", "original detail") |
| 425 | events := sink.all() |
| 426 | if len(events) != 1 { |
| 427 | t.Fatalf("inner sink events = %d, want 1", len(events)) |
| 428 | } |
| 429 | if events[0].Kind != event.Notice || events[0].Text != "rewritten" || events[0].Detail != "adjusted detail" { |
| 430 | t.Fatalf("emitted event = %+v, want rewritten text/detail with the kind intact", events[0]) |
| 431 | } |
| 432 | // Observers see exactly what the frontend received. |
| 433 | payloads := client.notifyPayloadsFor(protocol.EventFrontendEvent) |
| 434 | if len(payloads) != 1 || !strings.Contains(string(payloads[0]), "rewritten") { |
| 435 | t.Fatalf("observed payloads = %v, want the rewritten event", payloads) |
| 436 | } |
| 437 | } |
| 438 | |
| 439 | func TestFrontendEventStrategyKindChangeRejected(t *testing.T) { |
| 440 | client := &fakeExtClient{ |
| 441 | interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) { |
| 442 | return protocol.InterceptResult{Decision: protocol.DecisionReplace, |
| 443 | Replacement: json.RawMessage(`{"kind":"text","text":"hijacked"}`)}, nil |
| 444 | }, |
| 445 | } |
| 446 | d := newExtensionTestDispatcher(client, |
| 447 | []extension.InterceptorPoint{extension.PointFrontendEvent}, |
| 448 | map[extension.Slot]string{extension.SlotFrontendEvents: extensionTestPlugin}) |
| 449 | sink := &recordingSink{} |
| 450 | c := New(Options{Runner: &fakeTurnRunner{}, Sink: sink, Extensions: d}) |
| 451 | |
| 452 | c.notice("original") |
| 453 | events := sink.all() |
| 454 | if len(events) != 1 || events[0].Text != "original" || events[0].Kind != event.Notice { |
| 455 | t.Fatalf("emitted events = %+v, want the original event when the owner tries to change the kind", events) |
| 456 | } |
| 457 | } |
| 458 | |
| 459 | func TestFrontendEventStrategyBlockSuppresses(t *testing.T) { |
| 460 | client := &fakeExtClient{ |
| 461 | interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) { |
| 462 | return protocol.InterceptResult{Decision: protocol.DecisionBlock, Reason: "suppress"}, nil |
| 463 | }, |
| 464 | } |
| 465 | d := newExtensionTestDispatcher(client, |
| 466 | []extension.InterceptorPoint{extension.PointFrontendEvent}, |
| 467 | map[extension.Slot]string{extension.SlotFrontendEvents: extensionTestPlugin}) |
| 468 | sink := &recordingSink{} |
| 469 | c := New(Options{Runner: &fakeTurnRunner{}, Sink: sink, Extensions: d}) |
| 470 | |
| 471 | c.notice("suppressed") |
| 472 | if events := sink.all(); len(events) != 0 { |
| 473 | t.Fatalf("blocked event reached the frontend: %+v", events) |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | // Stage 6b2: the dispatcher installed on the controller must reach the |
| 478 | // executor agent, and a strategy-replaced system prompt must land in the |
| 479 | // executor's live session (and survive session rotations). |
| 480 | |
| 481 | func TestSetExtensionsPropagatesToExecutor(t *testing.T) { |
| 482 | client := &fakeExtClient{} |
| 483 | d := newExtensionTestDispatcher(client, []extension.InterceptorPoint{extension.PointAgentBeforeStart}, nil) |
| 484 | mp := testutil.NewMock("p", testutil.Turn{Text: "hi"}) |
| 485 | exec := agent.New(mp, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard) |
| 486 | c := New(Options{Runner: &fakeTurnRunner{}, Executor: exec}) |
| 487 | |
| 488 | c.SetExtensions(d) |
| 489 | if err := c.Executor().Run(context.Background(), "hello"); err != nil { |
| 490 | t.Fatalf("Run: %v", err) |
| 491 | } |
| 492 | found := false |
| 493 | for _, call := range client.intercepts { |
| 494 | if call.event == protocol.EventAgentBeforeStart { |
| 495 | found = true |
| 496 | } |
| 497 | } |
| 498 | if !found { |
| 499 | t.Fatal("executor run did not consult the dispatcher installed by SetExtensions") |
| 500 | } |
| 501 | if mp.CallCount() != 1 { |
| 502 | t.Fatalf("provider calls = %d, want 1", mp.CallCount()) |
| 503 | } |
| 504 | } |
| 505 | |
| 506 | func TestApplyExtensionSystemPrompt(t *testing.T) { |
| 507 | dir := t.TempDir() |
| 508 | exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("HOST PROMPT"), agent.Options{}, event.Discard) |
| 509 | c := New(Options{ |
| 510 | Runner: &fakeTurnRunner{}, |
| 511 | Executor: exec, |
| 512 | SessionDir: dir, |
| 513 | SessionPath: filepath.Join(dir, "s.jsonl"), |
| 514 | SystemPrompt: "HOST PROMPT", |
| 515 | }) |
| 516 | |
| 517 | c.ApplyExtensionSystemPrompt("EXTENSION PROMPT") |
| 518 | if got := controlSystemMessage(c.History()); got != "EXTENSION PROMPT" { |
| 519 | t.Fatalf("system message = %q, want the extension prompt", got) |
| 520 | } |
| 521 | // A session rotation must keep the strategy prompt, not revert to the |
| 522 | // host-composed one. |
| 523 | if err := c.NewSession(); err != nil { |
| 524 | t.Fatalf("NewSession: %v", err) |
| 525 | } |
| 526 | if got := controlSystemMessage(c.History()); got != "EXTENSION PROMPT" { |
| 527 | t.Fatalf("system message after rotation = %q, want the extension prompt", got) |
| 528 | } |
| 529 | } |
| 530 | |
| 531 | func controlSystemMessage(msgs []provider.Message) string { |
| 532 | for _, m := range msgs { |
| 533 | if m.Role == provider.RoleSystem { |
| 534 | return m.Content |
| 535 | } |
| 536 | } |
| 537 | return "" |
| 538 | } |
| 539 |