--- /dev/null
+package main
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// chatgptTestAccount is the fake ChatGPT account ID in the fake tokens.
+const chatgptTestAccount = "account-test"
+
+// ChatGPTFake is an httptest login service. The device token endpoint answers
+// 403 pending times before it grants the code. Each token answer has a new
+// access token, kept in access.
+type ChatGPTFake struct {
+ *httptest.Server
+ mutex sync.Mutex
+ pending int
+ deny bool
+ polls int
+ exchange url.Values
+ refreshes int
+ refreshed string
+ newRefresh string
+ failRefresh bool
+ access []string
+}
+
+// token returns a new access token that expires after ttl, for a token file
+// that a test writes.
+func (f *ChatGPTFake) token(ttl time.Duration) string {
+ f.mutex.Lock()
+ defer f.mutex.Unlock()
+
+ return f.newToken(ttl)
+}
+
+// newToken returns a new fake JWT with the expiry and the account claim, and
+// keeps it in access. The caller holds the mutex.
+func (f *ChatGPTFake) newToken(ttl time.Duration) string {
+ claims, err := json.Marshal(map[string]any{
+ "exp": time.Now().Add(ttl).Unix(),
+ chatgptAuthClaim: map[string]any{
+ "chatgpt_account_id": chatgptTestAccount,
+ },
+ "serial": len(f.access),
+ })
+ if err != nil {
+ panic(err)
+ }
+
+ encode := base64.RawURLEncoding.EncodeToString
+ token := encode([]byte(`{"alg":"none"}`)) + "." + encode(claims) + "." + encode([]byte("fake"))
+ f.access = append(f.access, token)
+
+ return token
+}
+
+func (f *ChatGPTFake) userCode(w http.ResponseWriter, r *http.Request) {
+ var body map[string]string
+ err := json.NewDecoder(r.Body).Decode(&body)
+ if err != nil || body["client_id"] != chatgptClientID {
+ http.Error(w, "bad client", http.StatusBadRequest)
+ return
+ }
+
+ chatgptWriteJSON(w, map[string]any{
+ "device_auth_id": "device-1",
+ "user_code": "CODE-1234",
+ "interval": "0",
+ })
+}
+
+func (f *ChatGPTFake) deviceToken(w http.ResponseWriter, r *http.Request) {
+ f.mutex.Lock()
+ defer f.mutex.Unlock()
+
+ var body map[string]string
+ err := json.NewDecoder(r.Body).Decode(&body)
+ if err != nil || body["device_auth_id"] != "device-1" || body["user_code"] != "CODE-1234" {
+ http.Error(w, "bad device", http.StatusBadRequest)
+ return
+ }
+
+ f.polls++
+ if f.deny {
+ http.Error(w, "denied", http.StatusBadRequest)
+ return
+ }
+
+ if f.polls <= f.pending {
+ http.Error(w, "pending", http.StatusForbidden)
+ return
+ }
+
+ chatgptWriteJSON(w, map[string]any{
+ "authorization_code": "auth-code",
+ "code_challenge": "challenge",
+ "code_verifier": "verifier",
+ })
+}
+
+func (f *ChatGPTFake) oauthToken(w http.ResponseWriter, r *http.Request) {
+ f.mutex.Lock()
+ defer f.mutex.Unlock()
+
+ if r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" {
+ err := r.ParseForm()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ f.exchange = r.PostForm
+ chatgptWriteJSON(w, map[string]any{
+ "access_token": f.newToken(time.Hour),
+ "refresh_token": "refresh-1",
+ "id_token": f.newToken(time.Hour),
+ })
+
+ return
+ }
+
+ var body map[string]string
+ err := json.NewDecoder(r.Body).Decode(&body)
+ if err != nil || body["grant_type"] != "refresh_token" || body["client_id"] != chatgptClientID {
+ http.Error(w, "bad refresh", http.StatusBadRequest)
+ return
+ }
+
+ f.refreshes++
+ f.refreshed = body["refresh_token"]
+ if f.failRefresh {
+ http.Error(w, `{"error": "invalid_grant", "token": "`+body["refresh_token"]+`"}`, http.StatusUnauthorized)
+ return
+ }
+
+ answer := map[string]any{
+ "access_token": f.newToken(time.Hour),
+ "id_token": f.newToken(time.Hour),
+ }
+
+ if f.newRefresh != "" {
+ answer["refresh_token"] = f.newRefresh
+ }
+
+ chatgptWriteJSON(w, answer)
+}
+
+func newChatGPTFake(t *testing.T) *ChatGPTFake {
+ t.Helper()
+
+ f := &ChatGPTFake{}
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /api/accounts/deviceauth/usercode", f.userCode)
+ mux.HandleFunc("POST /api/accounts/deviceauth/token", f.deviceToken)
+ mux.HandleFunc("POST /oauth/token", f.oauthToken)
+ f.Server = httptest.NewServer(mux)
+ t.Cleanup(f.Close)
+
+ return f
+}
+
+func newTestChatGPTAuth(t *testing.T, server *ChatGPTFake) *ChatGPTAuth {
+ t.Helper()
+
+ return newChatGPTAuth(filepath.Join(t.TempDir(), chatgptTokenFileName), server.URL)
+}
+
+func chatgptWriteTestTokens(t *testing.T, auth *ChatGPTAuth, access, refresh string) {
+ t.Helper()
+
+ err := auth.write(ChatGPTTokenFile{
+ AccessToken: access,
+ RefreshToken: refresh,
+ IDToken: access,
+ AccountID: chatgptTestAccount,
+ })
+ require.NoError(t, err)
+}
+
+func chatgptWriteJSON(w http.ResponseWriter, value any) {
+ w.Header().Set("Content-Type", "application/json")
+ err := json.NewEncoder(w).Encode(value)
+ if err != nil {
+ panic(fmt.Sprint("encode answer: ", err))
+ }
+}
package main
import (
- "encoding/base64"
"encoding/json"
- "fmt"
"io"
"net/http"
- "net/http/httptest"
- "net/url"
"os"
"path/filepath"
"strings"
"github.com/stretchr/testify/require"
)
-// chatgptTestAccount is the fake ChatGPT account ID in the fake tokens.
-const chatgptTestAccount = "account-test"
-
func TestLoginWritesTheTokenFile(t *testing.T) {
server := newChatGPTFake(t)
server.pending = 2
require.ErrorContains(t, err, "OPENAI_API_KEY")
}
-// ChatGPTFake is an httptest login service. The device token endpoint answers
-// 403 pending times before it grants the code. Each token answer has a new
-// access token, kept in access.
-type ChatGPTFake struct {
- *httptest.Server
- mutex sync.Mutex
- pending int
- deny bool
- polls int
- exchange url.Values
- refreshes int
- refreshed string
- newRefresh string
- failRefresh bool
- access []string
-}
-
-// token returns a new access token that expires after ttl, for a token file
-// that a test writes.
-func (f *ChatGPTFake) token(ttl time.Duration) string {
- f.mutex.Lock()
- defer f.mutex.Unlock()
-
- return f.newToken(ttl)
-}
-
-// newToken returns a new fake JWT with the expiry and the account claim, and
-// keeps it in access. The caller holds the mutex.
-func (f *ChatGPTFake) newToken(ttl time.Duration) string {
- claims, err := json.Marshal(map[string]any{
- "exp": time.Now().Add(ttl).Unix(),
- chatgptAuthClaim: map[string]any{
- "chatgpt_account_id": chatgptTestAccount,
- },
- "serial": len(f.access),
- })
- if err != nil {
- panic(err)
- }
-
- encode := base64.RawURLEncoding.EncodeToString
- token := encode([]byte(`{"alg":"none"}`)) + "." + encode(claims) + "." + encode([]byte("fake"))
- f.access = append(f.access, token)
-
- return token
-}
-
-func (f *ChatGPTFake) userCode(w http.ResponseWriter, r *http.Request) {
- var body map[string]string
- err := json.NewDecoder(r.Body).Decode(&body)
- if err != nil || body["client_id"] != chatgptClientID {
- http.Error(w, "bad client", http.StatusBadRequest)
- return
- }
-
- chatgptWriteJSON(w, map[string]any{
- "device_auth_id": "device-1",
- "user_code": "CODE-1234",
- "interval": "0",
- })
-}
-
-func (f *ChatGPTFake) deviceToken(w http.ResponseWriter, r *http.Request) {
- f.mutex.Lock()
- defer f.mutex.Unlock()
-
- var body map[string]string
- err := json.NewDecoder(r.Body).Decode(&body)
- if err != nil || body["device_auth_id"] != "device-1" || body["user_code"] != "CODE-1234" {
- http.Error(w, "bad device", http.StatusBadRequest)
- return
- }
-
- f.polls++
- if f.deny {
- http.Error(w, "denied", http.StatusBadRequest)
- return
- }
-
- if f.polls <= f.pending {
- http.Error(w, "pending", http.StatusForbidden)
- return
- }
-
- chatgptWriteJSON(w, map[string]any{
- "authorization_code": "auth-code",
- "code_challenge": "challenge",
- "code_verifier": "verifier",
- })
-}
-
-func (f *ChatGPTFake) oauthToken(w http.ResponseWriter, r *http.Request) {
- f.mutex.Lock()
- defer f.mutex.Unlock()
-
- if r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" {
- err := r.ParseForm()
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
-
- f.exchange = r.PostForm
- chatgptWriteJSON(w, map[string]any{
- "access_token": f.newToken(time.Hour),
- "refresh_token": "refresh-1",
- "id_token": f.newToken(time.Hour),
- })
-
- return
- }
-
- var body map[string]string
- err := json.NewDecoder(r.Body).Decode(&body)
- if err != nil || body["grant_type"] != "refresh_token" || body["client_id"] != chatgptClientID {
- http.Error(w, "bad refresh", http.StatusBadRequest)
- return
- }
-
- f.refreshes++
- f.refreshed = body["refresh_token"]
- if f.failRefresh {
- http.Error(w, `{"error": "invalid_grant", "token": "`+body["refresh_token"]+`"}`, http.StatusUnauthorized)
- return
- }
-
- answer := map[string]any{
- "access_token": f.newToken(time.Hour),
- "id_token": f.newToken(time.Hour),
- }
-
- if f.newRefresh != "" {
- answer["refresh_token"] = f.newRefresh
- }
-
- chatgptWriteJSON(w, answer)
-}
-
-func newChatGPTFake(t *testing.T) *ChatGPTFake {
- t.Helper()
-
- f := &ChatGPTFake{}
- mux := http.NewServeMux()
- mux.HandleFunc("POST /api/accounts/deviceauth/usercode", f.userCode)
- mux.HandleFunc("POST /api/accounts/deviceauth/token", f.deviceToken)
- mux.HandleFunc("POST /oauth/token", f.oauthToken)
- f.Server = httptest.NewServer(mux)
- t.Cleanup(f.Close)
-
- return f
-}
-
-func newTestChatGPTAuth(t *testing.T, server *ChatGPTFake) *ChatGPTAuth {
- t.Helper()
-
- return newChatGPTAuth(filepath.Join(t.TempDir(), chatgptTokenFileName), server.URL)
-}
-
func chatgptReadTokenFile(t *testing.T, path string) ChatGPTTokenFile {
t.Helper()
return tokens
}
-
-func chatgptWriteTestTokens(t *testing.T, auth *ChatGPTAuth, access, refresh string) {
- t.Helper()
-
- err := auth.write(ChatGPTTokenFile{
- AccessToken: access,
- RefreshToken: refresh,
- IDToken: access,
- AccountID: chatgptTestAccount,
- })
- require.NoError(t, err)
-}
-
-func chatgptWriteJSON(w http.ResponseWriter, value any) {
- w.Header().Set("Content-Type", "application/json")
- err := json.NewEncoder(w).Encode(value)
- if err != nil {
- panic(fmt.Sprint("encode answer: ", err))
- }
-}
--- /dev/null
+package main
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// daemonStartTestWithBot is daemonStartTest with bot, which can be nil.
+func daemonStartTestWithBot(t *testing.T, server *ModelFake, bot *TelegramBot) string {
+ t.Helper()
+
+ dir := t.TempDir()
+ agent, err := openAgent(filepath.Join(dir, daemonAgentDirName))
+ require.NoError(t, err)
+
+ agent.config.Workdir = t.TempDir()
+ clock := newTestClock()
+ memory, err := openMemory(t.Context(), agent.dir, clock.Now, newFakeSummarizer())
+ require.NoError(t, err)
+
+ socket := filepath.Join(dir, socketFileName)
+ listener, err := daemonListenSocket(socket)
+ require.NoError(t, err)
+
+ client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl"))
+ ctx, cancel := context.WithCancel(t.Context())
+ done := make(chan error)
+ go func() {
+ done <- daemonServe(ctx, listener, bot, func(notify func(Event)) (*TurnLoop, error) {
+ return newTurnLoop(agent, memory, client, clock.Now, notify)
+ })
+ }()
+
+ t.Cleanup(func() {
+ cancel()
+ require.NoError(t, <-done)
+ require.NoError(t, memory.Close())
+ })
+
+ return socket
+}
package main
import (
- "context"
"io"
"net/http"
"os"
return daemonStartTestWithBot(t, server, nil)
}
-
-// daemonStartTestWithBot is daemonStartTest with bot, which can be nil.
-func daemonStartTestWithBot(t *testing.T, server *ModelFake, bot *TelegramBot) string {
- t.Helper()
-
- dir := t.TempDir()
- agent, err := openAgent(filepath.Join(dir, daemonAgentDirName))
- require.NoError(t, err)
-
- agent.config.Workdir = t.TempDir()
- clock := newTestClock()
- memory, err := openMemory(t.Context(), agent.dir, clock.Now, newFakeSummarizer())
- require.NoError(t, err)
-
- socket := filepath.Join(dir, socketFileName)
- listener, err := daemonListenSocket(socket)
- require.NoError(t, err)
-
- client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl"))
- ctx, cancel := context.WithCancel(t.Context())
- done := make(chan error)
- go func() {
- done <- daemonServe(ctx, listener, bot, func(notify func(Event)) (*TurnLoop, error) {
- return newTurnLoop(agent, memory, client, clock.Now, notify)
- })
- }()
-
- t.Cleanup(func() {
- cancel()
- require.NoError(t, <-done)
- require.NoError(t, memory.Close())
- })
-
- return socket
-}
--- /dev/null
+package main
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// modelTestKey is a fake API key.
+const modelTestKey = "sk-test-not-a-real-key"
+
+// ModelFakeCall is one call to a fake endpoint, numbered from 0.
+type ModelFakeCall struct {
+ number int
+ header http.Header
+ raw []byte
+ body map[string]any
+}
+
+// ModelFake is an httptest Responses endpoint. It keeps each call, and reply
+// writes the response. The default reply is one short message.
+type ModelFake struct {
+ *httptest.Server
+ mutex sync.Mutex
+ calls []ModelFakeCall
+ reply func(http.ResponseWriter, ModelFakeCall)
+}
+
+// Call returns call number n.
+func (f *ModelFake) Call(n int) ModelFakeCall {
+ f.mutex.Lock()
+ defer f.mutex.Unlock()
+
+ return f.calls[n]
+}
+
+// Calls returns the number of calls.
+func (f *ModelFake) Calls() int {
+ f.mutex.Lock()
+ defer f.mutex.Unlock()
+
+ return len(f.calls)
+}
+
+func (f *ModelFake) serve(w http.ResponseWriter, r *http.Request) {
+ raw, err := io.ReadAll(r.Body)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ call := ModelFakeCall{
+ header: r.Header.Clone(),
+ raw: raw,
+ }
+
+ err = json.Unmarshal(raw, &call.body)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ f.mutex.Lock()
+ call.number = len(f.calls)
+ f.calls = append(f.calls, call)
+ reply := f.reply
+ f.mutex.Unlock()
+
+ reply(w, call)
+}
+
+func newModelFake(t *testing.T) *ModelFake {
+ t.Helper()
+
+ f := &ModelFake{
+ reply: func(w http.ResponseWriter, call ModelFakeCall) {
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("a line")}, 100, 0, 5))
+ },
+ }
+
+ f.Server = httptest.NewServer(http.HandlerFunc(f.serve))
+ t.Cleanup(f.Close)
+
+ return f
+}
+
+func modelTestRequest() ModelRequest {
+ return ModelRequest{
+ Model: "gpt-test",
+ Effort: "low",
+ Input: []any{modelUserMessage("hello")},
+ }
+}
+
+// modelWriteEvents writes events as a server-sent event stream.
+func modelWriteEvents(w http.ResponseWriter, events ...any) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ for _, event := range events {
+ data, err := json.Marshal(event)
+ if err != nil {
+ panic(err)
+ }
+
+ _, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.(map[string]any)["type"], data)
+ if err != nil {
+ panic(err)
+ }
+ }
+}
+
+func modelMessageItem(text string) map[string]any {
+ return map[string]any{
+ "type": "message",
+ "role": "assistant",
+ "content": []any{
+ map[string]any{"type": "output_text", "text": text},
+ },
+ }
+}
+
+func modelCompletedEvent(output []any, input, cached, out int) map[string]any {
+ if output == nil {
+ output = []any{}
+ }
+
+ return map[string]any{
+ "type": "response.completed",
+ "response": map[string]any{
+ "output": output,
+ "usage": map[string]any{
+ "input_tokens": input,
+ "input_tokens_details": map[string]any{"cached_tokens": cached},
+ "output_tokens": out,
+ },
+ },
+ }
+}
+
+func modelReadUsage(t *testing.T, path string) []ModelUsageRecord {
+ t.Helper()
+
+ file, err := os.Open(path)
+ require.NoError(t, err)
+
+ defer func() {
+ require.NoError(t, file.Close())
+ }()
+
+ var records []ModelUsageRecord
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ var record ModelUsageRecord
+ require.NoError(t, json.Unmarshal(scanner.Bytes(), &record))
+ records = append(records, record)
+ }
+
+ require.NoError(t, scanner.Err())
+
+ return records
+}
+
+func modelReasoningItem(id, encrypted string) map[string]any {
+ return map[string]any{
+ "type": "reasoning",
+ "id": id,
+ "encrypted_content": encrypted,
+ "summary": []any{},
+ }
+}
+
+func modelFunctionCallItem(id, callID, name, arguments string) map[string]any {
+ return map[string]any{
+ "type": "function_call",
+ "id": id,
+ "call_id": callID,
+ "name": name,
+ "arguments": arguments,
+ }
+}
package main
import (
- "bufio"
- "encoding/json"
- "fmt"
"io"
"net/http"
- "net/http/httptest"
"os"
"path/filepath"
- "sync"
"testing"
"time"
"github.com/stretchr/testify/require"
)
-// modelTestKey is a fake API key.
-const modelTestKey = "sk-test-not-a-real-key"
-
func TestClientSendsTheFixedRequestFields(t *testing.T) {
server := newModelFake(t)
client := newTestModelClient(t, server)
}
}
-// ModelFakeCall is one call to a fake endpoint, numbered from 0.
-type ModelFakeCall struct {
- number int
- header http.Header
- raw []byte
- body map[string]any
-}
-
-// ModelFake is an httptest Responses endpoint. It keeps each call, and reply
-// writes the response. The default reply is one short message.
-type ModelFake struct {
- *httptest.Server
- mutex sync.Mutex
- calls []ModelFakeCall
- reply func(http.ResponseWriter, ModelFakeCall)
-}
-
-// Call returns call number n.
-func (f *ModelFake) Call(n int) ModelFakeCall {
- f.mutex.Lock()
- defer f.mutex.Unlock()
-
- return f.calls[n]
-}
-
-// Calls returns the number of calls.
-func (f *ModelFake) Calls() int {
- f.mutex.Lock()
- defer f.mutex.Unlock()
-
- return len(f.calls)
-}
-
-func (f *ModelFake) serve(w http.ResponseWriter, r *http.Request) {
- raw, err := io.ReadAll(r.Body)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
-
- call := ModelFakeCall{
- header: r.Header.Clone(),
- raw: raw,
- }
-
- err = json.Unmarshal(raw, &call.body)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
-
- f.mutex.Lock()
- call.number = len(f.calls)
- f.calls = append(f.calls, call)
- reply := f.reply
- f.mutex.Unlock()
-
- reply(w, call)
-}
-
-func newModelFake(t *testing.T) *ModelFake {
- t.Helper()
-
- f := &ModelFake{
- reply: func(w http.ResponseWriter, call ModelFakeCall) {
- modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("a line")}, 100, 0, 5))
- },
- }
-
- f.Server = httptest.NewServer(http.HandlerFunc(f.serve))
- t.Cleanup(f.Close)
-
- return f
-}
-
func newTestModelClient(t *testing.T, server *ModelFake) *ModelClient {
t.Helper()
return newModelClient(server.URL, ModelAPIKey(modelTestKey), "agent-key", filepath.Join(t.TempDir(), "usage.jsonl"))
}
-
-func modelTestRequest() ModelRequest {
- return ModelRequest{
- Model: "gpt-test",
- Effort: "low",
- Input: []any{modelUserMessage("hello")},
- }
-}
-
-// modelWriteEvents writes events as a server-sent event stream.
-func modelWriteEvents(w http.ResponseWriter, events ...any) {
- w.Header().Set("Content-Type", "text/event-stream")
- for _, event := range events {
- data, err := json.Marshal(event)
- if err != nil {
- panic(err)
- }
-
- _, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.(map[string]any)["type"], data)
- if err != nil {
- panic(err)
- }
- }
-}
-
-func modelMessageItem(text string) map[string]any {
- return map[string]any{
- "type": "message",
- "role": "assistant",
- "content": []any{
- map[string]any{"type": "output_text", "text": text},
- },
- }
-}
-
-func modelCompletedEvent(output []any, input, cached, out int) map[string]any {
- if output == nil {
- output = []any{}
- }
-
- return map[string]any{
- "type": "response.completed",
- "response": map[string]any{
- "output": output,
- "usage": map[string]any{
- "input_tokens": input,
- "input_tokens_details": map[string]any{"cached_tokens": cached},
- "output_tokens": out,
- },
- },
- }
-}
-
-func modelReadUsage(t *testing.T, path string) []ModelUsageRecord {
- t.Helper()
-
- file, err := os.Open(path)
- require.NoError(t, err)
-
- defer func() {
- require.NoError(t, file.Close())
- }()
-
- var records []ModelUsageRecord
- scanner := bufio.NewScanner(file)
- for scanner.Scan() {
- var record ModelUsageRecord
- require.NoError(t, json.Unmarshal(scanner.Bytes(), &record))
- records = append(records, record)
- }
-
- require.NoError(t, scanner.Err())
-
- return records
-}
-
-func modelReasoningItem(id, encrypted string) map[string]any {
- return map[string]any{
- "type": "reasoning",
- "id": id,
- "encrypted_content": encrypted,
- "summary": []any{},
- }
-}
-
-func modelFunctionCallItem(id, callID, name, arguments string) map[string]any {
- return map[string]any{
- "type": "function_call",
- "id": id,
- "call_id": callID,
- "name": name,
- "arguments": arguments,
- }
-}
import (
"context"
- "encoding/json"
- "fmt"
"io"
"net/http"
"net/http/httptest"
- "strings"
- "sync"
"testing"
"time"
"github.com/stretchr/testify/require"
)
-const (
- // telegramTestToken is the bot token of the tests. It has the form of a
- // real token.
- telegramTestToken = "123456:TEST-secret_token"
-
- // telegramTestOwner is the user ID of the owner of the test bot.
- telegramTestOwner = 4242
-)
-
func TestGetUpdatesAsksForMessagesFromTheOffset(t *testing.T) {
server := newTelegramFake(t)
server.Add(TelegramUpdate{
require.ErrorIs(t, err, errTelegramNetwork)
require.NotContains(t, err.Error(), telegramTestToken)
}
-
-// TelegramFakeCall is one call of the fake Bot API. OK tells if the call got a
-// success answer.
-type TelegramFakeCall struct {
- method string
- params map[string]any
- ok bool
-}
-
-// TelegramFake is a fake Bot API server for telegramTestToken. getUpdates
-// returns the added updates from the offset on, or waits a short time when
-// there are none. The other methods succeed, unless fail gives an error answer
-// for a call. An answer with HTTP 429 asks for a wait of one second. When stall
-// is true for a call, the call gets no answer until the client gives it up or
-// the test ends. Fail and stall run with the mutex held.
-type TelegramFake struct {
- *httptest.Server
-
- mutex sync.Mutex
- updates []TelegramUpdate
- calls []TelegramFakeCall
- fail func(call TelegramFakeCall) (int, string)
- stall func(call TelegramFakeCall) bool
- added chan struct{}
- stopped chan struct{}
-}
-
-// Add makes update available to getUpdates.
-func (f *TelegramFake) Add(update TelegramUpdate) {
- f.mutex.Lock()
- f.updates = append(f.updates, update)
- f.mutex.Unlock()
-
- select {
- case f.added <- struct{}{}:
- default:
- }
-}
-
-// Calls returns the calls so far.
-func (f *TelegramFake) Calls() []TelegramFakeCall {
- f.mutex.Lock()
- defer f.mutex.Unlock()
-
- return append([]TelegramFakeCall(nil), f.calls...)
-}
-
-// Sent returns the texts of the sendMessage calls and the Markdown of the
-// sendRichMessage calls that succeeded, in order.
-func (f *TelegramFake) Sent() []string {
- var texts []string
- for _, call := range f.Calls() {
- switch {
- case !call.ok:
- case call.method == "sendMessage":
- texts = append(texts, call.params["text"].(string))
- case call.method == "sendRichMessage":
- texts = append(texts, call.params["rich_message"].(map[string]any)["markdown"].(string))
- }
- }
-
- return texts
-}
-
-func (f *TelegramFake) serve(w http.ResponseWriter, r *http.Request) {
- method, found := strings.CutPrefix(r.URL.Path, "/bot"+telegramTestToken+"/")
- if !found {
- http.Error(w, `{"ok":false,"error_code":404,"description":"Not Found"}`, http.StatusNotFound)
- return
- }
-
- var params map[string]any
- err := json.NewDecoder(r.Body).Decode(¶ms)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
-
- call := TelegramFakeCall{
- method: method,
- params: params,
- }
-
- f.mutex.Lock()
- status, description := 0, ""
- if f.fail != nil {
- status, description = f.fail(call)
- }
-
- stall := f.stall != nil && f.stall(call)
- f.mutex.Unlock()
-
- if stall {
- f.record(call)
- select {
- case <-r.Context().Done():
- case <-f.stopped:
- }
-
- return
- }
-
- if status != 0 {
- f.record(call)
- w.WriteHeader(status)
- wait := 0
- if status == http.StatusTooManyRequests {
- wait = 1
- }
-
- _, _ = fmt.Fprintf(w, `{"ok":false,"error_code":%d,"description":%q,"parameters":{"retry_after":%d}}`, status, description, wait)
-
- return
- }
-
- call.ok = true
- f.record(call)
- if method != "getUpdates" {
- _, _ = io.WriteString(w, `{"ok":true,"result":true}`)
- return
- }
-
- offset := int64(params["offset"].(float64))
- updates := f.from(offset)
- if len(updates) == 0 {
- select {
- case <-f.added:
- case <-time.After(50 * time.Millisecond):
- case <-r.Context().Done():
- }
-
- updates = f.from(offset)
- }
-
- _ = json.NewEncoder(w).Encode(map[string]any{
- "ok": true,
- "result": updates,
- })
-}
-
-// record keeps call.
-func (f *TelegramFake) record(call TelegramFakeCall) {
- f.mutex.Lock()
- defer f.mutex.Unlock()
-
- f.calls = append(f.calls, call)
-}
-
-// from returns the updates from offset on, and forgets the ones before it, as
-// Telegram does.
-func (f *TelegramFake) from(offset int64) []TelegramUpdate {
- f.mutex.Lock()
- defer f.mutex.Unlock()
-
- var updates []TelegramUpdate
- kept := f.updates[:0]
- for _, update := range f.updates {
- if update.UpdateID >= offset {
- updates = append(updates, update)
- kept = append(kept, update)
- }
- }
-
- f.updates = kept
-
- return updates
-}
-
-// newTelegramFake starts a fake Bot API server that stops at the end of the
-// test.
-func newTelegramFake(t *testing.T) *TelegramFake {
- t.Helper()
-
- f := &TelegramFake{
- added: make(chan struct{}, 1),
- stopped: make(chan struct{}),
- }
-
- f.Server = httptest.NewServer(http.HandlerFunc(f.serve))
- t.Cleanup(f.Close)
- t.Cleanup(func() {
- close(f.stopped)
- })
-
- return f
-}
-
-// telegramOwnerMessage returns a text message of the owner in the private chat.
-func telegramOwnerMessage(text string) *TelegramMessage {
- return &TelegramMessage{
- Chat: TelegramChat{
- ID: telegramTestOwner,
- Type: "private",
- },
- From: &TelegramUser{
- ID: telegramTestOwner,
- },
- Text: text,
- }
-}
--- /dev/null
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+const (
+ // telegramTestToken is the bot token of the tests. It has the form of a
+ // real token.
+ telegramTestToken = "123456:TEST-secret_token"
+
+ // telegramTestOwner is the user ID of the owner of the test bot.
+ telegramTestOwner = 4242
+)
+
+// TelegramFakeCall is one call of the fake Bot API. OK tells if the call got a
+// success answer.
+type TelegramFakeCall struct {
+ method string
+ params map[string]any
+ ok bool
+}
+
+// TelegramFake is a fake Bot API server for telegramTestToken. getUpdates
+// returns the added updates from the offset on, or waits a short time when
+// there are none. The other methods succeed, unless fail gives an error answer
+// for a call. An answer with HTTP 429 asks for a wait of one second. When stall
+// is true for a call, the call gets no answer until the client gives it up or
+// the test ends. Fail and stall run with the mutex held.
+type TelegramFake struct {
+ *httptest.Server
+
+ mutex sync.Mutex
+ updates []TelegramUpdate
+ calls []TelegramFakeCall
+ fail func(call TelegramFakeCall) (int, string)
+ stall func(call TelegramFakeCall) bool
+ added chan struct{}
+ stopped chan struct{}
+}
+
+// Add makes update available to getUpdates.
+func (f *TelegramFake) Add(update TelegramUpdate) {
+ f.mutex.Lock()
+ f.updates = append(f.updates, update)
+ f.mutex.Unlock()
+
+ select {
+ case f.added <- struct{}{}:
+ default:
+ }
+}
+
+// Calls returns the calls so far.
+func (f *TelegramFake) Calls() []TelegramFakeCall {
+ f.mutex.Lock()
+ defer f.mutex.Unlock()
+
+ return append([]TelegramFakeCall(nil), f.calls...)
+}
+
+// Sent returns the texts of the sendMessage calls and the Markdown of the
+// sendRichMessage calls that succeeded, in order.
+func (f *TelegramFake) Sent() []string {
+ var texts []string
+ for _, call := range f.Calls() {
+ switch {
+ case !call.ok:
+ case call.method == "sendMessage":
+ texts = append(texts, call.params["text"].(string))
+ case call.method == "sendRichMessage":
+ texts = append(texts, call.params["rich_message"].(map[string]any)["markdown"].(string))
+ }
+ }
+
+ return texts
+}
+
+func (f *TelegramFake) serve(w http.ResponseWriter, r *http.Request) {
+ method, found := strings.CutPrefix(r.URL.Path, "/bot"+telegramTestToken+"/")
+ if !found {
+ http.Error(w, `{"ok":false,"error_code":404,"description":"Not Found"}`, http.StatusNotFound)
+ return
+ }
+
+ var params map[string]any
+ err := json.NewDecoder(r.Body).Decode(¶ms)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ call := TelegramFakeCall{
+ method: method,
+ params: params,
+ }
+
+ f.mutex.Lock()
+ status, description := 0, ""
+ if f.fail != nil {
+ status, description = f.fail(call)
+ }
+
+ stall := f.stall != nil && f.stall(call)
+ f.mutex.Unlock()
+
+ if stall {
+ f.record(call)
+ select {
+ case <-r.Context().Done():
+ case <-f.stopped:
+ }
+
+ return
+ }
+
+ if status != 0 {
+ f.record(call)
+ w.WriteHeader(status)
+ wait := 0
+ if status == http.StatusTooManyRequests {
+ wait = 1
+ }
+
+ _, _ = fmt.Fprintf(w, `{"ok":false,"error_code":%d,"description":%q,"parameters":{"retry_after":%d}}`, status, description, wait)
+
+ return
+ }
+
+ call.ok = true
+ f.record(call)
+ if method != "getUpdates" {
+ _, _ = io.WriteString(w, `{"ok":true,"result":true}`)
+ return
+ }
+
+ offset := int64(params["offset"].(float64))
+ updates := f.from(offset)
+ if len(updates) == 0 {
+ select {
+ case <-f.added:
+ case <-time.After(50 * time.Millisecond):
+ case <-r.Context().Done():
+ }
+
+ updates = f.from(offset)
+ }
+
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "ok": true,
+ "result": updates,
+ })
+}
+
+// record keeps call.
+func (f *TelegramFake) record(call TelegramFakeCall) {
+ f.mutex.Lock()
+ defer f.mutex.Unlock()
+
+ f.calls = append(f.calls, call)
+}
+
+// from returns the updates from offset on, and forgets the ones before it, as
+// Telegram does.
+func (f *TelegramFake) from(offset int64) []TelegramUpdate {
+ f.mutex.Lock()
+ defer f.mutex.Unlock()
+
+ var updates []TelegramUpdate
+ kept := f.updates[:0]
+ for _, update := range f.updates {
+ if update.UpdateID >= offset {
+ updates = append(updates, update)
+ kept = append(kept, update)
+ }
+ }
+
+ f.updates = kept
+
+ return updates
+}
+
+// newTelegramFake starts a fake Bot API server that stops at the end of the
+// test.
+func newTelegramFake(t *testing.T) *TelegramFake {
+ t.Helper()
+
+ f := &TelegramFake{
+ added: make(chan struct{}, 1),
+ stopped: make(chan struct{}),
+ }
+
+ f.Server = httptest.NewServer(http.HandlerFunc(f.serve))
+ t.Cleanup(f.Close)
+ t.Cleanup(func() {
+ close(f.stopped)
+ })
+
+ return f
+}
+
+// telegramOwnerMessage returns a text message of the owner in the private chat.
+func telegramOwnerMessage(text string) *TelegramMessage {
+ return &TelegramMessage{
+ Chat: TelegramChat{
+ ID: telegramTestOwner,
+ Type: "private",
+ },
+ From: &TelegramUser{
+ ID: telegramTestOwner,
+ },
+ Text: text,
+ }
+}
--- /dev/null
+package main
+
+import (
+ "io"
+ "net"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TerminalTestClient is a terminal client of a test daemon, with a pipe as its
+// input.
+type TerminalTestClient struct {
+ input *io.PipeWriter
+ done chan error
+ out *SyncBuffer
+}
+
+// Type writes one line to the input of the client.
+func (c *TerminalTestClient) Type(line string) {
+ _, err := io.WriteString(c.input, line+"\n")
+ if err != nil {
+ panic(err)
+ }
+}
+
+// Output returns what the client showed.
+func (c *TerminalTestClient) Output() string {
+ return c.out.String()
+}
+
+// WaitFor waits until the client showed text.
+func (c *TerminalTestClient) WaitFor(t *testing.T, text string) {
+ t.Helper()
+
+ require.Eventually(t, func() bool {
+ return strings.Contains(c.Output(), text)
+ }, 10*time.Second, time.Millisecond, "output so far: %q", c.Output())
+}
+
+// Close ends the input and waits for the client.
+func (c *TerminalTestClient) Close() error {
+ err := c.input.Close()
+ if err != nil {
+ return err
+ }
+
+ return <-c.done
+}
+
+// terminalStartTestClient connects a client to the daemon at socket.
+func terminalStartTestClient(t *testing.T, socket string) *TerminalTestClient {
+ t.Helper()
+
+ conn, err := net.Dial("unix", socket)
+ require.NoError(t, err)
+
+ reader, writer := io.Pipe()
+ client := &TerminalTestClient{
+ input: writer,
+ done: make(chan error, 1),
+ out: &SyncBuffer{},
+ }
+
+ go func() {
+ client.done <- terminalSession(t.Context(), conn, reader, client.out)
+ }()
+
+ t.Cleanup(func() {
+ _ = writer.Close()
+ })
+
+ return client
+}
"path/filepath"
"strings"
"testing"
- "time"
"github.com/stretchr/testify/require"
)
})
}
}
-
-// TerminalTestClient is a terminal client of a test daemon, with a pipe as its
-// input.
-type TerminalTestClient struct {
- input *io.PipeWriter
- done chan error
- out *SyncBuffer
-}
-
-// Type writes one line to the input of the client.
-func (c *TerminalTestClient) Type(line string) {
- _, err := io.WriteString(c.input, line+"\n")
- if err != nil {
- panic(err)
- }
-}
-
-// Output returns what the client showed.
-func (c *TerminalTestClient) Output() string {
- return c.out.String()
-}
-
-// WaitFor waits until the client showed text.
-func (c *TerminalTestClient) WaitFor(t *testing.T, text string) {
- t.Helper()
-
- require.Eventually(t, func() bool {
- return strings.Contains(c.Output(), text)
- }, 10*time.Second, time.Millisecond, "output so far: %q", c.Output())
-}
-
-// Close ends the input and waits for the client.
-func (c *TerminalTestClient) Close() error {
- err := c.input.Close()
- if err != nil {
- return err
- }
-
- return <-c.done
-}
-
-// terminalStartTestClient connects a client to the daemon at socket.
-func terminalStartTestClient(t *testing.T, socket string) *TerminalTestClient {
- t.Helper()
-
- conn, err := net.Dial("unix", socket)
- require.NoError(t, err)
-
- reader, writer := io.Pipe()
- client := &TerminalTestClient{
- input: writer,
- done: make(chan error, 1),
- out: &SyncBuffer{},
- }
-
- go func() {
- client.done <- terminalSession(t.Context(), conn, reader, client.out)
- }()
-
- t.Cleanup(func() {
- _ = writer.Close()
- })
-
- return client
-}
--- /dev/null
+package main
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func treeTestNode(level, index int, text string) Node {
+ return Node{
+ Level: level,
+ Index: index,
+ Text: text,
+ Size: len(text),
+ }
+}
+
+// treeReadNode reads the node (l, i) of tree, which must be built.
+func treeReadNode(t *testing.T, tree *Tree, level, index int) Node {
+ t.Helper()
+
+ node, built, err := tree.Node(level, index)
+ require.NoError(t, err)
+ require.True(t, built, "node (%d, %d)", level, index)
+
+ return node
+}
+
+// treeIsBuilt tells if the node (l, i) of tree is built.
+func treeIsBuilt(t *testing.T, tree *Tree, level, index int) bool {
+ t.Helper()
+
+ built, err := tree.Built(level, index)
+ require.NoError(t, err)
+
+ return built
+}
require.Equal(t, "reply: b", treeReadNode(t, tree, 0, 1).Text)
require.NoError(t, tree.Add(treeTestNode(1, 0, "user: a\nreply: b")))
}
-
-func treeTestNode(level, index int, text string) Node {
- return Node{
- Level: level,
- Index: index,
- Text: text,
- Size: len(text),
- }
-}
-
-// treeReadNode reads the node (l, i) of tree, which must be built.
-func treeReadNode(t *testing.T, tree *Tree, level, index int) Node {
- t.Helper()
-
- node, built, err := tree.Node(level, index)
- require.NoError(t, err)
- require.True(t, built, "node (%d, %d)", level, index)
-
- return node
-}
-
-// treeIsBuilt tells if the node (l, i) of tree is built.
-func treeIsBuilt(t *testing.T, tree *Tree, level, index int) bool {
- t.Helper()
-
- built, err := tree.Built(level, index)
- require.NoError(t, err)
-
- return built
-}