detached b0688d1

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 UnverifiedB5690EEEBB952194 The 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
252252 only the active session socket; a normally completed turn keeps it warm for
253253 the next continuation.
254254
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.
255258
256259 ## Local llama.cpp router
257260
@@ -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 for252 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 router259 ## 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`.
208208 | `tool_result` | `id`, `is_error`, `content` | Tool finished |
209209 | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends |
210210 | `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 |
212212 | `done` | (none) | The whole prompt/compact completed (success or error) |
213213 | `error` | `message` | Non-fatal error message |
214214 | `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
725725 return err
726726 }
727727
728+ if stop == provider.StopContinue {
729+ if err := ctx.Err(); err != nil {
730+ sink(EvDone{})
731+ return err
732+ }
733+ continue
734+ }
728735 if stop == provider.StopToolUse {
729736 // Execute each tool call, append a single tool-results message, continue.
730737 toolMsg, hadError := a.executeTools(ctx, assistantMsg, sink)
@@ -725,6 +725,13 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte
725 return err725 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
801801 ReasoningTokens int `json:"reasoning_tokens"`
802802 } `json:"output_tokens_details"`
803803 } `json:"usage"`
804- Status string `json:"status"`
804+ Status string `json:"status"`
805+ EndTurn *bool `json:"end_turn"`
805806 } `json:"response"`
806807 }
807808 _ = json.Unmarshal([]byte(ev.Data), &p)
@@ -822,6 +823,9 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques
822823 stop = StopToolUse
823824 } else {
824825 stop = StopEnd
826+ if p.Response.EndTurn != nil && !*p.Response.EndTurn {
827+ stop = StopContinue
828+ }
825829 }
826830 if completed != nil && p.Response.ID != "" {
827831 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 = StopToolUse823 stop = StopToolUse
823 } else {824 } else {
824 stop = StopEnd825 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 {
225225 type StopReason string
226226
227227 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"
233234 )
234235
235236 // Event is one item from a provider stream.
@@ -225,11 +225,12 @@ func (u Usage) Add(v Usage) Usage {
225 type StopReason string225 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.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close