X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/5da3ea038d1e9f9a2634d5ab137aef6989a3a789..02cdaff8be27f37f44db8892bb12554f2d702323:/turn_test.go?ds=inline diff --git a/turn_test.go b/turn_test.go index 1b17940..c378469 100644 --- a/turn_test.go +++ b/turn_test.go @@ -2,8 +2,10 @@ package main import ( "context" + "encoding/json" "errors" "net/http" + "path/filepath" "strings" "sync" "testing" @@ -13,22 +15,22 @@ import ( ) func TestTurnRunsToolsUntilTheFinalAnswer(t *testing.T) { - server := newFakeResponses(t) - server.reply = func(w http.ResponseWriter, call fakeCall) { + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { if call.number == 0 { - writeEvents(w, completedEvent([]any{ - reasoningItem("rs_1", "opaque thought"), - functionCallItem("fc_1", "call_1", "shell", `{"command":"echo hi"}`), + modelWriteEvents(w, modelCompletedEvent([]any{ + modelReasoningItem("rs_1", "opaque thought"), + modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"echo hi"}`), }, 100, 0, 5)) return } - writeEvents(w, completedEvent([]any{messageItem("It said hi.")}, 120, 100, 5)) + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It said hi.")}, 120, 100, 5)) } test := newTestLoop(t, server) - _, err := test.loop.Submit("Run echo hi.") + _, err := test.loop.Submit(terminalSource, "Run echo hi.") require.NoError(t, err) test.events.WaitFor(t, "idle", 1) require.Equal(t, 2, server.Calls()) @@ -57,47 +59,54 @@ func TestTurnRunsToolsUntilTheFinalAnswer(t *testing.T) { "reply: It said hi.", }, test.Log(t)) require.Equal(t, []string{"user", "busy", "tool", "echo", "reply", "idle"}, test.events.Kinds()) + + var turns []string + for _, record := range modelReadUsage(t, test.usagePath) { + turns = append(turns, record.Turn) + } + + require.Equal(t, []string{"first", "next"}, turns) } func TestFirstRequestHoldsTheViewTheStateAndTheMessage(t *testing.T) { - server := newFakeResponses(t) + server := newModelFake(t) test := newTestLoop(t, server) _, err := test.memory.Append("user", "an earlier message") require.NoError(t, err) - _, err = test.loop.Submit("The new message.") + _, err = test.loop.Submit(terminalSource, "The new message.") require.NoError(t, err) test.events.WaitFor(t, "idle", 1) require.Equal(t, []string{ - "\n0+1|user: an earlier message\n\n", - "Now: Thursday 2026-01-01 12:00 +00:00\n", + "\n0+1|user: an earlier message\n", + "\n\nNow: Thursday 2026-01-01 12:00 +00:00", "The new message.", - }, inputParts(t, server.Call(0))) + }, inputTexts(t, server.Call(0))) } func TestMessageDuringATurnJoinsTheNextRequest(t *testing.T) { - server := newFakeResponses(t) + server := newModelFake(t) arrived := make(chan struct{}) release := make(chan struct{}) - server.reply = func(w http.ResponseWriter, call fakeCall) { + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { if call.number == 0 { close(arrived) <-release - writeEvents(w, completedEvent([]any{functionCallItem("fc_1", "call_1", "date", `{"id":0}`)}, 100, 0, 5)) + modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "date", `{"id":0}`)}, 100, 0, 5)) return } - writeEvents(w, completedEvent([]any{messageItem("done")}, 100, 0, 5)) + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("done")}, 100, 0, 5)) } test := newTestLoop(t, server) - _, err := test.loop.Submit("first") + _, err := test.loop.Submit(terminalSource, "first") require.NoError(t, err) <-arrived - _, err = test.loop.Submit("second, during the turn") + _, err = test.loop.Submit(terminalSource, "second, during the turn") require.NoError(t, err) close(release) @@ -114,37 +123,37 @@ func TestMessageDuringATurnJoinsTheNextRequest(t *testing.T) { } func TestMessageDuringTheFinalRequestStartsANewTurn(t *testing.T) { - server := newFakeResponses(t) + server := newModelFake(t) arrived := make(chan struct{}) release := make(chan struct{}) - server.reply = func(w http.ResponseWriter, call fakeCall) { + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { if call.number == 0 { close(arrived) <-release } - writeEvents(w, completedEvent([]any{messageItem("answer")}, 100, 0, 5)) + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("answer")}, 100, 0, 5)) } test := newTestLoop(t, server) - _, err := test.loop.Submit("first") + _, err := test.loop.Submit(terminalSource, "first") require.NoError(t, err) <-arrived - _, err = test.loop.Submit("second") + _, err = test.loop.Submit(terminalSource, "second") require.NoError(t, err) close(release) test.events.WaitFor(t, "idle", 2) require.Equal(t, 2, server.Calls()) - parts := inputParts(t, server.Call(1)) - require.Equal(t, "second", parts[len(parts)-1]) - require.Contains(t, parts[0], "|reply: answer\n") + texts := inputTexts(t, server.Call(1)) + require.Equal(t, "second", texts[len(texts)-1]) + require.Contains(t, strings.Join(texts, ""), "|reply: answer\n") } func TestTurnWaitsForTheSummaries(t *testing.T) { - server := newFakeResponses(t) + server := newModelFake(t) summarizer := newFakeSummarizer() summarizer.gate = make(chan struct{}) test := newTestLoopWith(t, server, summarizer) @@ -152,7 +161,7 @@ func TestTurnWaitsForTheSummaries(t *testing.T) { _, err := test.memory.Append("echo", strings.Repeat("long output ", 100)) require.NoError(t, err) - _, err = test.loop.Submit("go on") + _, err = test.loop.Submit(terminalSource, "go on") require.NoError(t, err) time.Sleep(50 * time.Millisecond) @@ -161,24 +170,24 @@ func TestTurnWaitsForTheSummaries(t *testing.T) { close(summarizer.gate) test.events.WaitFor(t, "idle", 1) require.Equal(t, 1, server.Calls()) - require.NotContains(t, inputParts(t, server.Call(0))[0], pending) + require.NotContains(t, inputTexts(t, server.Call(0))[0], pending) } func TestInterruptEndsTheTurn(t *testing.T) { - server := newFakeResponses(t) + server := newModelFake(t) arrived := make(chan struct{}) release := make(chan struct{}) - server.reply = func(w http.ResponseWriter, call fakeCall) { + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { if call.number == 0 { close(arrived) <-release } - writeEvents(w, completedEvent([]any{messageItem("answer")}, 100, 0, 5)) + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("answer")}, 100, 0, 5)) } test := newTestLoop(t, server) - _, err := test.loop.Submit("a long task") + _, err := test.loop.Submit(terminalSource, "a long task") require.NoError(t, err) <-arrived @@ -190,20 +199,20 @@ func TestInterruptEndsTheTurn(t *testing.T) { require.Equal(t, []string{"user: a long task"}, test.Log(t)) require.False(t, test.loop.Interrupt()) - _, err = test.loop.Submit("again") + _, err = test.loop.Submit(terminalSource, "again") require.NoError(t, err) test.events.WaitFor(t, "idle", 2) require.Equal(t, "reply: answer", test.Log(t)[2]) } func TestFailedCallEndsTheTurnWithoutARetry(t *testing.T) { - server := newFakeResponses(t) - server.reply = func(w http.ResponseWriter, call fakeCall) { + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { http.Error(w, "busy", http.StatusServiceUnavailable) } test := newTestLoop(t, server) - _, err := test.loop.Submit("hello") + _, err := test.loop.Submit(terminalSource, "hello") require.NoError(t, err) test.events.WaitFor(t, "idle", 1) @@ -212,12 +221,36 @@ func TestFailedCallEndsTheTurnWithoutARetry(t *testing.T) { require.Contains(t, test.events.Last("error"), "HTTP 503") } +func TestViewMessagesOnlyGrowBetweenTurns(t *testing.T) { + server := newModelFake(t) + test := newTestLoop(t, server) + for n, text := range []string{"one", "two", "three"} { + _, err := test.loop.Submit(terminalSource, text) + require.NoError(t, err) + test.events.WaitFor(t, "idle", n+1) + } + + first := server.Call(1).body["input"].([]any) + second := server.Call(2).body["input"].([]any) + require.Len(t, first, 4) + require.Len(t, second, 5) + require.Equal(t, first[:2], second[:2]) + require.Equal(t, []string{ + "\n", + "0+1|user: one\n1+1|reply: a line\n", + "2+1|user: two\n3+1|reply: a line\n", + "\n\nNow: Thursday 2026-01-01 12:00 +00:00", + "three", + }, inputTexts(t, server.Call(2))) +} + // testLoop is a loop that runs in the background of a test, with its parts. type testLoop struct { - agent *Agent - memory *Memory - loop *loop - events *eventRecorder + agent *Agent + memory *Memory + loop *loop + events *eventRecorder + usagePath string } // Log returns the logged messages as "kind: text". @@ -294,7 +327,7 @@ func (r *eventRecorder) WaitFor(t *testing.T, kind string, count int) { }, 10*time.Second, time.Millisecond) } -func newTestLoop(t *testing.T, server *fakeResponses) *testLoop { +func newTestLoop(t *testing.T, server *ModelFake) *testLoop { t.Helper() return newTestLoopWith(t, server, newFakeSummarizer()) @@ -302,7 +335,7 @@ func newTestLoop(t *testing.T, server *fakeResponses) *testLoop { // newTestLoopWith returns a running loop on a new agent whose builds use // summarizer and whose commands run in a temporary directory. -func newTestLoopWith(t *testing.T, server *fakeResponses, summarizer Summarizer) *testLoop { +func newTestLoopWith(t *testing.T, server *ModelFake, summarizer Summarizer) *testLoop { t.Helper() agent, err := openAgent(t.TempDir()) @@ -314,12 +347,15 @@ func newTestLoopWith(t *testing.T, server *fakeResponses, summarizer Summarizer) require.NoError(t, err) test := &testLoop{ - agent: agent, - memory: memory, - events: &eventRecorder{}, + agent: agent, + memory: memory, + events: &eventRecorder{}, + usagePath: filepath.Join(t.TempDir(), "usage.jsonl"), } - test.loop = newLoop(agent, memory, newTestClient(t, server), clock.Now, test.events.Add) + client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), test.usagePath) + test.loop, err = newLoop(agent, memory, client, clock.Now, test.events.Add) + require.NoError(t, err) ctx, cancel := context.WithCancel(t.Context()) done := make(chan error) go func() { @@ -335,21 +371,34 @@ func newTestLoopWith(t *testing.T, server *fakeResponses, summarizer Summarizer) return test } -func reasoningItem(id, encrypted string) map[string]any { - return map[string]any{ - "type": "reasoning", - "id": id, - "encrypted_content": encrypted, - "summary": []any{}, +// inputTexts returns the text of each user message of the input of call. +func inputTexts(t *testing.T, call ModelFakeCall) []string { + t.Helper() + + var body struct { + Input []struct { + Role string `json:"role"` + Content []struct { + Text string `json:"text"` + } `json:"content"` + } `json:"input"` } -} -func functionCallItem(id, callID, name, arguments string) map[string]any { - return map[string]any{ - "type": "function_call", - "id": id, - "call_id": callID, - "name": name, - "arguments": arguments, + require.NoError(t, json.Unmarshal(call.raw, &body)) + + var texts []string + for _, item := range body.Input { + if item.Role != "user" { + continue + } + + var text strings.Builder + for _, part := range item.Content { + text.WriteString(part.Text) + } + + texts = append(texts, text.String()) } + + return texts }