diff --git a/go/e2e/cannedmodel.go b/go/e2e/cannedmodel.go index 9c48390d..9475fcdc 100644 --- a/go/e2e/cannedmodel.go +++ b/go/e2e/cannedmodel.go @@ -39,6 +39,18 @@ import ( // openai-completions provider appends to its configured baseUrl. const cannedChatPath = "/chat/completions" +// The openai-completions SSE wire values the stub emits, named so the two turn +// branches (text and tool-call) share one source of truth. chunkObject is the +// `object` every chat.completion.chunk carries; finishStop and finishToolCall +// are the two finish_reason values the SDK's mapStopReason keys on ("stop" is a +// clean settle, "tool_calls" maps to stopReason toolUse the agent loop gates +// tool execution on). +const ( + chunkObject = "chat.completion.chunk" + finishStop = "stop" + finishToolCall = "tool_calls" +) + // hostRoutableAddr returns the host's default-route source address — the // interface pasta forwards a container's host-gateway traffic to. A stub bound // to loopback (127.0.0.1) is unreachable from the container; it must bind THIS @@ -61,28 +73,80 @@ func hostRoutableAddr() (string, error) { return udpAddr.IP.String(), nil } +// CannedTurn is one scripted model round-trip the canned backend serves: either +// a pure-text turn (delta.content + finish_reason "stop") or a single-tool-call +// turn (a one-element delta.tool_calls + finish_reason "tool_calls"). Build one +// with CannedText or CannedToolCall — the zero value is not a valid turn. The +// server serves an ordered script of these, one per request (request N serves +// script[N]), so a multi-round agent scenario advances one scripted turn per +// model round-trip. +type CannedTurn struct { + // isToolCall selects the turn shape: false is a text turn, true a tool-call + // turn. Distinguishing on a bool (not "text != ''") lets a text turn script + // an empty reply without collapsing into the tool-call branch. + isToolCall bool + // text is the assistant reply a text turn settles on. + text string + // toolName / toolArgs script a tool-call turn: the function name and its + // arguments already serialized as a JSON string (the OpenAI contract — the + // SDK parser reads function.arguments as a string it JSON-parses in one shot, + // openai-completions.ts:1208-1220). The call id is assigned deterministically + // per served turn by the handler, not carried here. + toolName string + toolArgs string +} + +// CannedText builds a pure-text scripted turn: the assistant settles on reply +// with a clean finish_reason "stop". This is the pre-script single-reply +// behavior, now expressed as one turn. +func CannedText(reply string) CannedTurn { + return CannedTurn{text: reply} +} + +// CannedToolCall builds a single-tool-call scripted turn: the assistant emits +// one tool call naming toolName with argsJSON as its arguments (passed already +// serialized as a JSON string, the OpenAI contract) and a finish_reason +// "tool_calls" (which maps to stopReason toolUse the agent loop gates tool +// execution on, openai-completions.ts:2364-2365). The server assigns a unique +// deterministic call id per served turn. +func CannedToolCall(toolName, argsJSON string) CannedTurn { + return CannedTurn{isToolCall: true, toolName: toolName, toolArgs: argsJSON} +} + // cannedModelServer is a running canned model backend. It owns its listener and // http.Server; Close stops both. The zero value is not usable — build one with // startCannedModelServer. type cannedModelServer struct { srv *http.Server ln net.Listener - reply string + script []CannedTurn port int closeErr error closeMu sync.Mutex closed bool + // served counts requests handled so far; request N is served script[N]. It + // is read+incremented under servedMu so a pathological concurrent hit stays + // race-free (go test -race clean) — a real agent serializes its round-trips, + // but the stub guards the counter anyway. + servedMu sync.Mutex + served int } // startCannedModelServer binds a listener on bindAddr (host:port; port 0 lets -// the kernel assign a free one) and serves the canned streaming SSE turn on -// /chat/completions until Close. reply is the exact assistant text every turn -// settles on. bindAddr must be an interface the model client can reach: in the -// container leg it is the host's routable address (pasta forwards the container -// to it via the host-gateway); in the hermetic unit test it is loopback. It -// returns an error rather than panicking (rule://go-no-panic-in-lib) so the -// caller — a test — decides fatality. -func startCannedModelServer(bindAddr, reply string) (*cannedModelServer, error) { +// the kernel assign a free one) and serves the canned streaming SSE script on +// /chat/completions until Close. script is the ordered per-request turn list: +// request N settles on script[N] (a text or single-tool-call turn); a request +// past the end is a test bug the handler answers with a loud 500. An empty +// script is a construction error — a backend that can never settle a turn. +// bindAddr must be an interface the model client can reach: in the container leg +// it is the host's routable address (pasta forwards the container to it via the +// host-gateway); in the hermetic unit test it is loopback. It returns an error +// rather than panicking (rule://go-no-panic-in-lib) so the caller — a test — +// decides fatality. +func startCannedModelServer(bindAddr string, script []CannedTurn) (*cannedModelServer, error) { + if len(script) == 0 { + return nil, errors.New("canned model server requires a non-empty script") + } ln, err := net.Listen("tcp", bindAddr) if err != nil { return nil, fmt.Errorf("canned model server listen on %q: %w", bindAddr, err) @@ -92,7 +156,7 @@ func startCannedModelServer(bindAddr, reply string) (*cannedModelServer, error) _ = ln.Close() // failed construction; release the listener we just opened return nil, fmt.Errorf("canned model server listener has unexpected addr type %T", ln.Addr()) } - c := &cannedModelServer{ln: ln, reply: reply, port: tcpAddr.Port} + c := &cannedModelServer{ln: ln, script: script, port: tcpAddr.Port} mux := http.NewServeMux() mux.HandleFunc(cannedChatPath, c.handleChatCompletions) // ReadHeaderTimeout bounds a slow-header client (gosec G112); the canned @@ -171,15 +235,42 @@ type chatChoice struct { type chatDelta struct { Role string `json:"role,omitempty"` Content string `json:"content,omitempty"` + // ToolCalls is the streamed tool-call array on a tool-call turn. It is + // omitempty so a text-only chunk serializes exactly as before (no empty + // tool_calls array on a text turn — the SDK parser keys on its presence). + ToolCalls []chatToolCall `json:"tool_calls,omitempty"` } -// handleChatCompletions serves the deterministic streaming turn. It rejects a +// chatToolCall is one element of delta.tool_calls: the SDK reads index, id, +// type ("function"), and the function name+arguments (openai-completions.ts +// :1208-1220). A single chunk carrying the complete arguments string suffices — +// parseStreamingJsonThrottled parses it in one shot, so no fragmentation. +type chatToolCall struct { + Index int `json:"index"` + ID string `json:"id"` + Type string `json:"type"` + Function chatToolCallFunction `json:"function"` +} + +// chatToolCallFunction is the function name and its arguments serialized as a +// JSON string (the OpenAI contract — arguments is a string the SDK JSON-parses, +// not a nested object). +type chatToolCallFunction struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +// handleChatCompletions serves one scripted turn per request. It rejects a // non-POST with 405 (the provider only ever POSTs) and writes an HTTP error — -// never panics — on any failure (rule://go-no-panic-in-lib). The body is three -// SSE events: a content chunk carrying the whole canned reply, a terminal chunk -// with finish_reason "stop", and the literal `data: [DONE]` sentinel. Each event -// is flushed immediately so the client's first-event watchdog sees bytes without -// waiting on the handler to return. +// never panics — on any failure (rule://go-no-panic-in-lib). It claims the next +// script index under servedMu; a request past the end of the script is a test +// bug answered with a loud 500 naming exhaustion (never a hang or a default +// turn). The turn's body is either a text turn (a content chunk + a terminal +// finish_reason "stop") or a single-tool-call turn (a chunk whose +// delta.tool_calls carries one entry + a terminal finish_reason "tool_calls"), +// then the literal `data: [DONE]` sentinel. Each event is flushed immediately so +// the client's first-event watchdog sees bytes without waiting on the handler to +// return. func (c *cannedModelServer) handleChatCompletions(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "canned model backend only serves POST", http.StatusMethodNotAllowed) @@ -190,23 +281,67 @@ func (c *cannedModelServer) handleChatCompletions(w http.ResponseWriter, r *http http.Error(w, "canned model backend requires a flushable ResponseWriter", http.StatusInternalServerError) return } + + // Claim the next script index under the counter mutex so a pathological + // concurrent hit stays race-free. An index past the end is a test bug: fail + // loudly with a 500 naming exhaustion BEFORE writing the 200 stream header, + // so the client sees the error status rather than an empty stream. + c.servedMu.Lock() + idx := c.served + c.served++ + c.servedMu.Unlock() + if idx >= len(c.script) { + http.Error(w, fmt.Sprintf("canned model backend script exhausted: request %d past the end of a %d-turn script (an unscripted turn is a test bug)", idx, len(c.script)), http.StatusInternalServerError) + return + } + turn := c.script[idx] + w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.WriteHeader(http.StatusOK) const id = "canned-completion" - stop := "stop" - events := []chatChunk{ - {ID: id, Object: "chat.completion.chunk", Choices: []chatChoice{{ - Index: 0, - Delta: chatDelta{Role: "assistant", Content: c.reply}, - }}}, - {ID: id, Object: "chat.completion.chunk", Choices: []chatChoice{{ - Index: 0, - Delta: chatDelta{}, - FinishReason: &stop, - }}}, + var events []chatChunk + if turn.isToolCall { + // A single tool-call turn: one delta.tool_calls entry with a unique + // deterministic call id, then a terminal finish_reason "tool_calls" (maps + // to stopReason toolUse the agent loop gates tool execution on). + finish := finishToolCall + callID := fmt.Sprintf("call_%d", idx) + events = []chatChunk{ + {ID: id, Object: chunkObject, Choices: []chatChoice{{ + Index: 0, + Delta: chatDelta{Role: "assistant", ToolCalls: []chatToolCall{{ + Index: 0, + ID: callID, + Type: "function", + Function: chatToolCallFunction{ + Name: turn.toolName, + Arguments: turn.toolArgs, + }, + }}}, + }}}, + {ID: id, Object: chunkObject, Choices: []chatChoice{{ + Index: 0, + Delta: chatDelta{}, + FinishReason: &finish, + }}}, + } + } else { + // A pure-text turn: the content chunk then a clean finish_reason "stop". + stop := finishStop + events = []chatChunk{ + {ID: id, Object: chunkObject, Choices: []chatChoice{{ + Index: 0, + Delta: chatDelta{Role: "assistant", Content: turn.text}, + }}}, + {ID: id, Object: chunkObject, Choices: []chatChoice{{ + Index: 0, + Delta: chatDelta{}, + FinishReason: &stop, + }}}, + } } for _, ev := range events { payload, err := json.Marshal(ev) diff --git a/go/e2e/cannedmodel_test.go b/go/e2e/cannedmodel_test.go index d5f5caa0..cc24c1f1 100644 --- a/go/e2e/cannedmodel_test.go +++ b/go/e2e/cannedmodel_test.go @@ -16,6 +16,7 @@ import ( "bufio" "context" "encoding/json" + "io" "net/http" "strings" "testing" @@ -39,7 +40,7 @@ func TestCannedModelServerEmitsParseableSSE(t *testing.T) { if err != nil { t.Fatalf("hostRoutableAddr: %v", err) } - srv, err := startCannedModelServer(host+":0", reply) + srv, err := startCannedModelServer(host+":0", []CannedTurn{CannedText(reply)}) if err != nil { t.Fatalf("startCannedModelServer: %v", err) } @@ -134,7 +135,7 @@ func TestCannedModelServerRejectsNonPost(t *testing.T) { if err != nil { t.Fatalf("hostRoutableAddr: %v", err) } - srv, err := startCannedModelServer(host+":0", "unused") + srv, err := startCannedModelServer(host+":0", []CannedTurn{CannedText("unused")}) if err != nil { t.Fatalf("startCannedModelServer: %v", err) } @@ -162,3 +163,285 @@ func TestCannedModelServerRejectsNonPost(t *testing.T) { t.Fatalf("GET status = %d, want 405", resp.StatusCode) } } + +// sseTurn is a reassembled scripted turn decoded from the stub's SSE frames, +// mirroring how the SDK's openai-completions parser accumulates a stream: the +// concatenated content deltas, the tool_calls entry (if any), the final +// finish_reason, and whether the terminal [DONE] sentinel arrived. +type sseTurn struct { + content string + finish string + sawDone bool + toolCalls []struct { + id string + typ string + name string + args string + } + // rawFrames holds each SSE `data:` payload verbatim (minus the [DONE] + // sentinel), so a test can assert on the exact serialized wire shape — e.g. + // that a text turn's chunk carries no "tool_calls" key at all (the omitempty + // discipline), which the decoded struct alone cannot distinguish from an + // empty/null array. + rawFrames []string +} + +// readCannedTurn POSTs one /chat/completions request the way the SDK transport +// does (Accept: text/event-stream) and decodes the SSE frames into an sseTurn. +// It bounds the wait with the caller's ctx (rule://no-retries: no sleeps/polls). +func readCannedTurn(ctx context.Context, t *testing.T, url string) sseTurn { + t.Helper() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(`{"model":"x","messages":[]}`)) + if err != nil { + t.Fatalf("build request: %v", err) + } + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST %s: %v", url, err) + } + defer func() { + _ = resp.Body.Close() // response-body close in test; error not actionable + }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var turn sseTurn + var content strings.Builder + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + data, ok := strings.CutPrefix(scanner.Text(), "data: ") + if !ok { + continue + } + if data == "[DONE]" { + turn.sawDone = true + break + } + turn.rawFrames = append(turn.rawFrames, data) + var chunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + ToolCalls []struct { + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"delta"` + FinishReason *string `json:"finish_reason"` + } `json:"choices"` + } + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + t.Fatalf("frame %q is not parseable JSON (the SDK parser would throw): %v", data, err) + } + if len(chunk.Choices) == 0 { + t.Fatalf("frame %q has no choices; the openai-completions parser reads choices[0]", data) + } + content.WriteString(chunk.Choices[0].Delta.Content) + for _, tc := range chunk.Choices[0].Delta.ToolCalls { + turn.toolCalls = append(turn.toolCalls, struct { + id string + typ string + name string + args string + }{id: tc.ID, typ: tc.Type, name: tc.Function.Name, args: tc.Function.Arguments}) + } + if fr := chunk.Choices[0].FinishReason; fr != nil { + turn.finish = *fr + } + } + if err := scanner.Err(); err != nil { + t.Fatalf("reading SSE stream: %v", err) + } + turn.content = content.String() + return turn +} + +// TestCannedModelServerEmitsToolCallTurn pins the tool-call turn wire contract: +// a scripted CannedToolCall turn emits a single-element delta.tool_calls entry +// (non-empty id, type "function", the tool name, and the args JSON STRING +// verbatim), a terminal finish_reason "tool_calls" (which maps to stopReason +// toolUse the agent loop gates on), and the [DONE] sentinel. +func TestCannedModelServerEmitsToolCallTurn(t *testing.T) { + const ( + toolName = "my_tool" + argsJSON = `{"k":"v"}` + ) + host, err := hostRoutableAddr() + if err != nil { + t.Fatalf("hostRoutableAddr: %v", err) + } + srv, err := startCannedModelServer(host+":0", []CannedTurn{CannedToolCall(toolName, argsJSON)}) + if err != nil { + t.Fatalf("startCannedModelServer: %v", err) + } + t.Cleanup(func() { + if err := srv.Close(); err != nil { + t.Errorf("canned model server Close: %v", err) + } + }) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + turn := readCannedTurn(ctx, t, srv.BaseURL(host)+"/chat/completions") + if len(turn.toolCalls) != 1 { + t.Fatalf("tool_calls count = %d, want 1", len(turn.toolCalls)) + } + tc := turn.toolCalls[0] + if tc.name != toolName { + t.Fatalf("tool call name = %q, want %q", tc.name, toolName) + } + if tc.id == "" { + t.Fatal("tool call id is empty; the SDK requires a non-empty call id") + } + if tc.typ != "function" { + t.Fatalf("tool call type = %q, want function", tc.typ) + } + if tc.args != argsJSON { + t.Fatalf("tool call arguments = %q, want %q (verbatim JSON string)", tc.args, argsJSON) + } + if turn.finish != "tool_calls" { + t.Fatalf("finish_reason = %q, want tool_calls", turn.finish) + } + if turn.content != "" { + t.Fatalf("tool-call turn carried assistant content %q, want none (a tool-call turn must not stamp text the SDK would append alongside the call)", turn.content) + } + if !turn.sawDone { + t.Fatal("stream never sent the [DONE] sentinel") + } +} + +// TestCannedModelServerServesMultiTurnScript pins the per-request script: a +// 2-turn script (a tool-call turn then a text turn) serves turn[0] on the first +// POST and turn[1] on the second, so a multi-round agent scenario advances one +// scripted turn per model round-trip. +func TestCannedModelServerServesMultiTurnScript(t *testing.T) { + const ( + toolName = "spawn" + argsJSON = `{"a":1}` + reply = "all done" + ) + host, err := hostRoutableAddr() + if err != nil { + t.Fatalf("hostRoutableAddr: %v", err) + } + srv, err := startCannedModelServer(host+":0", []CannedTurn{ + CannedToolCall(toolName, argsJSON), + CannedText(reply), + }) + if err != nil { + t.Fatalf("startCannedModelServer: %v", err) + } + t.Cleanup(func() { + if err := srv.Close(); err != nil { + t.Errorf("canned model server Close: %v", err) + } + }) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + url := srv.BaseURL(host) + "/chat/completions" + + first := readCannedTurn(ctx, t, url) + if len(first.toolCalls) != 1 || first.toolCalls[0].name != toolName { + t.Fatalf("POST#1 = %+v, want a single %q tool call", first, toolName) + } + if first.finish != "tool_calls" { + t.Fatalf("POST#1 finish_reason = %q, want tool_calls", first.finish) + } + + second := readCannedTurn(ctx, t, url) + if len(second.toolCalls) != 0 { + t.Fatalf("POST#2 carried %d tool calls, want a pure text turn", len(second.toolCalls)) + } + if second.content != reply { + t.Fatalf("POST#2 content = %q, want %q", second.content, reply) + } + if second.finish != "stop" { + t.Fatalf("POST#2 finish_reason = %q, want stop", second.finish) + } + // Pin the omitempty wire discipline the cannedmodel.go ToolCalls tag claims: + // a text turn's frames must carry no "tool_calls" key at all — not an empty + // or null array. The decoded len==0 above cannot tell an absent key from a + // present-but-empty one, so assert on the raw serialized frame directly. If + // someone dropped the omitempty tag, a text chunk would emit "tool_calls":null + // and this reddens. + for _, frame := range second.rawFrames { + if strings.Contains(frame, "tool_calls") { + t.Fatalf("POST#2 (text turn) frame %q contains a tool_calls key; a text turn must omit it entirely (omitempty)", frame) + } + } +} + +// TestCannedModelServerExhaustionIs500 pins the loud-failure contract: a request +// past the end of the script is a test bug, so the stub answers HTTP 500 with a +// body naming exhaustion rather than hanging or serving a default turn. +func TestCannedModelServerExhaustionIs500(t *testing.T) { + host, err := hostRoutableAddr() + if err != nil { + t.Fatalf("hostRoutableAddr: %v", err) + } + srv, err := startCannedModelServer(host+":0", []CannedTurn{CannedText("only turn")}) + if err != nil { + t.Fatalf("startCannedModelServer: %v", err) + } + t.Cleanup(func() { + if err := srv.Close(); err != nil { + t.Errorf("canned model server Close: %v", err) + } + }) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + url := srv.BaseURL(host) + "/chat/completions" + + // First POST drains the one scripted turn. + _ = readCannedTurn(ctx, t, url) + + // Second POST is past the end: expect a loud 500 naming exhaustion. + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(`{"model":"x","messages":[]}`)) + if err != nil { + t.Fatalf("build request: %v", err) + } + req.Header.Set("Accept", "text/event-stream") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer func() { + _ = resp.Body.Close() // response-body close in test; error not actionable + }() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("exhausted POST status = %d, want 500", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if !strings.Contains(strings.ToLower(string(body)), "exhaust") { + t.Fatalf("exhaustion body = %q, want it to name exhaustion", string(body)) + } +} + +// TestCannedModelServerRejectsEmptyScript pins the construction guard: an empty +// script is a caller bug, so startCannedModelServer returns an error rather than +// serving a backend that can never settle a turn. +func TestCannedModelServerRejectsEmptyScript(t *testing.T) { + host, err := hostRoutableAddr() + if err != nil { + t.Fatalf("hostRoutableAddr: %v", err) + } + srv, err := startCannedModelServer(host+":0", []CannedTurn{}) + if err == nil { + if srv != nil { + _ = srv.Close() // unexpected success; release the listener + } + t.Fatal("startCannedModelServer with an empty script returned nil error, want a construction error") + } +} diff --git a/go/e2e/fixture.go b/go/e2e/fixture.go index 9f281c61..231b8374 100644 --- a/go/e2e/fixture.go +++ b/go/e2e/fixture.go @@ -55,8 +55,8 @@ type Fixture struct { // before NewFixture stands the stack up. The zero value is the plain H1/H2 // fixture (no canned model); WithCannedModel turns on the SEA-1787 H3 backend. type fixtureConfig struct { - canned bool - cannedReply string + canned bool + cannedScript []CannedTurn } // fixtureOption mutates a fixtureConfig. Variadic options keep NewFixture's @@ -65,16 +65,31 @@ type fixtureConfig struct { type fixtureOption func(*fixtureConfig) // WithCannedModel makes NewFixture stand up the deterministic canned model -// backend (SEA-1787 H3): it starts the stub SSE server on the host's routable -// interface, writes a models.yml custom openai-completions provider pointing at -// it (through the pasta host-gateway) into a host dir bind-mounted at the -// agent's ~/.omp/agent, and pins the fixture's AgentModel/EgressAllow so the -// agent resolves that provider and its default-deny egress permits exactly the -// stub. reply is the assistant text every scripted turn settles on. +// backend (SEA-1787 H3) with a single pure-text turn: it starts the stub SSE +// server on the host's routable interface, writes a models.yml custom +// openai-completions provider pointing at it (through the pasta host-gateway) +// into a host dir bind-mounted at the agent's ~/.omp/agent, and pins the +// fixture's AgentModel/EgressAllow so the agent resolves that provider and its +// default-deny egress permits exactly the stub. reply is the assistant text the +// single scripted turn settles on. For a multi-turn script (H4), use +// WithCannedScript. func WithCannedModel(reply string) fixtureOption { return func(fc *fixtureConfig) { fc.canned = true - fc.cannedReply = reply + fc.cannedScript = []CannedTurn{CannedText(reply)} + } +} + +// WithCannedScript makes NewFixture stand up the canned model backend serving an +// ordered multi-turn script (SEA-1788 H4): the agent settles request N on +// script[N], so a multi-round scenario (e.g. a tool-call turn then a closing +// text turn) advances one scripted turn per model round-trip. It shares the same +// underlying backend as WithCannedModel — the single-turn convenience is just a +// one-CannedText script. +func WithCannedScript(script ...CannedTurn) fixtureOption { + return func(fc *fixtureConfig) { + fc.canned = true + fc.cannedScript = script } } @@ -165,7 +180,7 @@ func NewFixture(ctx context.Context, t *testing.T, opts ...fixtureOption) *Fixtu // stub means plain mode and every canned field stays as set above. var stub *cannedModelServer if fc.canned { - stub = configureCannedModel(t, &cfg, root, fc.cannedReply) + stub = configureCannedModel(t, &cfg, root, fc.cannedScript) } deps := stack.Deps{ @@ -267,14 +282,14 @@ const pastaHostGateway = "169.254.1.2" // It returns the running stub; its Close rides a t.Cleanup so teardown never // leaks it. cfgRoot is the fixture's short root (the models.yml host dir lives // under it, short enough to stay clear of any path budget). -func configureCannedModel(t *testing.T, cfg *stack.Config, cfgRoot, reply string) *cannedModelServer { +func configureCannedModel(t *testing.T, cfg *stack.Config, cfgRoot string, script []CannedTurn) *cannedModelServer { t.Helper() hostAddr, err := hostRoutableAddr() if err != nil { t.Fatalf("resolve host routable address for canned model: %v", err) } - stub, err := startCannedModelServer(hostAddr+":0", reply) + stub, err := startCannedModelServer(hostAddr+":0", script) if err != nil { t.Fatalf("start canned model server: %v", err) }