feat(responses): honor explicit continuation signal (#188)
Parse explicit end_turn:false on Responses completion as provider.StopContinue and continue the agent loop in the same turn, reusing request context, guards, and usage accounting. Tool calls keep precedence; missing, null, and true end_turn retain existing behavior.
- commit
- b0688d18579d389596c2d80d8af8505254a6e9ff
- author
- bnema · · 2026-09-07 14:23 UTC
- committer
- GitHub · · 2026-09-07 14:23 UTC
- parent
- a1aeca6
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
11 files · +838 -7
M docs/providers.md +3 -0
| @@ -252,6 +252,9 @@ HTTP/SSE with the same logical session/cache identity. Cancellation closes | |||
| 252 | 252 | only the active session socket; a normally completed turn keeps it warm for | |
| 253 | 253 | the next continuation. | |
| 254 | 254 | ||
| 255 | + | ### Responses explicit continuation | |
| 256 | + | ||
| 257 | + | When a Responses completion carries an explicit `end_turn:false`, zut treats the finished inference as a continuation (`turn_end` with `stop:"continue"`) and issues another model call in the same turn, reusing its request context, guards, usage accounting, and queued-input boundary. Tool calls still take precedence over the signal, and errors or cancellation never become continuations. A missing, null, or true `end_turn` keeps the existing behavior and ends the turn. | |
| 255 | 258 | ||
| 256 | 259 | ## Local llama.cpp router | |
| 257 | 260 | ||
| @@ -252,6 +252,9 @@ HTTP/SSE with the same logical session/cache identity. Cancellation closes | |||||
| 252 | only the active session socket; a normally completed turn keeps it warm for | 252 | only the active session socket; a normally completed turn keeps it warm for | ||
| 253 | the next continuation. | 253 | the next continuation. | ||
| 254 | 254 | ||||
| 255 | + | ### Responses explicit continuation | |||
| 256 | + | ||||
| 257 | + | When a Responses completion carries an explicit `end_turn:false`, zut treats the finished inference as a continuation (`turn_end` with `stop:"continue"`) and issues another model call in the same turn, reusing its request context, guards, usage accounting, and queued-input boundary. Tool calls still take precedence over the signal, and errors or cancellation never become continuations. A missing, null, or true `end_turn` keeps the existing behavior and ends the turn. | |||
| 255 | 258 | ||||
| 256 | ## Local llama.cpp router | 259 | ## Local llama.cpp router | ||
| 257 | 260 | ||||
M docs/rpc.md +1 -1
| @@ -208,7 +208,7 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`. | |||
| 208 | 208 | | `tool_result` | `id`, `is_error`, `content` | Tool finished | | |
| 209 | 209 | | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends | | |
| 210 | 210 | | `usage` | `input`, `output`, `reasoning`, `cache_read`, `cache_write`, `cache_measured_prompt`, `cache_measured_read`, `cost_usd`, `cumulative` | Per-turn + cumulative tokens / cost. Cache ratios use only the measured cache fields; `reasoning` is null when unavailable. | | |
| 211 | - | | `turn_end` | `stop`, optional `error` | One model call finished. `stop` is `end_turn`, `tool_use`, `length`, `error`, or `aborted` | | |
| 211 | + | | `turn_end` | `stop`, optional `error` | One model call finished. `stop` is `end`, `continue`, `tool_use`, `length`, `error`, or `aborted`. `continue` ends one inference, not the logical task: the agent issues another model call in the same turn | | |
| 212 | 212 | | `done` | (none) | The whole prompt/compact completed (success or error) | | |
| 213 | 213 | | `error` | `message` | Non-fatal error message | | |
| 214 | 214 | | `compact_done` | `summary` | Compaction finished, summary text included | | |
| @@ -208,7 +208,7 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`. | |||||
| 208 | | `tool_result` | `id`, `is_error`, `content` | Tool finished | | 208 | | `tool_result` | `id`, `is_error`, `content` | Tool finished | | ||
| 209 | | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends | | 209 | | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends | | ||
| 210 | | `usage` | `input`, `output`, `reasoning`, `cache_read`, `cache_write`, `cache_measured_prompt`, `cache_measured_read`, `cost_usd`, `cumulative` | Per-turn + cumulative tokens / cost. Cache ratios use only the measured cache fields; `reasoning` is null when unavailable. | | 210 | | `usage` | `input`, `output`, `reasoning`, `cache_read`, `cache_write`, `cache_measured_prompt`, `cache_measured_read`, `cost_usd`, `cumulative` | Per-turn + cumulative tokens / cost. Cache ratios use only the measured cache fields; `reasoning` is null when unavailable. | | ||
| 211 | - | | `turn_end` | `stop`, optional `error` | One model call finished. `stop` is `end_turn`, `tool_use`, `length`, `error`, or `aborted` | | 211 | + | | `turn_end` | `stop`, optional `error` | One model call finished. `stop` is `end`, `continue`, `tool_use`, `length`, `error`, or `aborted`. `continue` ends one inference, not the logical task: the agent issues another model call in the same turn | |
| 212 | | `done` | (none) | The whole prompt/compact completed (success or error) | | 212 | | `done` | (none) | The whole prompt/compact completed (success or error) | | ||
| 213 | | `error` | `message` | Non-fatal error message | | 213 | | `error` | `message` | Non-fatal error message | | ||
| 214 | | `compact_done` | `summary` | Compaction finished, summary text included | | 214 | | `compact_done` | `summary` | Compaction finished, summary text included | | ||
A packages/agent/modes/stream_continuation_test.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "strings" | |
| 7 | + | "sync/atomic" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zut/packages/core" | |
| 11 | + | "github.com/bnema/zut/packages/provider" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // streamContinuationClient emits interim text with StopContinue on the | |
| 15 | + | // first call and the final answer with StopEnd on the second call. | |
| 16 | + | type streamContinuationClient struct { | |
| 17 | + | calls int32 | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (c *streamContinuationClient) Name() string { return "stream-continuation" } | |
| 21 | + | ||
| 22 | + | func (c *streamContinuationClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 23 | + | call := atomic.AddInt32(&c.calls, 1) | |
| 24 | + | out := make(chan provider.Event, 8) | |
| 25 | + | go func() { | |
| 26 | + | defer close(out) | |
| 27 | + | text, stop := "final", provider.StopEnd | |
| 28 | + | if call == 1 { | |
| 29 | + | text, stop = "part one", provider.StopContinue | |
| 30 | + | } | |
| 31 | + | for _, chunk := range strings.Split(text, " ") { | |
| 32 | + | select { | |
| 33 | + | case <-ctx.Done(): | |
| 34 | + | return | |
| 35 | + | case out <- provider.EventTextDelta{Delta: chunk + " "}: | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | select { | |
| 39 | + | case <-ctx.Done(): | |
| 40 | + | return | |
| 41 | + | case out <- provider.EventDone{Stop: stop, Message: provider.Message{ | |
| 42 | + | Role: provider.RoleAssistant, | |
| 43 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |
| 44 | + | }}: | |
| 45 | + | } | |
| 46 | + | }() | |
| 47 | + | return out, nil | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestRunStreamContinuation(t *testing.T) { | |
| 51 | + | client := &streamContinuationClient{} | |
| 52 | + | ag := core.NewAgent(client, "test-model", "", nil) | |
| 53 | + | var out, diag bytes.Buffer | |
| 54 | + | if err := RunStreamWithDiag(context.Background(), ag, "hi", nil, &out, &diag); err != nil { | |
| 55 | + | t.Fatal(err) | |
| 56 | + | } | |
| 57 | + | // Two inferences happened: execution did not return after the | |
| 58 | + | // interim continuation. | |
| 59 | + | if got := atomic.LoadInt32(&client.calls); got != 2 { | |
| 60 | + | t.Fatalf("provider calls = %d, want 2", got) | |
| 61 | + | } | |
| 62 | + | got := out.String() | |
| 63 | + | if !strings.Contains(got, "final") { | |
| 64 | + | t.Fatalf("stdout = %q, want it to include the final answer", got) | |
| 65 | + | } | |
| 66 | + | if !strings.Contains(got, "part one") { | |
| 67 | + | t.Fatalf("stdout = %q, want it to include the interim text", got) | |
| 68 | + | } | |
| 69 | + | } | |
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "strings" | |||
| 7 | + | "sync/atomic" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zut/packages/core" | |||
| 11 | + | "github.com/bnema/zut/packages/provider" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // streamContinuationClient emits interim text with StopContinue on the | |||
| 15 | + | // first call and the final answer with StopEnd on the second call. | |||
| 16 | + | type streamContinuationClient struct { | |||
| 17 | + | calls int32 | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func (c *streamContinuationClient) Name() string { return "stream-continuation" } | |||
| 21 | + | ||||
| 22 | + | func (c *streamContinuationClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) { | |||
| 23 | + | call := atomic.AddInt32(&c.calls, 1) | |||
| 24 | + | out := make(chan provider.Event, 8) | |||
| 25 | + | go func() { | |||
| 26 | + | defer close(out) | |||
| 27 | + | text, stop := "final", provider.StopEnd | |||
| 28 | + | if call == 1 { | |||
| 29 | + | text, stop = "part one", provider.StopContinue | |||
| 30 | + | } | |||
| 31 | + | for _, chunk := range strings.Split(text, " ") { | |||
| 32 | + | select { | |||
| 33 | + | case <-ctx.Done(): | |||
| 34 | + | return | |||
| 35 | + | case out <- provider.EventTextDelta{Delta: chunk + " "}: | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | select { | |||
| 39 | + | case <-ctx.Done(): | |||
| 40 | + | return | |||
| 41 | + | case out <- provider.EventDone{Stop: stop, Message: provider.Message{ | |||
| 42 | + | Role: provider.RoleAssistant, | |||
| 43 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |||
| 44 | + | }}: | |||
| 45 | + | } | |||
| 46 | + | }() | |||
| 47 | + | return out, nil | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestRunStreamContinuation(t *testing.T) { | |||
| 51 | + | client := &streamContinuationClient{} | |||
| 52 | + | ag := core.NewAgent(client, "test-model", "", nil) | |||
| 53 | + | var out, diag bytes.Buffer | |||
| 54 | + | if err := RunStreamWithDiag(context.Background(), ag, "hi", nil, &out, &diag); err != nil { | |||
| 55 | + | t.Fatal(err) | |||
| 56 | + | } | |||
| 57 | + | // Two inferences happened: execution did not return after the | |||
| 58 | + | // interim continuation. | |||
| 59 | + | if got := atomic.LoadInt32(&client.calls); got != 2 { | |||
| 60 | + | t.Fatalf("provider calls = %d, want 2", got) | |||
| 61 | + | } | |||
| 62 | + | got := out.String() | |||
| 63 | + | if !strings.Contains(got, "final") { | |||
| 64 | + | t.Fatalf("stdout = %q, want it to include the final answer", got) | |||
| 65 | + | } | |||
| 66 | + | if !strings.Contains(got, "part one") { | |||
| 67 | + | t.Fatalf("stdout = %q, want it to include the interim text", got) | |||
| 68 | + | } | |||
| 69 | + | } | |||
A packages/agent/sdk/continuation_test.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | package sdk | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zut/packages/core" | |
| 7 | + | "github.com/bnema/zut/packages/provider" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // TestTurnEndPreservesContinuationStop pins that the SDK event converter | |
| 11 | + | // forwards the additive continuation stop value instead of treating the | |
| 12 | + | // intermediate inference as overall completion. | |
| 13 | + | func TestTurnEndPreservesContinuationStop(t *testing.T) { | |
| 14 | + | ev := toEvent(core.EvTurnEnd{Stop: provider.StopContinue}) | |
| 15 | + | if ev.Type != "turn_end" { | |
| 16 | + | t.Fatalf("event type = %q, want turn_end", ev.Type) | |
| 17 | + | } | |
| 18 | + | if ev.Stop != "continue" { | |
| 19 | + | t.Fatalf("stop = %q, want continue", ev.Stop) | |
| 20 | + | } | |
| 21 | + | } | |
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | package sdk | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/zut/packages/core" | |||
| 7 | + | "github.com/bnema/zut/packages/provider" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // TestTurnEndPreservesContinuationStop pins that the SDK event converter | |||
| 11 | + | // forwards the additive continuation stop value instead of treating the | |||
| 12 | + | // intermediate inference as overall completion. | |||
| 13 | + | func TestTurnEndPreservesContinuationStop(t *testing.T) { | |||
| 14 | + | ev := toEvent(core.EvTurnEnd{Stop: provider.StopContinue}) | |||
| 15 | + | if ev.Type != "turn_end" { | |||
| 16 | + | t.Fatalf("event type = %q, want turn_end", ev.Type) | |||
| 17 | + | } | |||
| 18 | + | if ev.Stop != "continue" { | |||
| 19 | + | t.Fatalf("stop = %q, want continue", ev.Stop) | |||
| 20 | + | } | |||
| 21 | + | } | |||
M packages/core/agent.go +7 -0
| @@ -725,6 +725,13 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte | |||
| 725 | 725 | return err | |
| 726 | 726 | } | |
| 727 | 727 | ||
| 728 | + | if stop == provider.StopContinue { | |
| 729 | + | if err := ctx.Err(); err != nil { | |
| 730 | + | sink(EvDone{}) | |
| 731 | + | return err | |
| 732 | + | } | |
| 733 | + | continue | |
| 734 | + | } | |
| 728 | 735 | if stop == provider.StopToolUse { | |
| 729 | 736 | // Execute each tool call, append a single tool-results message, continue. | |
| 730 | 737 | toolMsg, hadError := a.executeTools(ctx, assistantMsg, sink) | |
| @@ -725,6 +725,13 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte | |||||
| 725 | return err | 725 | return err | ||
| 726 | } | 726 | } | ||
| 727 | 727 | ||||
| 728 | + | if stop == provider.StopContinue { | |||
| 729 | + | if err := ctx.Err(); err != nil { | |||
| 730 | + | sink(EvDone{}) | |||
| 731 | + | return err | |||
| 732 | + | } | |||
| 733 | + | continue | |||
| 734 | + | } | |||
| 728 | if stop == provider.StopToolUse { | 735 | if stop == provider.StopToolUse { | ||
| 729 | // Execute each tool call, append a single tool-results message, continue. | 736 | // Execute each tool call, append a single tool-results message, continue. | ||
| 730 | toolMsg, hadError := a.executeTools(ctx, assistantMsg, sink) | 737 | toolMsg, hadError := a.executeTools(ctx, assistantMsg, sink) | ||
A packages/core/agent_continuation_loop_test.go +289 -0
| @@ -0,0 +1,289 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | "sync" | |
| 9 | + | "sync/atomic" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/zut/packages/provider" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // continuationScriptClient replays a scripted stop reason per provider call | |
| 17 | + | // and records every request for identity/history assertions. | |
| 18 | + | type continuationScriptClient struct { | |
| 19 | + | mu sync.Mutex | |
| 20 | + | requests []provider.Request | |
| 21 | + | calls int32 | |
| 22 | + | // continuations is the number of leading calls that return | |
| 23 | + | // StopContinue before one final StopEnd. | |
| 24 | + | continuations int32 | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (c *continuationScriptClient) Name() string { return "continuation-script" } | |
| 28 | + | ||
| 29 | + | func (c *continuationScriptClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 30 | + | call := atomic.AddInt32(&c.calls, 1) | |
| 31 | + | c.mu.Lock() | |
| 32 | + | c.requests = append(c.requests, req) | |
| 33 | + | c.mu.Unlock() | |
| 34 | + | out := make(chan provider.Event, 4) | |
| 35 | + | out <- provider.EventStart{Provider: c.Name(), Model: req.Model} | |
| 36 | + | text := "final" | |
| 37 | + | stop := provider.StopEnd | |
| 38 | + | if call <= c.continuations { | |
| 39 | + | text = fmt.Sprintf("part-%d", call) | |
| 40 | + | stop = provider.StopContinue | |
| 41 | + | } | |
| 42 | + | out <- provider.EventTextDelta{Delta: text} | |
| 43 | + | out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 1, OutputTokens: 1}} | |
| 44 | + | out <- provider.EventDone{Stop: stop, Message: provider.Message{ | |
| 45 | + | Role: provider.RoleAssistant, | |
| 46 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |
| 47 | + | }} | |
| 48 | + | close(out) | |
| 49 | + | return out, nil | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (c *continuationScriptClient) Calls() int32 { return atomic.LoadInt32(&c.calls) } | |
| 53 | + | ||
| 54 | + | func (c *continuationScriptClient) Requests() []provider.Request { | |
| 55 | + | c.mu.Lock() | |
| 56 | + | defer c.mu.Unlock() | |
| 57 | + | out := make([]provider.Request, len(c.requests)) | |
| 58 | + | copy(out, c.requests) | |
| 59 | + | return out | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | type continuationEvents struct { | |
| 63 | + | mu sync.Mutex | |
| 64 | + | turnEnds []EvTurnEnd | |
| 65 | + | doneCount int | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (e *continuationEvents) sink(ev AgentEvent) { | |
| 69 | + | e.mu.Lock() | |
| 70 | + | defer e.mu.Unlock() | |
| 71 | + | if te, ok := ev.(EvTurnEnd); ok { | |
| 72 | + | e.turnEnds = append(e.turnEnds, te) | |
| 73 | + | } | |
| 74 | + | if _, ok := ev.(EvDone); ok { | |
| 75 | + | e.doneCount++ | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (e *continuationEvents) snapshot() ([]EvTurnEnd, int) { | |
| 80 | + | e.mu.Lock() | |
| 81 | + | defer e.mu.Unlock() | |
| 82 | + | out := make([]EvTurnEnd, len(e.turnEnds)) | |
| 83 | + | copy(out, e.turnEnds) | |
| 84 | + | return out, e.doneCount | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // TestAgentContinuationTwelveStepsThenDone pins AC3: 12 text-only explicit | |
| 88 | + | // continuations followed by a final response produce 13 requests and one | |
| 89 | + | // terminal completion, with no artificial user/tool rows. | |
| 90 | + | func TestAgentContinuationTwelveStepsThenDone(t *testing.T) { | |
| 91 | + | client := &continuationScriptClient{continuations: 12} | |
| 92 | + | agent := NewAgent(client, "test-model", "system", Registry{}) | |
| 93 | + | events := &continuationEvents{} | |
| 94 | + | ||
| 95 | + | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| 96 | + | defer cancel() | |
| 97 | + | if err := agent.Prompt(ctx, "start", nil, events.sink); err != nil { | |
| 98 | + | t.Fatalf("Prompt returned %v", err) | |
| 99 | + | } | |
| 100 | + | if got := client.Calls(); got != 13 { | |
| 101 | + | t.Fatalf("provider calls = %d, want 13", got) | |
| 102 | + | } | |
| 103 | + | turnEnds, doneCount := events.snapshot() | |
| 104 | + | if doneCount != 1 { | |
| 105 | + | t.Fatalf("terminal EvDone count = %d, want 1", doneCount) | |
| 106 | + | } | |
| 107 | + | if len(turnEnds) != 13 { | |
| 108 | + | t.Fatalf("EvTurnEnd count = %d, want 13", len(turnEnds)) | |
| 109 | + | } | |
| 110 | + | for i, te := range turnEnds { | |
| 111 | + | want := provider.StopContinue | |
| 112 | + | if i == 12 { | |
| 113 | + | want = provider.StopEnd | |
| 114 | + | } | |
| 115 | + | if te.Stop != want || te.Err != nil { | |
| 116 | + | t.Fatalf("turn %d end = (%q, %v), want (%q, nil)", i+1, te.Stop, te.Err, want) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | msgs := agent.Messages() | |
| 121 | + | if len(msgs) != 14 { | |
| 122 | + | t.Fatalf("transcript messages = %d, want 1 user + 13 assistant", len(msgs)) | |
| 123 | + | } | |
| 124 | + | if msgs[0].Role != provider.RoleUser { | |
| 125 | + | t.Fatalf("message 0 role = %q, want user", msgs[0].Role) | |
| 126 | + | } | |
| 127 | + | for i := 1; i < len(msgs); i++ { | |
| 128 | + | if msgs[i].Role != provider.RoleAssistant { | |
| 129 | + | t.Fatalf("message %d role = %q, want assistant (no artificial rows)", i, msgs[i].Role) | |
| 130 | + | } | |
| 131 | + | if len(msgs[i].Content) == 0 { | |
| 132 | + | t.Fatalf("message %d has no content", i) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // Turn/thread identity is stable across the whole logical turn and | |
| 137 | + | // every continuation sees prior assistant content. | |
| 138 | + | requests := client.Requests() | |
| 139 | + | turnID := requests[0].Context.TurnID | |
| 140 | + | if turnID == "" { | |
| 141 | + | t.Fatal("first request has empty turn ID") | |
| 142 | + | } | |
| 143 | + | for i, req := range requests { | |
| 144 | + | if req.Context.TurnID != turnID { | |
| 145 | + | t.Fatalf("request %d turn ID = %q, want stable %q", i, req.Context.TurnID, turnID) | |
| 146 | + | } | |
| 147 | + | if want := 1 + i; len(req.Messages) != want { | |
| 148 | + | t.Fatalf("request %d messages = %d, want %d (prior content retained)", i, len(req.Messages), want) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // Usage is counted once per inference. | |
| 153 | + | if got := agent.Cost(); got.InputTokens != 13 || got.OutputTokens != 13 { | |
| 154 | + | t.Fatalf("cumulative usage = %+v, want 13 input / 13 output", got) | |
| 155 | + | } | |
| 156 | + | if got := extractText(msgs[len(msgs)-1]); got != "final" { | |
| 157 | + | t.Fatalf("final text = %q, want %q", got, "final") | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // TestAgentContinuationCanceledAtBoundary pins the canceled path: no | |
| 162 | + | // further inference is requested and the cancellation error surfaces. | |
| 163 | + | func TestAgentContinuationCanceledAtBoundary(t *testing.T) { | |
| 164 | + | client := &continuationScriptClient{continuations: 12} | |
| 165 | + | agent := NewAgent(client, "test-model", "system", Registry{}) | |
| 166 | + | events := &continuationEvents{} | |
| 167 | + | ||
| 168 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 169 | + | defer cancel() | |
| 170 | + | var once sync.Once | |
| 171 | + | sink := func(ev AgentEvent) { | |
| 172 | + | events.sink(ev) | |
| 173 | + | if te, ok := ev.(EvTurnEnd); ok && te.Stop == provider.StopContinue { | |
| 174 | + | once.Do(cancel) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | err := agent.Prompt(ctx, "start", nil, sink) | |
| 178 | + | if !errors.Is(err, context.Canceled) { | |
| 179 | + | t.Fatalf("Prompt error = %v, want context.Canceled", err) | |
| 180 | + | } | |
| 181 | + | if got := client.Calls(); got != 1 { | |
| 182 | + | t.Fatalf("provider calls after cancel = %d, want 1", got) | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // TestAgentContinuationRespectsMaxSteps pins that configured step limits | |
| 187 | + | // still bound continuations with the existing typed error. | |
| 188 | + | func TestAgentContinuationRespectsMaxSteps(t *testing.T) { | |
| 189 | + | client := &continuationScriptClient{continuations: 12} | |
| 190 | + | agent := NewAgent(client, "test-model", "system", Registry{}) | |
| 191 | + | agent.MaxSteps = 2 | |
| 192 | + | ||
| 193 | + | err := agent.Prompt(context.Background(), "start", nil, nil) | |
| 194 | + | if !errors.Is(err, ErrMaxSteps) { | |
| 195 | + | t.Fatalf("Prompt error = %v, want ErrMaxSteps", err) | |
| 196 | + | } | |
| 197 | + | if got := client.Calls(); got != 2 { | |
| 198 | + | t.Fatalf("provider calls = %d, want 2", got) | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // TestAgentContinuationGuardBlocksNextRequest pins that turn guards stay | |
| 203 | + | // active across the continuation boundary. | |
| 204 | + | func TestAgentContinuationGuardBlocksNextRequest(t *testing.T) { | |
| 205 | + | client := &continuationScriptClient{continuations: 12} | |
| 206 | + | agent := NewAgent(client, "test-model", "system", Registry{}) | |
| 207 | + | agent.BeforeTurn = func(step int) (bool, string) { | |
| 208 | + | if step > 1 { | |
| 209 | + | return false, "blocked at step 2" | |
| 210 | + | } | |
| 211 | + | return true, "" | |
| 212 | + | } | |
| 213 | + | events := &continuationEvents{} | |
| 214 | + | ||
| 215 | + | if err := agent.Prompt(context.Background(), "start", nil, events.sink); err != nil { | |
| 216 | + | t.Fatalf("Prompt returned %v", err) | |
| 217 | + | } | |
| 218 | + | if got := client.Calls(); got != 1 { | |
| 219 | + | t.Fatalf("provider calls = %d, want 1 (guard blocks the next request)", got) | |
| 220 | + | } | |
| 221 | + | turnEnds, doneCount := events.snapshot() | |
| 222 | + | if doneCount != 1 { | |
| 223 | + | t.Fatalf("terminal EvDone count = %d, want 1", doneCount) | |
| 224 | + | } | |
| 225 | + | if len(turnEnds) != 2 { | |
| 226 | + | t.Fatalf("EvTurnEnd count = %d, want continuation + guard", len(turnEnds)) | |
| 227 | + | } | |
| 228 | + | if turnEnds[0].Stop != provider.StopContinue { | |
| 229 | + | t.Fatalf("first turn end = %q, want %q", turnEnds[0].Stop, provider.StopContinue) | |
| 230 | + | } | |
| 231 | + | if turnEnds[1].Stop != provider.StopError || turnEnds[1].Err == nil { | |
| 232 | + | t.Fatalf("guard turn end = (%q, %v), want (error, non-nil)", turnEnds[1].Stop, turnEnds[1].Err) | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | // TestAgentContinuationConsumesQueuedInput pins that user text queued | |
| 237 | + | // during a continuation is delivered at the normal boundary, answered by | |
| 238 | + | // the next inference, and never synthesized by the loop itself. | |
| 239 | + | func TestAgentContinuationConsumesQueuedInput(t *testing.T) { | |
| 240 | + | client := &continuationScriptClient{continuations: 1} | |
| 241 | + | agent := NewAgent(client, "test-model", "system", Registry{}) | |
| 242 | + | events := &continuationEvents{} | |
| 243 | + | ||
| 244 | + | var once sync.Once | |
| 245 | + | sink := func(ev AgentEvent) { | |
| 246 | + | events.sink(ev) | |
| 247 | + | if te, ok := ev.(EvTurnEnd); ok && te.Stop == provider.StopContinue { | |
| 248 | + | once.Do(func() { | |
| 249 | + | if !agent.QueueMessage("follow-up", nil) { | |
| 250 | + | t.Error("QueueMessage returned false") | |
| 251 | + | } | |
| 252 | + | }) | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| 256 | + | defer cancel() | |
| 257 | + | if err := agent.Prompt(ctx, "start", nil, sink); err != nil { | |
| 258 | + | t.Fatalf("Prompt returned %v", err) | |
| 259 | + | } | |
| 260 | + | if got := client.Calls(); got != 2 { | |
| 261 | + | t.Fatalf("provider calls = %d, want 2", got) | |
| 262 | + | } | |
| 263 | + | requests := client.Requests() | |
| 264 | + | if len(requests) != 2 { | |
| 265 | + | t.Fatalf("recorded requests = %d, want 2", len(requests)) | |
| 266 | + | } | |
| 267 | + | var sawQueued bool | |
| 268 | + | for _, m := range requests[1].Messages { | |
| 269 | + | if m.Role == provider.RoleUser && strings.Contains(extractText(m), "follow-up") { | |
| 270 | + | sawQueued = true | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | if !sawQueued { | |
| 274 | + | t.Fatalf("second request did not carry queued input: %#v", requests[1].Messages) | |
| 275 | + | } | |
| 276 | + | msgs := agent.Messages() | |
| 277 | + | var userCount int | |
| 278 | + | for _, m := range msgs { | |
| 279 | + | if m.Role == provider.RoleUser { | |
| 280 | + | userCount++ | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | if userCount != 2 { | |
| 284 | + | t.Fatalf("user messages = %d, want initial + queued", userCount) | |
| 285 | + | } | |
| 286 | + | if _, doneCount := events.snapshot(); doneCount != 1 { | |
| 287 | + | t.Fatalf("terminal EvDone count = %d, want 1", doneCount) | |
| 288 | + | } | |
| 289 | + | } | |
| @@ -0,0 +1,289 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync" | |||
| 9 | + | "sync/atomic" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/zut/packages/provider" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // continuationScriptClient replays a scripted stop reason per provider call | |||
| 17 | + | // and records every request for identity/history assertions. | |||
| 18 | + | type continuationScriptClient struct { | |||
| 19 | + | mu sync.Mutex | |||
| 20 | + | requests []provider.Request | |||
| 21 | + | calls int32 | |||
| 22 | + | // continuations is the number of leading calls that return | |||
| 23 | + | // StopContinue before one final StopEnd. | |||
| 24 | + | continuations int32 | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (c *continuationScriptClient) Name() string { return "continuation-script" } | |||
| 28 | + | ||||
| 29 | + | func (c *continuationScriptClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |||
| 30 | + | call := atomic.AddInt32(&c.calls, 1) | |||
| 31 | + | c.mu.Lock() | |||
| 32 | + | c.requests = append(c.requests, req) | |||
| 33 | + | c.mu.Unlock() | |||
| 34 | + | out := make(chan provider.Event, 4) | |||
| 35 | + | out <- provider.EventStart{Provider: c.Name(), Model: req.Model} | |||
| 36 | + | text := "final" | |||
| 37 | + | stop := provider.StopEnd | |||
| 38 | + | if call <= c.continuations { | |||
| 39 | + | text = fmt.Sprintf("part-%d", call) | |||
| 40 | + | stop = provider.StopContinue | |||
| 41 | + | } | |||
| 42 | + | out <- provider.EventTextDelta{Delta: text} | |||
| 43 | + | out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 1, OutputTokens: 1}} | |||
| 44 | + | out <- provider.EventDone{Stop: stop, Message: provider.Message{ | |||
| 45 | + | Role: provider.RoleAssistant, | |||
| 46 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |||
| 47 | + | }} | |||
| 48 | + | close(out) | |||
| 49 | + | return out, nil | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func (c *continuationScriptClient) Calls() int32 { return atomic.LoadInt32(&c.calls) } | |||
| 53 | + | ||||
| 54 | + | func (c *continuationScriptClient) Requests() []provider.Request { | |||
| 55 | + | c.mu.Lock() | |||
| 56 | + | defer c.mu.Unlock() | |||
| 57 | + | out := make([]provider.Request, len(c.requests)) | |||
| 58 | + | copy(out, c.requests) | |||
| 59 | + | return out | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | type continuationEvents struct { | |||
| 63 | + | mu sync.Mutex | |||
| 64 | + | turnEnds []EvTurnEnd | |||
| 65 | + | doneCount int | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (e *continuationEvents) sink(ev AgentEvent) { | |||
| 69 | + | e.mu.Lock() | |||
| 70 | + | defer e.mu.Unlock() | |||
| 71 | + | if te, ok := ev.(EvTurnEnd); ok { | |||
| 72 | + | e.turnEnds = append(e.turnEnds, te) | |||
| 73 | + | } | |||
| 74 | + | if _, ok := ev.(EvDone); ok { | |||
| 75 | + | e.doneCount++ | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (e *continuationEvents) snapshot() ([]EvTurnEnd, int) { | |||
| 80 | + | e.mu.Lock() | |||
| 81 | + | defer e.mu.Unlock() | |||
| 82 | + | out := make([]EvTurnEnd, len(e.turnEnds)) | |||
| 83 | + | copy(out, e.turnEnds) | |||
| 84 | + | return out, e.doneCount | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // TestAgentContinuationTwelveStepsThenDone pins AC3: 12 text-only explicit | |||
| 88 | + | // continuations followed by a final response produce 13 requests and one | |||
| 89 | + | // terminal completion, with no artificial user/tool rows. | |||
| 90 | + | func TestAgentContinuationTwelveStepsThenDone(t *testing.T) { | |||
| 91 | + | client := &continuationScriptClient{continuations: 12} | |||
| 92 | + | agent := NewAgent(client, "test-model", "system", Registry{}) | |||
| 93 | + | events := &continuationEvents{} | |||
| 94 | + | ||||
| 95 | + | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |||
| 96 | + | defer cancel() | |||
| 97 | + | if err := agent.Prompt(ctx, "start", nil, events.sink); err != nil { | |||
| 98 | + | t.Fatalf("Prompt returned %v", err) | |||
| 99 | + | } | |||
| 100 | + | if got := client.Calls(); got != 13 { | |||
| 101 | + | t.Fatalf("provider calls = %d, want 13", got) | |||
| 102 | + | } | |||
| 103 | + | turnEnds, doneCount := events.snapshot() | |||
| 104 | + | if doneCount != 1 { | |||
| 105 | + | t.Fatalf("terminal EvDone count = %d, want 1", doneCount) | |||
| 106 | + | } | |||
| 107 | + | if len(turnEnds) != 13 { | |||
| 108 | + | t.Fatalf("EvTurnEnd count = %d, want 13", len(turnEnds)) | |||
| 109 | + | } | |||
| 110 | + | for i, te := range turnEnds { | |||
| 111 | + | want := provider.StopContinue | |||
| 112 | + | if i == 12 { | |||
| 113 | + | want = provider.StopEnd | |||
| 114 | + | } | |||
| 115 | + | if te.Stop != want || te.Err != nil { | |||
| 116 | + | t.Fatalf("turn %d end = (%q, %v), want (%q, nil)", i+1, te.Stop, te.Err, want) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | msgs := agent.Messages() | |||
| 121 | + | if len(msgs) != 14 { | |||
| 122 | + | t.Fatalf("transcript messages = %d, want 1 user + 13 assistant", len(msgs)) | |||
| 123 | + | } | |||
| 124 | + | if msgs[0].Role != provider.RoleUser { | |||
| 125 | + | t.Fatalf("message 0 role = %q, want user", msgs[0].Role) | |||
| 126 | + | } | |||
| 127 | + | for i := 1; i < len(msgs); i++ { | |||
| 128 | + | if msgs[i].Role != provider.RoleAssistant { | |||
| 129 | + | t.Fatalf("message %d role = %q, want assistant (no artificial rows)", i, msgs[i].Role) | |||
| 130 | + | } | |||
| 131 | + | if len(msgs[i].Content) == 0 { | |||
| 132 | + | t.Fatalf("message %d has no content", i) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // Turn/thread identity is stable across the whole logical turn and | |||
| 137 | + | // every continuation sees prior assistant content. | |||
| 138 | + | requests := client.Requests() | |||
| 139 | + | turnID := requests[0].Context.TurnID | |||
| 140 | + | if turnID == "" { | |||
| 141 | + | t.Fatal("first request has empty turn ID") | |||
| 142 | + | } | |||
| 143 | + | for i, req := range requests { | |||
| 144 | + | if req.Context.TurnID != turnID { | |||
| 145 | + | t.Fatalf("request %d turn ID = %q, want stable %q", i, req.Context.TurnID, turnID) | |||
| 146 | + | } | |||
| 147 | + | if want := 1 + i; len(req.Messages) != want { | |||
| 148 | + | t.Fatalf("request %d messages = %d, want %d (prior content retained)", i, len(req.Messages), want) | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // Usage is counted once per inference. | |||
| 153 | + | if got := agent.Cost(); got.InputTokens != 13 || got.OutputTokens != 13 { | |||
| 154 | + | t.Fatalf("cumulative usage = %+v, want 13 input / 13 output", got) | |||
| 155 | + | } | |||
| 156 | + | if got := extractText(msgs[len(msgs)-1]); got != "final" { | |||
| 157 | + | t.Fatalf("final text = %q, want %q", got, "final") | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // TestAgentContinuationCanceledAtBoundary pins the canceled path: no | |||
| 162 | + | // further inference is requested and the cancellation error surfaces. | |||
| 163 | + | func TestAgentContinuationCanceledAtBoundary(t *testing.T) { | |||
| 164 | + | client := &continuationScriptClient{continuations: 12} | |||
| 165 | + | agent := NewAgent(client, "test-model", "system", Registry{}) | |||
| 166 | + | events := &continuationEvents{} | |||
| 167 | + | ||||
| 168 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 169 | + | defer cancel() | |||
| 170 | + | var once sync.Once | |||
| 171 | + | sink := func(ev AgentEvent) { | |||
| 172 | + | events.sink(ev) | |||
| 173 | + | if te, ok := ev.(EvTurnEnd); ok && te.Stop == provider.StopContinue { | |||
| 174 | + | once.Do(cancel) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | err := agent.Prompt(ctx, "start", nil, sink) | |||
| 178 | + | if !errors.Is(err, context.Canceled) { | |||
| 179 | + | t.Fatalf("Prompt error = %v, want context.Canceled", err) | |||
| 180 | + | } | |||
| 181 | + | if got := client.Calls(); got != 1 { | |||
| 182 | + | t.Fatalf("provider calls after cancel = %d, want 1", got) | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // TestAgentContinuationRespectsMaxSteps pins that configured step limits | |||
| 187 | + | // still bound continuations with the existing typed error. | |||
| 188 | + | func TestAgentContinuationRespectsMaxSteps(t *testing.T) { | |||
| 189 | + | client := &continuationScriptClient{continuations: 12} | |||
| 190 | + | agent := NewAgent(client, "test-model", "system", Registry{}) | |||
| 191 | + | agent.MaxSteps = 2 | |||
| 192 | + | ||||
| 193 | + | err := agent.Prompt(context.Background(), "start", nil, nil) | |||
| 194 | + | if !errors.Is(err, ErrMaxSteps) { | |||
| 195 | + | t.Fatalf("Prompt error = %v, want ErrMaxSteps", err) | |||
| 196 | + | } | |||
| 197 | + | if got := client.Calls(); got != 2 { | |||
| 198 | + | t.Fatalf("provider calls = %d, want 2", got) | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // TestAgentContinuationGuardBlocksNextRequest pins that turn guards stay | |||
| 203 | + | // active across the continuation boundary. | |||
| 204 | + | func TestAgentContinuationGuardBlocksNextRequest(t *testing.T) { | |||
| 205 | + | client := &continuationScriptClient{continuations: 12} | |||
| 206 | + | agent := NewAgent(client, "test-model", "system", Registry{}) | |||
| 207 | + | agent.BeforeTurn = func(step int) (bool, string) { | |||
| 208 | + | if step > 1 { | |||
| 209 | + | return false, "blocked at step 2" | |||
| 210 | + | } | |||
| 211 | + | return true, "" | |||
| 212 | + | } | |||
| 213 | + | events := &continuationEvents{} | |||
| 214 | + | ||||
| 215 | + | if err := agent.Prompt(context.Background(), "start", nil, events.sink); err != nil { | |||
| 216 | + | t.Fatalf("Prompt returned %v", err) | |||
| 217 | + | } | |||
| 218 | + | if got := client.Calls(); got != 1 { | |||
| 219 | + | t.Fatalf("provider calls = %d, want 1 (guard blocks the next request)", got) | |||
| 220 | + | } | |||
| 221 | + | turnEnds, doneCount := events.snapshot() | |||
| 222 | + | if doneCount != 1 { | |||
| 223 | + | t.Fatalf("terminal EvDone count = %d, want 1", doneCount) | |||
| 224 | + | } | |||
| 225 | + | if len(turnEnds) != 2 { | |||
| 226 | + | t.Fatalf("EvTurnEnd count = %d, want continuation + guard", len(turnEnds)) | |||
| 227 | + | } | |||
| 228 | + | if turnEnds[0].Stop != provider.StopContinue { | |||
| 229 | + | t.Fatalf("first turn end = %q, want %q", turnEnds[0].Stop, provider.StopContinue) | |||
| 230 | + | } | |||
| 231 | + | if turnEnds[1].Stop != provider.StopError || turnEnds[1].Err == nil { | |||
| 232 | + | t.Fatalf("guard turn end = (%q, %v), want (error, non-nil)", turnEnds[1].Stop, turnEnds[1].Err) | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | // TestAgentContinuationConsumesQueuedInput pins that user text queued | |||
| 237 | + | // during a continuation is delivered at the normal boundary, answered by | |||
| 238 | + | // the next inference, and never synthesized by the loop itself. | |||
| 239 | + | func TestAgentContinuationConsumesQueuedInput(t *testing.T) { | |||
| 240 | + | client := &continuationScriptClient{continuations: 1} | |||
| 241 | + | agent := NewAgent(client, "test-model", "system", Registry{}) | |||
| 242 | + | events := &continuationEvents{} | |||
| 243 | + | ||||
| 244 | + | var once sync.Once | |||
| 245 | + | sink := func(ev AgentEvent) { | |||
| 246 | + | events.sink(ev) | |||
| 247 | + | if te, ok := ev.(EvTurnEnd); ok && te.Stop == provider.StopContinue { | |||
| 248 | + | once.Do(func() { | |||
| 249 | + | if !agent.QueueMessage("follow-up", nil) { | |||
| 250 | + | t.Error("QueueMessage returned false") | |||
| 251 | + | } | |||
| 252 | + | }) | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |||
| 256 | + | defer cancel() | |||
| 257 | + | if err := agent.Prompt(ctx, "start", nil, sink); err != nil { | |||
| 258 | + | t.Fatalf("Prompt returned %v", err) | |||
| 259 | + | } | |||
| 260 | + | if got := client.Calls(); got != 2 { | |||
| 261 | + | t.Fatalf("provider calls = %d, want 2", got) | |||
| 262 | + | } | |||
| 263 | + | requests := client.Requests() | |||
| 264 | + | if len(requests) != 2 { | |||
| 265 | + | t.Fatalf("recorded requests = %d, want 2", len(requests)) | |||
| 266 | + | } | |||
| 267 | + | var sawQueued bool | |||
| 268 | + | for _, m := range requests[1].Messages { | |||
| 269 | + | if m.Role == provider.RoleUser && strings.Contains(extractText(m), "follow-up") { | |||
| 270 | + | sawQueued = true | |||
| 271 | + | } | |||
| 272 | + | } | |||
| 273 | + | if !sawQueued { | |||
| 274 | + | t.Fatalf("second request did not carry queued input: %#v", requests[1].Messages) | |||
| 275 | + | } | |||
| 276 | + | msgs := agent.Messages() | |||
| 277 | + | var userCount int | |||
| 278 | + | for _, m := range msgs { | |||
| 279 | + | if m.Role == provider.RoleUser { | |||
| 280 | + | userCount++ | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | if userCount != 2 { | |||
| 284 | + | t.Fatalf("user messages = %d, want initial + queued", userCount) | |||
| 285 | + | } | |||
| 286 | + | if _, doneCount := events.snapshot(); doneCount != 1 { | |||
| 287 | + | t.Fatalf("terminal EvDone count = %d, want 1", doneCount) | |||
| 288 | + | } | |||
| 289 | + | } | |||
A packages/core/agent_continuation_test.go +160 -0
| @@ -0,0 +1,160 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "io" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "strings" | |
| 10 | + | "sync" | |
| 11 | + | "sync/atomic" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/zut/packages/provider" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // continuationEchoTool is a deterministic test tool: it records how often | |
| 19 | + | // it ran so the continuation regression can assert exactly one execution. | |
| 20 | + | type continuationEchoTool struct { | |
| 21 | + | calls int32 | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (t *continuationEchoTool) Name() string { return "echo" } | |
| 25 | + | func (t *continuationEchoTool) Description() string { return "echoes" } | |
| 26 | + | func (t *continuationEchoTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) } | |
| 27 | + | ||
| 28 | + | func (t *continuationEchoTool) Execute(_ context.Context, _ json.RawMessage, _ func(string)) (ToolResult, error) { | |
| 29 | + | atomic.AddInt32(&t.calls, 1) | |
| 30 | + | return ToolResult{ | |
| 31 | + | Content: []provider.Content{provider.TextBlock{Text: "tool ok"}}, | |
| 32 | + | }, nil | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (t *continuationEchoTool) Calls() int32 { return atomic.LoadInt32(&t.calls) } | |
| 36 | + | ||
| 37 | + | // TestAgentContinuationExplicitEndTurnFalse scripts a real Responses SSE | |
| 38 | + | // stack: interim text with explicit end_turn:false, then a tool call, then | |
| 39 | + | // final text. The agent must issue three requests, execute the tool once, | |
| 40 | + | // keep transcript pairing/order without injected user rows, and finish with | |
| 41 | + | // exactly one terminal EvDone. | |
| 42 | + | func TestAgentContinuationExplicitEndTurnFalse(t *testing.T) { | |
| 43 | + | var ( | |
| 44 | + | mu sync.Mutex | |
| 45 | + | requests int | |
| 46 | + | ) | |
| 47 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 48 | + | mu.Lock() | |
| 49 | + | requests++ | |
| 50 | + | n := requests | |
| 51 | + | mu.Unlock() | |
| 52 | + | w.Header().Set("content-type", "text/event-stream") | |
| 53 | + | switch n { | |
| 54 | + | case 1: | |
| 55 | + | _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"message\"}}\n\n") | |
| 56 | + | _, _ = io.WriteString(w, "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"delta\":\"part one\"}\n\n") | |
| 57 | + | _, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"end_turn\":false,\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n") | |
| 58 | + | case 2: | |
| 59 | + | _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"echo\"}}\n\n") | |
| 60 | + | _, _ = io.WriteString(w, "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":\"{}\"}\n\n") | |
| 61 | + | _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"function_call\"}}\n\n") | |
| 62 | + | _, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-2\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n") | |
| 63 | + | default: | |
| 64 | + | _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"message\"}}\n\n") | |
| 65 | + | _, _ = io.WriteString(w, "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"delta\":\"final\"}\n\n") | |
| 66 | + | _, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-3\",\"end_turn\":true,\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n") | |
| 67 | + | } | |
| 68 | + | })) | |
| 69 | + | defer srv.Close() | |
| 70 | + | ||
| 71 | + | client := provider.NewOpenAIResponsesNamed("synthetic-key", srv.URL, "openai-responses") | |
| 72 | + | tool := &continuationEchoTool{} | |
| 73 | + | agent := NewAgent(client, "gpt-5.2", "system", Registry{"echo": tool}) | |
| 74 | + | ||
| 75 | + | var ( | |
| 76 | + | evMu sync.Mutex | |
| 77 | + | events []AgentEvent | |
| 78 | + | doneSeen int | |
| 79 | + | ) | |
| 80 | + | sink := func(ev AgentEvent) { | |
| 81 | + | evMu.Lock() | |
| 82 | + | defer evMu.Unlock() | |
| 83 | + | events = append(events, ev) | |
| 84 | + | if _, ok := ev.(EvDone); ok { | |
| 85 | + | doneSeen++ | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| 90 | + | defer cancel() | |
| 91 | + | if err := agent.Prompt(ctx, "start", nil, sink); err != nil { | |
| 92 | + | t.Fatalf("Prompt returned %v", err) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | mu.Lock() | |
| 96 | + | gotRequests := requests | |
| 97 | + | mu.Unlock() | |
| 98 | + | if gotRequests != 3 { | |
| 99 | + | t.Fatalf("provider requests = %d, want 3 (continuation + tool + final)", gotRequests) | |
| 100 | + | } | |
| 101 | + | if got := tool.Calls(); got != 1 { | |
| 102 | + | t.Fatalf("tool executions = %d, want 1", got) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | evMu.Lock() | |
| 106 | + | defer evMu.Unlock() | |
| 107 | + | if doneSeen != 1 { | |
| 108 | + | t.Fatalf("terminal EvDone count = %d, want 1", doneSeen) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // Transcript pairing/order: user, interim assistant, tool call, | |
| 112 | + | // tool result, final assistant. No injected user rows. | |
| 113 | + | msgs := agent.Messages() | |
| 114 | + | if len(msgs) != 5 { | |
| 115 | + | var roles []string | |
| 116 | + | for _, m := range msgs { | |
| 117 | + | roles = append(roles, string(m.Role)) | |
| 118 | + | } | |
| 119 | + | t.Fatalf("transcript messages = %d (%v), want 5", len(msgs), roles) | |
| 120 | + | } | |
| 121 | + | wantRoles := []provider.Role{ | |
| 122 | + | provider.RoleUser, | |
| 123 | + | provider.RoleAssistant, | |
| 124 | + | provider.RoleAssistant, | |
| 125 | + | provider.RoleTool, | |
| 126 | + | provider.RoleAssistant, | |
| 127 | + | } | |
| 128 | + | for i, want := range wantRoles { | |
| 129 | + | if msgs[i].Role != want { | |
| 130 | + | t.Fatalf("message %d role = %q, want %q", i, msgs[i].Role, want) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | if got := extractText(msgs[1]); got != "part one" { | |
| 134 | + | t.Fatalf("interim assistant text = %q, want %q", got, "part one") | |
| 135 | + | } | |
| 136 | + | var callID string | |
| 137 | + | for _, c := range msgs[2].Content { | |
| 138 | + | if tc, ok := c.(provider.ToolCallBlock); ok { | |
| 139 | + | callID = tc.ID | |
| 140 | + | if tc.Name != "echo" { | |
| 141 | + | t.Fatalf("tool call name = %q, want echo", tc.Name) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | if callID == "" { | |
| 146 | + | t.Fatalf("assistant tool message carries no tool call: %#v", msgs[2].Content) | |
| 147 | + | } | |
| 148 | + | var resultCallID string | |
| 149 | + | for _, c := range msgs[3].Content { | |
| 150 | + | if tr, ok := c.(provider.ToolResultBlock); ok { | |
| 151 | + | resultCallID = tr.CallID | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | if resultCallID != callID { | |
| 155 | + | t.Fatalf("tool result call ID = %q, want paired %q", resultCallID, callID) | |
| 156 | + | } | |
| 157 | + | if got := extractText(msgs[4]); !strings.Contains(got, "final") { | |
| 158 | + | t.Fatalf("final assistant text = %q, want it to contain %q", got, "final") | |
| 159 | + | } | |
| 160 | + | } | |
| @@ -0,0 +1,160 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "io" | |||
| 7 | + | "net/http" | |||
| 8 | + | "net/http/httptest" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync" | |||
| 11 | + | "sync/atomic" | |||
| 12 | + | "testing" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/zut/packages/provider" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // continuationEchoTool is a deterministic test tool: it records how often | |||
| 19 | + | // it ran so the continuation regression can assert exactly one execution. | |||
| 20 | + | type continuationEchoTool struct { | |||
| 21 | + | calls int32 | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func (t *continuationEchoTool) Name() string { return "echo" } | |||
| 25 | + | func (t *continuationEchoTool) Description() string { return "echoes" } | |||
| 26 | + | func (t *continuationEchoTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) } | |||
| 27 | + | ||||
| 28 | + | func (t *continuationEchoTool) Execute(_ context.Context, _ json.RawMessage, _ func(string)) (ToolResult, error) { | |||
| 29 | + | atomic.AddInt32(&t.calls, 1) | |||
| 30 | + | return ToolResult{ | |||
| 31 | + | Content: []provider.Content{provider.TextBlock{Text: "tool ok"}}, | |||
| 32 | + | }, nil | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (t *continuationEchoTool) Calls() int32 { return atomic.LoadInt32(&t.calls) } | |||
| 36 | + | ||||
| 37 | + | // TestAgentContinuationExplicitEndTurnFalse scripts a real Responses SSE | |||
| 38 | + | // stack: interim text with explicit end_turn:false, then a tool call, then | |||
| 39 | + | // final text. The agent must issue three requests, execute the tool once, | |||
| 40 | + | // keep transcript pairing/order without injected user rows, and finish with | |||
| 41 | + | // exactly one terminal EvDone. | |||
| 42 | + | func TestAgentContinuationExplicitEndTurnFalse(t *testing.T) { | |||
| 43 | + | var ( | |||
| 44 | + | mu sync.Mutex | |||
| 45 | + | requests int | |||
| 46 | + | ) | |||
| 47 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 48 | + | mu.Lock() | |||
| 49 | + | requests++ | |||
| 50 | + | n := requests | |||
| 51 | + | mu.Unlock() | |||
| 52 | + | w.Header().Set("content-type", "text/event-stream") | |||
| 53 | + | switch n { | |||
| 54 | + | case 1: | |||
| 55 | + | _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"message\"}}\n\n") | |||
| 56 | + | _, _ = io.WriteString(w, "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"delta\":\"part one\"}\n\n") | |||
| 57 | + | _, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"end_turn\":false,\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n") | |||
| 58 | + | case 2: | |||
| 59 | + | _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"echo\"}}\n\n") | |||
| 60 | + | _, _ = io.WriteString(w, "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":\"{}\"}\n\n") | |||
| 61 | + | _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"function_call\"}}\n\n") | |||
| 62 | + | _, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-2\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n") | |||
| 63 | + | default: | |||
| 64 | + | _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"message\"}}\n\n") | |||
| 65 | + | _, _ = io.WriteString(w, "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"delta\":\"final\"}\n\n") | |||
| 66 | + | _, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-3\",\"end_turn\":true,\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n") | |||
| 67 | + | } | |||
| 68 | + | })) | |||
| 69 | + | defer srv.Close() | |||
| 70 | + | ||||
| 71 | + | client := provider.NewOpenAIResponsesNamed("synthetic-key", srv.URL, "openai-responses") | |||
| 72 | + | tool := &continuationEchoTool{} | |||
| 73 | + | agent := NewAgent(client, "gpt-5.2", "system", Registry{"echo": tool}) | |||
| 74 | + | ||||
| 75 | + | var ( | |||
| 76 | + | evMu sync.Mutex | |||
| 77 | + | events []AgentEvent | |||
| 78 | + | doneSeen int | |||
| 79 | + | ) | |||
| 80 | + | sink := func(ev AgentEvent) { | |||
| 81 | + | evMu.Lock() | |||
| 82 | + | defer evMu.Unlock() | |||
| 83 | + | events = append(events, ev) | |||
| 84 | + | if _, ok := ev.(EvDone); ok { | |||
| 85 | + | doneSeen++ | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |||
| 90 | + | defer cancel() | |||
| 91 | + | if err := agent.Prompt(ctx, "start", nil, sink); err != nil { | |||
| 92 | + | t.Fatalf("Prompt returned %v", err) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | mu.Lock() | |||
| 96 | + | gotRequests := requests | |||
| 97 | + | mu.Unlock() | |||
| 98 | + | if gotRequests != 3 { | |||
| 99 | + | t.Fatalf("provider requests = %d, want 3 (continuation + tool + final)", gotRequests) | |||
| 100 | + | } | |||
| 101 | + | if got := tool.Calls(); got != 1 { | |||
| 102 | + | t.Fatalf("tool executions = %d, want 1", got) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | evMu.Lock() | |||
| 106 | + | defer evMu.Unlock() | |||
| 107 | + | if doneSeen != 1 { | |||
| 108 | + | t.Fatalf("terminal EvDone count = %d, want 1", doneSeen) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // Transcript pairing/order: user, interim assistant, tool call, | |||
| 112 | + | // tool result, final assistant. No injected user rows. | |||
| 113 | + | msgs := agent.Messages() | |||
| 114 | + | if len(msgs) != 5 { | |||
| 115 | + | var roles []string | |||
| 116 | + | for _, m := range msgs { | |||
| 117 | + | roles = append(roles, string(m.Role)) | |||
| 118 | + | } | |||
| 119 | + | t.Fatalf("transcript messages = %d (%v), want 5", len(msgs), roles) | |||
| 120 | + | } | |||
| 121 | + | wantRoles := []provider.Role{ | |||
| 122 | + | provider.RoleUser, | |||
| 123 | + | provider.RoleAssistant, | |||
| 124 | + | provider.RoleAssistant, | |||
| 125 | + | provider.RoleTool, | |||
| 126 | + | provider.RoleAssistant, | |||
| 127 | + | } | |||
| 128 | + | for i, want := range wantRoles { | |||
| 129 | + | if msgs[i].Role != want { | |||
| 130 | + | t.Fatalf("message %d role = %q, want %q", i, msgs[i].Role, want) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | if got := extractText(msgs[1]); got != "part one" { | |||
| 134 | + | t.Fatalf("interim assistant text = %q, want %q", got, "part one") | |||
| 135 | + | } | |||
| 136 | + | var callID string | |||
| 137 | + | for _, c := range msgs[2].Content { | |||
| 138 | + | if tc, ok := c.(provider.ToolCallBlock); ok { | |||
| 139 | + | callID = tc.ID | |||
| 140 | + | if tc.Name != "echo" { | |||
| 141 | + | t.Fatalf("tool call name = %q, want echo", tc.Name) | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | if callID == "" { | |||
| 146 | + | t.Fatalf("assistant tool message carries no tool call: %#v", msgs[2].Content) | |||
| 147 | + | } | |||
| 148 | + | var resultCallID string | |||
| 149 | + | for _, c := range msgs[3].Content { | |||
| 150 | + | if tr, ok := c.(provider.ToolResultBlock); ok { | |||
| 151 | + | resultCallID = tr.CallID | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | if resultCallID != callID { | |||
| 155 | + | t.Fatalf("tool result call ID = %q, want paired %q", resultCallID, callID) | |||
| 156 | + | } | |||
| 157 | + | if got := extractText(msgs[4]); !strings.Contains(got, "final") { | |||
| 158 | + | t.Fatalf("final assistant text = %q, want it to contain %q", got, "final") | |||
| 159 | + | } | |||
| 160 | + | } | |||
M packages/provider/openai_codex.go +5 -1
| @@ -801,7 +801,8 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques | |||
| 801 | 801 | ReasoningTokens int `json:"reasoning_tokens"` | |
| 802 | 802 | } `json:"output_tokens_details"` | |
| 803 | 803 | } `json:"usage"` | |
| 804 | - | Status string `json:"status"` | |
| 804 | + | Status string `json:"status"` | |
| 805 | + | EndTurn *bool `json:"end_turn"` | |
| 805 | 806 | } `json:"response"` | |
| 806 | 807 | } | |
| 807 | 808 | _ = json.Unmarshal([]byte(ev.Data), &p) | |
| @@ -822,6 +823,9 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques | |||
| 822 | 823 | stop = StopToolUse | |
| 823 | 824 | } else { | |
| 824 | 825 | stop = StopEnd | |
| 826 | + | if p.Response.EndTurn != nil && !*p.Response.EndTurn { | |
| 827 | + | stop = StopContinue | |
| 828 | + | } | |
| 825 | 829 | } | |
| 826 | 830 | if completed != nil && p.Response.ID != "" { | |
| 827 | 831 | completed(p.Response.ID, assemble()) | |
| @@ -801,7 +801,8 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques | |||||
| 801 | ReasoningTokens int `json:"reasoning_tokens"` | 801 | ReasoningTokens int `json:"reasoning_tokens"` | ||
| 802 | } `json:"output_tokens_details"` | 802 | } `json:"output_tokens_details"` | ||
| 803 | } `json:"usage"` | 803 | } `json:"usage"` | ||
| 804 | - | Status string `json:"status"` | 804 | + | Status string `json:"status"` |
| 805 | + | EndTurn *bool `json:"end_turn"` | |||
| 805 | } `json:"response"` | 806 | } `json:"response"` | ||
| 806 | } | 807 | } | ||
| 807 | _ = json.Unmarshal([]byte(ev.Data), &p) | 808 | _ = json.Unmarshal([]byte(ev.Data), &p) | ||
| @@ -822,6 +823,9 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques | |||||
| 822 | stop = StopToolUse | 823 | stop = StopToolUse | ||
| 823 | } else { | 824 | } else { | ||
| 824 | stop = StopEnd | 825 | stop = StopEnd | ||
| 826 | + | if p.Response.EndTurn != nil && !*p.Response.EndTurn { | |||
| 827 | + | stop = StopContinue | |||
| 828 | + | } | |||
| 825 | } | 829 | } | ||
| 826 | if completed != nil && p.Response.ID != "" { | 830 | if completed != nil && p.Response.ID != "" { | ||
| 827 | completed(p.Response.ID, assemble()) | 831 | completed(p.Response.ID, assemble()) | ||
A packages/provider/openai_responses_continuation_test.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // runResponsesCompletionFixture feeds scripted Responses SSE payloads | |
| 10 | + | // through the shared completion parser and returns the terminal event | |
| 11 | + | // plus the reported usage. | |
| 12 | + | func runResponsesCompletionFixture(t *testing.T, payloads ...string) (EventDone, Usage) { | |
| 13 | + | t.Helper() | |
| 14 | + | c := newOpenAICodexClient("token", "acct", "") | |
| 15 | + | raw := make(chan sseEvent, len(payloads)+1) | |
| 16 | + | for _, payload := range payloads { | |
| 17 | + | raw <- sseEvent{Data: payload} | |
| 18 | + | } | |
| 19 | + | close(raw) | |
| 20 | + | out := make(chan Event, 32) | |
| 21 | + | go c.runResponseEventsWithFirst(context.Background(), Request{Model: "gpt-5.2"}, out, raw, nil, nil) | |
| 22 | + | var usage Usage | |
| 23 | + | for ev := range out { | |
| 24 | + | switch e := ev.(type) { | |
| 25 | + | case EventUsage: | |
| 26 | + | usage = e.Usage | |
| 27 | + | case EventDone: | |
| 28 | + | return e, usage | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | t.Fatal("parser stream ended without EventDone") | |
| 32 | + | return EventDone{}, Usage{} | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func completedPayload(event, response string) string { | |
| 36 | + | return `{"type":"` + event + `","response":{` + response + `}}` | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestResponsesContinuation(t *testing.T) { | |
| 40 | + | textItem := []string{ | |
| 41 | + | `{"type":"response.output_item.added","output_index":0,"item":{"type":"message"}}`, | |
| 42 | + | `{"type":"response.output_text.delta","output_index":0,"delta":"part one"}`, | |
| 43 | + | } | |
| 44 | + | usage := `"id":"resp-1","usage":{"input_tokens":3,"output_tokens":4}` | |
| 45 | + | ||
| 46 | + | for _, tt := range []struct { | |
| 47 | + | name string | |
| 48 | + | event string | |
| 49 | + | endTurn string // raw end_turn field, empty when absent | |
| 50 | + | want StopReason | |
| 51 | + | }{ | |
| 52 | + | {name: "explicit false continues", event: "response.completed", endTurn: `"end_turn":false`, want: StopContinue}, | |
| 53 | + | {name: "done alias continues", event: "response.done", endTurn: `"end_turn":false`, want: StopContinue}, | |
| 54 | + | {name: "explicit true ends", event: "response.completed", endTurn: `"end_turn":true`, want: StopEnd}, | |
| 55 | + | {name: "absent ends", event: "response.completed", endTurn: ``, want: StopEnd}, | |
| 56 | + | {name: "null ends", event: "response.completed", endTurn: `"end_turn":null`, want: StopEnd}, | |
| 57 | + | } { | |
| 58 | + | t.Run(tt.name, func(t *testing.T) { | |
| 59 | + | response := usage | |
| 60 | + | if tt.endTurn != "" { | |
| 61 | + | response += "," + tt.endTurn | |
| 62 | + | } | |
| 63 | + | payloads := append(append([]string{}, textItem...), completedPayload(tt.event, response)) | |
| 64 | + | done, gotUsage := runResponsesCompletionFixture(t, payloads...) | |
| 65 | + | if done.Stop != tt.want { | |
| 66 | + | t.Fatalf("stop = %q, want %q", done.Stop, tt.want) | |
| 67 | + | } | |
| 68 | + | if done.Err != nil { | |
| 69 | + | t.Fatalf("err = %v, want nil", done.Err) | |
| 70 | + | } | |
| 71 | + | if gotUsage.InputTokens != 3 || gotUsage.OutputTokens != 4 { | |
| 72 | + | t.Fatalf("usage = %+v, want input 3 output 4", gotUsage) | |
| 73 | + | } | |
| 74 | + | var text strings.Builder | |
| 75 | + | for _, c := range done.Message.Content { | |
| 76 | + | if tb, ok := c.(TextBlock); ok { | |
| 77 | + | text.WriteString(tb.Text) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | if text.String() != "part one" { | |
| 81 | + | t.Fatalf("text = %q, want %q", text.String(), "part one") | |
| 82 | + | } | |
| 83 | + | }) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestResponsesContinuationReasoningOnly(t *testing.T) { | |
| 88 | + | done, _ := runResponsesCompletionFixture(t, | |
| 89 | + | `{"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_1","encrypted_content":"blob"}}`, | |
| 90 | + | completedPayload("response.completed", `"id":"resp-1","end_turn":false,"usage":{"input_tokens":1,"output_tokens":1}`), | |
| 91 | + | ) | |
| 92 | + | if done.Stop != StopContinue { | |
| 93 | + | t.Fatalf("stop = %q, want %q", done.Stop, StopContinue) | |
| 94 | + | } | |
| 95 | + | var sawReasoning bool | |
| 96 | + | for _, c := range done.Message.Content { | |
| 97 | + | if rb, ok := c.(ReasoningBlock); ok && rb.ID == "rs_1" && rb.Encrypted == "blob" { | |
| 98 | + | sawReasoning = true | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | if !sawReasoning { | |
| 102 | + | t.Fatalf("reasoning not preserved: %#v", done.Message.Content) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestResponsesContinuationToolCallPrecedence(t *testing.T) { | |
| 107 | + | toolItem := []string{ | |
| 108 | + | `{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call-1","name":"echo"}}`, | |
| 109 | + | `{"type":"response.function_call_arguments.delta","output_index":0,"delta":"{}"}`, | |
| 110 | + | `{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call"}}`, | |
| 111 | + | } | |
| 112 | + | for _, endTurn := range []string{`"end_turn":false`, `"end_turn":true`, ``} { | |
| 113 | + | response := `"id":"resp-1","usage":{"input_tokens":1,"output_tokens":1}` | |
| 114 | + | if endTurn != "" { | |
| 115 | + | response += "," + endTurn | |
| 116 | + | } | |
| 117 | + | payloads := append(append([]string{}, toolItem...), | |
| 118 | + | completedPayload("response.completed", response)) | |
| 119 | + | done, _ := runResponsesCompletionFixture(t, payloads...) | |
| 120 | + | if done.Stop != StopToolUse { | |
| 121 | + | t.Fatalf("end_turn %s with tool call: stop = %q, want %q", endTurn, done.Stop, StopToolUse) | |
| 122 | + | } | |
| 123 | + | var sawCall bool | |
| 124 | + | for _, c := range done.Message.Content { | |
| 125 | + | if tc, ok := c.(ToolCallBlock); ok && tc.ID == "call-1" && tc.Name == "echo" { | |
| 126 | + | sawCall = true | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | if !sawCall { | |
| 130 | + | t.Fatalf("tool call not preserved: %#v", done.Message.Content) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestResponsesContinuationPreservesErrorPaths(t *testing.T) { | |
| 136 | + | done, _ := runResponsesCompletionFixture(t, | |
| 137 | + | `{"type":"response.failed","response":{"error":{"message":"boom"}}}`, | |
| 138 | + | ) | |
| 139 | + | if done.Stop != StopError || done.Err == nil { | |
| 140 | + | t.Fatalf("failed response: stop = %q err = %v, want error", done.Stop, done.Err) | |
| 141 | + | } | |
| 142 | + | } | |
| @@ -0,0 +1,142 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // runResponsesCompletionFixture feeds scripted Responses SSE payloads | |||
| 10 | + | // through the shared completion parser and returns the terminal event | |||
| 11 | + | // plus the reported usage. | |||
| 12 | + | func runResponsesCompletionFixture(t *testing.T, payloads ...string) (EventDone, Usage) { | |||
| 13 | + | t.Helper() | |||
| 14 | + | c := newOpenAICodexClient("token", "acct", "") | |||
| 15 | + | raw := make(chan sseEvent, len(payloads)+1) | |||
| 16 | + | for _, payload := range payloads { | |||
| 17 | + | raw <- sseEvent{Data: payload} | |||
| 18 | + | } | |||
| 19 | + | close(raw) | |||
| 20 | + | out := make(chan Event, 32) | |||
| 21 | + | go c.runResponseEventsWithFirst(context.Background(), Request{Model: "gpt-5.2"}, out, raw, nil, nil) | |||
| 22 | + | var usage Usage | |||
| 23 | + | for ev := range out { | |||
| 24 | + | switch e := ev.(type) { | |||
| 25 | + | case EventUsage: | |||
| 26 | + | usage = e.Usage | |||
| 27 | + | case EventDone: | |||
| 28 | + | return e, usage | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | t.Fatal("parser stream ended without EventDone") | |||
| 32 | + | return EventDone{}, Usage{} | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func completedPayload(event, response string) string { | |||
| 36 | + | return `{"type":"` + event + `","response":{` + response + `}}` | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestResponsesContinuation(t *testing.T) { | |||
| 40 | + | textItem := []string{ | |||
| 41 | + | `{"type":"response.output_item.added","output_index":0,"item":{"type":"message"}}`, | |||
| 42 | + | `{"type":"response.output_text.delta","output_index":0,"delta":"part one"}`, | |||
| 43 | + | } | |||
| 44 | + | usage := `"id":"resp-1","usage":{"input_tokens":3,"output_tokens":4}` | |||
| 45 | + | ||||
| 46 | + | for _, tt := range []struct { | |||
| 47 | + | name string | |||
| 48 | + | event string | |||
| 49 | + | endTurn string // raw end_turn field, empty when absent | |||
| 50 | + | want StopReason | |||
| 51 | + | }{ | |||
| 52 | + | {name: "explicit false continues", event: "response.completed", endTurn: `"end_turn":false`, want: StopContinue}, | |||
| 53 | + | {name: "done alias continues", event: "response.done", endTurn: `"end_turn":false`, want: StopContinue}, | |||
| 54 | + | {name: "explicit true ends", event: "response.completed", endTurn: `"end_turn":true`, want: StopEnd}, | |||
| 55 | + | {name: "absent ends", event: "response.completed", endTurn: ``, want: StopEnd}, | |||
| 56 | + | {name: "null ends", event: "response.completed", endTurn: `"end_turn":null`, want: StopEnd}, | |||
| 57 | + | } { | |||
| 58 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 59 | + | response := usage | |||
| 60 | + | if tt.endTurn != "" { | |||
| 61 | + | response += "," + tt.endTurn | |||
| 62 | + | } | |||
| 63 | + | payloads := append(append([]string{}, textItem...), completedPayload(tt.event, response)) | |||
| 64 | + | done, gotUsage := runResponsesCompletionFixture(t, payloads...) | |||
| 65 | + | if done.Stop != tt.want { | |||
| 66 | + | t.Fatalf("stop = %q, want %q", done.Stop, tt.want) | |||
| 67 | + | } | |||
| 68 | + | if done.Err != nil { | |||
| 69 | + | t.Fatalf("err = %v, want nil", done.Err) | |||
| 70 | + | } | |||
| 71 | + | if gotUsage.InputTokens != 3 || gotUsage.OutputTokens != 4 { | |||
| 72 | + | t.Fatalf("usage = %+v, want input 3 output 4", gotUsage) | |||
| 73 | + | } | |||
| 74 | + | var text strings.Builder | |||
| 75 | + | for _, c := range done.Message.Content { | |||
| 76 | + | if tb, ok := c.(TextBlock); ok { | |||
| 77 | + | text.WriteString(tb.Text) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | if text.String() != "part one" { | |||
| 81 | + | t.Fatalf("text = %q, want %q", text.String(), "part one") | |||
| 82 | + | } | |||
| 83 | + | }) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestResponsesContinuationReasoningOnly(t *testing.T) { | |||
| 88 | + | done, _ := runResponsesCompletionFixture(t, | |||
| 89 | + | `{"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_1","encrypted_content":"blob"}}`, | |||
| 90 | + | completedPayload("response.completed", `"id":"resp-1","end_turn":false,"usage":{"input_tokens":1,"output_tokens":1}`), | |||
| 91 | + | ) | |||
| 92 | + | if done.Stop != StopContinue { | |||
| 93 | + | t.Fatalf("stop = %q, want %q", done.Stop, StopContinue) | |||
| 94 | + | } | |||
| 95 | + | var sawReasoning bool | |||
| 96 | + | for _, c := range done.Message.Content { | |||
| 97 | + | if rb, ok := c.(ReasoningBlock); ok && rb.ID == "rs_1" && rb.Encrypted == "blob" { | |||
| 98 | + | sawReasoning = true | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | if !sawReasoning { | |||
| 102 | + | t.Fatalf("reasoning not preserved: %#v", done.Message.Content) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestResponsesContinuationToolCallPrecedence(t *testing.T) { | |||
| 107 | + | toolItem := []string{ | |||
| 108 | + | `{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call-1","name":"echo"}}`, | |||
| 109 | + | `{"type":"response.function_call_arguments.delta","output_index":0,"delta":"{}"}`, | |||
| 110 | + | `{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call"}}`, | |||
| 111 | + | } | |||
| 112 | + | for _, endTurn := range []string{`"end_turn":false`, `"end_turn":true`, ``} { | |||
| 113 | + | response := `"id":"resp-1","usage":{"input_tokens":1,"output_tokens":1}` | |||
| 114 | + | if endTurn != "" { | |||
| 115 | + | response += "," + endTurn | |||
| 116 | + | } | |||
| 117 | + | payloads := append(append([]string{}, toolItem...), | |||
| 118 | + | completedPayload("response.completed", response)) | |||
| 119 | + | done, _ := runResponsesCompletionFixture(t, payloads...) | |||
| 120 | + | if done.Stop != StopToolUse { | |||
| 121 | + | t.Fatalf("end_turn %s with tool call: stop = %q, want %q", endTurn, done.Stop, StopToolUse) | |||
| 122 | + | } | |||
| 123 | + | var sawCall bool | |||
| 124 | + | for _, c := range done.Message.Content { | |||
| 125 | + | if tc, ok := c.(ToolCallBlock); ok && tc.ID == "call-1" && tc.Name == "echo" { | |||
| 126 | + | sawCall = true | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | if !sawCall { | |||
| 130 | + | t.Fatalf("tool call not preserved: %#v", done.Message.Content) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func TestResponsesContinuationPreservesErrorPaths(t *testing.T) { | |||
| 136 | + | done, _ := runResponsesCompletionFixture(t, | |||
| 137 | + | `{"type":"response.failed","response":{"error":{"message":"boom"}}}`, | |||
| 138 | + | ) | |||
| 139 | + | if done.Stop != StopError || done.Err == nil { | |||
| 140 | + | t.Fatalf("failed response: stop = %q err = %v, want error", done.Stop, done.Err) | |||
| 141 | + | } | |||
| 142 | + | } | |||
A packages/provider/openai_responses_continuation_websocket_test.go +135 -0
| @@ -0,0 +1,135 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "net/http/httptest" | |
| 7 | + | "strings" | |
| 8 | + | "sync" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "golang.org/x/net/websocket" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // newContinuationWebSocketServer serves two sequential Responses on one | |
| 15 | + | // WebSocket connection: interim text with explicit end_turn:false, then | |
| 16 | + | // final text with no end_turn field (missing still terminates). | |
| 17 | + | func newContinuationWebSocketServer(t *testing.T, mu *sync.Mutex, requests *[]map[string]any) *httptest.Server { | |
| 18 | + | t.Helper() | |
| 19 | + | texts := []string{"part one", "final"} | |
| 20 | + | endTurns := []any{false, nil} | |
| 21 | + | return httptest.NewServer(websocket.Handler(func(conn *websocket.Conn) { | |
| 22 | + | defer conn.Close() | |
| 23 | + | for i := 0; i < 2; i++ { | |
| 24 | + | var payload map[string]any | |
| 25 | + | if err := websocket.JSON.Receive(conn, &payload); err != nil { | |
| 26 | + | t.Errorf("receive websocket request: %v", err) | |
| 27 | + | return | |
| 28 | + | } | |
| 29 | + | mu.Lock() | |
| 30 | + | *requests = append(*requests, payload) | |
| 31 | + | mu.Unlock() | |
| 32 | + | response := map[string]any{ | |
| 33 | + | "id": "resp_" + string(rune('1'+i)), | |
| 34 | + | "usage": map[string]any{"input_tokens": 1, "output_tokens": 1}, | |
| 35 | + | } | |
| 36 | + | if endTurns[i] != nil { | |
| 37 | + | response["end_turn"] = endTurns[i] | |
| 38 | + | } | |
| 39 | + | for _, event := range []map[string]any{ | |
| 40 | + | {"type": "response.created", "response": map[string]any{"id": response["id"]}}, | |
| 41 | + | {"type": "response.output_item.added", "output_index": 0, "item": map[string]any{"type": "message"}}, | |
| 42 | + | {"type": "response.output_text.delta", "output_index": 0, "delta": texts[i]}, | |
| 43 | + | {"type": "response.completed", "response": response}, | |
| 44 | + | } { | |
| 45 | + | _ = websocket.JSON.Send(conn, event) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | })) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // streamContinuationResponse collects one Responses stream into its stop | |
| 52 | + | // reason and assembled text. | |
| 53 | + | func streamContinuationResponse(t *testing.T, client Client, req Request) (StopReason, string) { | |
| 54 | + | t.Helper() | |
| 55 | + | stream, err := client.Stream(context.Background(), req) | |
| 56 | + | if err != nil { | |
| 57 | + | t.Fatal(err) | |
| 58 | + | } | |
| 59 | + | var ( | |
| 60 | + | done EventDone | |
| 61 | + | text strings.Builder | |
| 62 | + | ) | |
| 63 | + | for event := range stream { | |
| 64 | + | switch e := event.(type) { | |
| 65 | + | case EventTextDelta: | |
| 66 | + | text.WriteString(e.Delta) | |
| 67 | + | case EventDone: | |
| 68 | + | done = e | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | if done.Err != nil { | |
| 72 | + | t.Fatalf("stream error: %v", done.Err) | |
| 73 | + | } | |
| 74 | + | return done.Stop, text.String() | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // TestResponsesContinuationWebSocket pins explicit continuation over the | |
| 78 | + | // Responses WebSocket transport: the first response carries | |
| 79 | + | // end_turn:false, the second request reuses the connection with the | |
| 80 | + | // expected previous-response/incremental state, and the second response | |
| 81 | + | // terminates normally. | |
| 82 | + | func TestResponsesContinuationWebSocket(t *testing.T) { | |
| 83 | + | var ( | |
| 84 | + | mu sync.Mutex | |
| 85 | + | requests []map[string]any | |
| 86 | + | ) | |
| 87 | + | server := newContinuationWebSocketServer(t, &mu, &requests) | |
| 88 | + | defer server.Close() | |
| 89 | + | ||
| 90 | + | inner := &codexClient{ | |
| 91 | + | token: "test-token", | |
| 92 | + | baseURL: server.URL + "/v1/responses", | |
| 93 | + | providerName: "openai", | |
| 94 | + | capabilities: responsesCapabilities{StablePromptCacheKey: true}, | |
| 95 | + | } | |
| 96 | + | client := newResponsesWebSocketClient(inner) | |
| 97 | + | ||
| 98 | + | first := Request{ | |
| 99 | + | Model: "gpt-5.6-sol", | |
| 100 | + | Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"}, | |
| 101 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}, | |
| 102 | + | } | |
| 103 | + | if stop, text := streamContinuationResponse(t, client, first); stop != StopContinue || text != "part one" { | |
| 104 | + | t.Fatalf("first response = (%q, %q), want (continue, part one)", stop, text) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | second := Request{ | |
| 108 | + | Model: "gpt-5.6-sol", | |
| 109 | + | Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"}, | |
| 110 | + | Messages: append(append([]Message(nil), first.Messages...), | |
| 111 | + | Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "part one"}}}, | |
| 112 | + | Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}}, | |
| 113 | + | ), | |
| 114 | + | } | |
| 115 | + | if stop, text := streamContinuationResponse(t, client, second); stop != StopEnd || text != "final" { | |
| 116 | + | t.Fatalf("second response = (%q, %q), want (end, final)", stop, text) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | mu.Lock() | |
| 120 | + | defer mu.Unlock() | |
| 121 | + | if len(requests) != 2 { | |
| 122 | + | t.Fatalf("websocket requests = %d, want 2 on one connection", len(requests)) | |
| 123 | + | } | |
| 124 | + | if got := requests[1]["previous_response_id"]; got != "resp_1" { | |
| 125 | + | t.Fatalf("previous_response_id = %#v, want resp_1", got) | |
| 126 | + | } | |
| 127 | + | input, ok := requests[1]["input"].([]any) | |
| 128 | + | if !ok || len(input) != 1 { | |
| 129 | + | t.Fatalf("incremental input = %#v", requests[1]["input"]) | |
| 130 | + | } | |
| 131 | + | encoded, _ := json.Marshal(input[0]) | |
| 132 | + | if !strings.Contains(string(encoded), "second") || strings.Contains(string(encoded), "part one") { | |
| 133 | + | t.Fatalf("incremental input = %s", encoded) | |
| 134 | + | } | |
| 135 | + | } | |
| @@ -0,0 +1,135 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "net/http/httptest" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "golang.org/x/net/websocket" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // newContinuationWebSocketServer serves two sequential Responses on one | |||
| 15 | + | // WebSocket connection: interim text with explicit end_turn:false, then | |||
| 16 | + | // final text with no end_turn field (missing still terminates). | |||
| 17 | + | func newContinuationWebSocketServer(t *testing.T, mu *sync.Mutex, requests *[]map[string]any) *httptest.Server { | |||
| 18 | + | t.Helper() | |||
| 19 | + | texts := []string{"part one", "final"} | |||
| 20 | + | endTurns := []any{false, nil} | |||
| 21 | + | return httptest.NewServer(websocket.Handler(func(conn *websocket.Conn) { | |||
| 22 | + | defer conn.Close() | |||
| 23 | + | for i := 0; i < 2; i++ { | |||
| 24 | + | var payload map[string]any | |||
| 25 | + | if err := websocket.JSON.Receive(conn, &payload); err != nil { | |||
| 26 | + | t.Errorf("receive websocket request: %v", err) | |||
| 27 | + | return | |||
| 28 | + | } | |||
| 29 | + | mu.Lock() | |||
| 30 | + | *requests = append(*requests, payload) | |||
| 31 | + | mu.Unlock() | |||
| 32 | + | response := map[string]any{ | |||
| 33 | + | "id": "resp_" + string(rune('1'+i)), | |||
| 34 | + | "usage": map[string]any{"input_tokens": 1, "output_tokens": 1}, | |||
| 35 | + | } | |||
| 36 | + | if endTurns[i] != nil { | |||
| 37 | + | response["end_turn"] = endTurns[i] | |||
| 38 | + | } | |||
| 39 | + | for _, event := range []map[string]any{ | |||
| 40 | + | {"type": "response.created", "response": map[string]any{"id": response["id"]}}, | |||
| 41 | + | {"type": "response.output_item.added", "output_index": 0, "item": map[string]any{"type": "message"}}, | |||
| 42 | + | {"type": "response.output_text.delta", "output_index": 0, "delta": texts[i]}, | |||
| 43 | + | {"type": "response.completed", "response": response}, | |||
| 44 | + | } { | |||
| 45 | + | _ = websocket.JSON.Send(conn, event) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | })) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // streamContinuationResponse collects one Responses stream into its stop | |||
| 52 | + | // reason and assembled text. | |||
| 53 | + | func streamContinuationResponse(t *testing.T, client Client, req Request) (StopReason, string) { | |||
| 54 | + | t.Helper() | |||
| 55 | + | stream, err := client.Stream(context.Background(), req) | |||
| 56 | + | if err != nil { | |||
| 57 | + | t.Fatal(err) | |||
| 58 | + | } | |||
| 59 | + | var ( | |||
| 60 | + | done EventDone | |||
| 61 | + | text strings.Builder | |||
| 62 | + | ) | |||
| 63 | + | for event := range stream { | |||
| 64 | + | switch e := event.(type) { | |||
| 65 | + | case EventTextDelta: | |||
| 66 | + | text.WriteString(e.Delta) | |||
| 67 | + | case EventDone: | |||
| 68 | + | done = e | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | if done.Err != nil { | |||
| 72 | + | t.Fatalf("stream error: %v", done.Err) | |||
| 73 | + | } | |||
| 74 | + | return done.Stop, text.String() | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // TestResponsesContinuationWebSocket pins explicit continuation over the | |||
| 78 | + | // Responses WebSocket transport: the first response carries | |||
| 79 | + | // end_turn:false, the second request reuses the connection with the | |||
| 80 | + | // expected previous-response/incremental state, and the second response | |||
| 81 | + | // terminates normally. | |||
| 82 | + | func TestResponsesContinuationWebSocket(t *testing.T) { | |||
| 83 | + | var ( | |||
| 84 | + | mu sync.Mutex | |||
| 85 | + | requests []map[string]any | |||
| 86 | + | ) | |||
| 87 | + | server := newContinuationWebSocketServer(t, &mu, &requests) | |||
| 88 | + | defer server.Close() | |||
| 89 | + | ||||
| 90 | + | inner := &codexClient{ | |||
| 91 | + | token: "test-token", | |||
| 92 | + | baseURL: server.URL + "/v1/responses", | |||
| 93 | + | providerName: "openai", | |||
| 94 | + | capabilities: responsesCapabilities{StablePromptCacheKey: true}, | |||
| 95 | + | } | |||
| 96 | + | client := newResponsesWebSocketClient(inner) | |||
| 97 | + | ||||
| 98 | + | first := Request{ | |||
| 99 | + | Model: "gpt-5.6-sol", | |||
| 100 | + | Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"}, | |||
| 101 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}, | |||
| 102 | + | } | |||
| 103 | + | if stop, text := streamContinuationResponse(t, client, first); stop != StopContinue || text != "part one" { | |||
| 104 | + | t.Fatalf("first response = (%q, %q), want (continue, part one)", stop, text) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | second := Request{ | |||
| 108 | + | Model: "gpt-5.6-sol", | |||
| 109 | + | Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"}, | |||
| 110 | + | Messages: append(append([]Message(nil), first.Messages...), | |||
| 111 | + | Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "part one"}}}, | |||
| 112 | + | Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}}, | |||
| 113 | + | ), | |||
| 114 | + | } | |||
| 115 | + | if stop, text := streamContinuationResponse(t, client, second); stop != StopEnd || text != "final" { | |||
| 116 | + | t.Fatalf("second response = (%q, %q), want (end, final)", stop, text) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | mu.Lock() | |||
| 120 | + | defer mu.Unlock() | |||
| 121 | + | if len(requests) != 2 { | |||
| 122 | + | t.Fatalf("websocket requests = %d, want 2 on one connection", len(requests)) | |||
| 123 | + | } | |||
| 124 | + | if got := requests[1]["previous_response_id"]; got != "resp_1" { | |||
| 125 | + | t.Fatalf("previous_response_id = %#v, want resp_1", got) | |||
| 126 | + | } | |||
| 127 | + | input, ok := requests[1]["input"].([]any) | |||
| 128 | + | if !ok || len(input) != 1 { | |||
| 129 | + | t.Fatalf("incremental input = %#v", requests[1]["input"]) | |||
| 130 | + | } | |||
| 131 | + | encoded, _ := json.Marshal(input[0]) | |||
| 132 | + | if !strings.Contains(string(encoded), "second") || strings.Contains(string(encoded), "part one") { | |||
| 133 | + | t.Fatalf("incremental input = %s", encoded) | |||
| 134 | + | } | |||
| 135 | + | } | |||
M packages/provider/provider.go +6 -5
| @@ -225,11 +225,12 @@ func (u Usage) Add(v Usage) Usage { | |||
| 225 | 225 | type StopReason string | |
| 226 | 226 | ||
| 227 | 227 | const ( | |
| 228 | - | StopEnd StopReason = "end" | |
| 229 | - | StopLength StopReason = "length" | |
| 230 | - | StopToolUse StopReason = "tool_use" | |
| 231 | - | StopError StopReason = "error" | |
| 232 | - | StopAborted StopReason = "aborted" | |
| 228 | + | StopEnd StopReason = "end" | |
| 229 | + | StopContinue StopReason = "continue" | |
| 230 | + | StopLength StopReason = "length" | |
| 231 | + | StopToolUse StopReason = "tool_use" | |
| 232 | + | StopError StopReason = "error" | |
| 233 | + | StopAborted StopReason = "aborted" | |
| 233 | 234 | ) | |
| 234 | 235 | ||
| 235 | 236 | // Event is one item from a provider stream. | |
| @@ -225,11 +225,12 @@ func (u Usage) Add(v Usage) Usage { | |||||
| 225 | type StopReason string | 225 | type StopReason string | ||
| 226 | 226 | ||||
| 227 | const ( | 227 | const ( | ||
| 228 | - | StopEnd StopReason = "end" | 228 | + | StopEnd StopReason = "end" |
| 229 | - | StopLength StopReason = "length" | 229 | + | StopContinue StopReason = "continue" |
| 230 | - | StopToolUse StopReason = "tool_use" | 230 | + | StopLength StopReason = "length" |
| 231 | - | StopError StopReason = "error" | 231 | + | StopToolUse StopReason = "tool_use" |
| 232 | - | StopAborted StopReason = "aborted" | 232 | + | StopError StopReason = "error" |
| 233 | + | StopAborted StopReason = "aborted" | |||
| 233 | ) | 234 | ) | ||
| 234 | 235 | ||||
| 235 | // Event is one item from a provider stream. | 236 | // Event is one item from a provider stream. | ||