v0.50.1 a637010

agent_continuation_test.go RawHistoryFolder

1package core
2
3import (
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.
20type continuationEchoTool struct {
21	calls int32
22}
23
24func (t *continuationEchoTool) Name() string            { return "echo" }
25func (t *continuationEchoTool) Description() string     { return "echoes" }
26func (t *continuationEchoTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
27
28func (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
35func (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.
42func 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}

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 476

↑↓ moveenter openesc close