Image input

This commit is contained in:
Ian Gulliver
2026-10-14 20:07:26 -07:00
parent bd8702ee87
commit 9e741ede32
27 changed files with 1044 additions and 122 deletions
+1 -1
View File
@@ -45,6 +45,6 @@ func (m *Manager) handoff(ctx context.Context, sessionID, handoff string) error
return err
}
_, err = m.create(ctx, task, creator, handoffPreamble+handoff, sess.Model, "byre")
_, err = m.create(ctx, task, creator, handoffPreamble+handoff, sess.Model, "byre", nil)
return err
}
+1 -1
View File
@@ -250,7 +250,7 @@ func (m *Manager) Deliver(ctx context.Context, sessionID string) error {
return err
}
_, err = m.turn(ctx, sess, task, pending[0].Response.User, deliveryMessage(pending))
_, err = m.turn(ctx, sess, task, pending[0].Response.User, deliveryMessage(pending), nil)
if errors.Is(err, ErrConflict) {
return nil
}
+29 -15
View File
@@ -83,11 +83,11 @@ func New(st *store.Store, limits Limits, worktreeRoot string) *Manager {
}
}
func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, prompt, model string) (api.AgentSession, error) {
return m.create(ctx, task, creator, prompt, model, creator.Login)
func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, prompt, model string, images ...string) (api.AgentSession, error) {
return m.create(ctx, task, creator, prompt, model, creator.Login, images)
}
func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, prompt, model, actor string) (api.AgentSession, error) {
func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, prompt, model, actor string, images []string) (api.AgentSession, error) {
if creator.GitName == "" || creator.GitEmail == "" {
return api.AgentSession{}, ErrNoGitIdentity
}
@@ -124,10 +124,10 @@ func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, p
return api.AgentSession{}, err
}
return m.turn(ctx, sess, task, actor, prompt)
return m.turn(ctx, sess, task, actor, prompt, images)
}
func (m *Manager) Send(ctx context.Context, sessionID, actor, text, model string) (api.AgentSession, error) {
func (m *Manager) Send(ctx context.Context, sessionID, actor, text, model string, images ...string) (api.AgentSession, error) {
sess, err := m.store.GetSession(ctx, sessionID)
if err != nil {
return api.AgentSession{}, err
@@ -150,7 +150,7 @@ func (m *Manager) Send(ctx context.Context, sessionID, actor, text, model string
return api.AgentSession{}, err
}
return m.turn(ctx, sess, task, actor, text)
return m.turn(ctx, sess, task, actor, text, images)
}
func (m *Manager) Interrupt(ctx context.Context, sessionID string) (api.AgentSession, error) {
@@ -249,7 +249,7 @@ func (m *Manager) Recover(ctx context.Context) error {
continue
}
err = m.message(ctx, l, "byre", restartNotice)
err = m.message(ctx, l, "byre", restartNotice, nil)
if err != nil {
return err
}
@@ -310,7 +310,7 @@ func (m *Manager) Usage(ctx context.Context) (Usage, error) {
return u, nil
}
func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string) (api.AgentSession, error) {
func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, images []string) (api.AgentSession, error) {
m.capMu.Lock()
defer m.capMu.Unlock()
@@ -320,7 +320,7 @@ func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task
}
if room {
return m.start(ctx, sess, task, actor, text, api.AgentSessionStateIdle)
return m.start(ctx, sess, task, actor, text, images, api.AgentSessionStateIdle)
}
err = transition(ctx, m.store, sess.Id, api.AgentSessionStateIdle, api.AgentSessionStateQueued, actor)
@@ -333,6 +333,7 @@ func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task
Vendor: sess.Vendor,
Actor: actor,
Text: text,
Images: images,
CreatedAt: time.Now().UTC(),
})
if err != nil {
@@ -342,7 +343,7 @@ func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task
return m.store.GetSession(ctx, sess.Id)
}
func (m *Manager) start(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, from api.AgentSessionState) (api.AgentSession, error) {
func (m *Manager) start(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, images []string, from api.AgentSessionState) (api.AgentSession, error) {
err := transition(ctx, m.store, sess.Id, from, api.AgentSessionStateRunning, actor)
if err != nil {
return api.AgentSession{}, err
@@ -360,7 +361,7 @@ func (m *Manager) start(ctx context.Context, sess api.AgentSession, task api.Tas
return api.AgentSession{}, err
}
err = m.message(ctx, l, actor, text)
err = m.message(ctx, l, actor, text, images)
if err != nil {
return api.AgentSession{}, err
}
@@ -430,7 +431,7 @@ func (m *Manager) drain(ctx context.Context) error {
return err
}
_, err = m.start(ctx, sess, task, q.Actor, q.Text, api.AgentSessionStateQueued)
_, err = m.start(ctx, sess, task, q.Actor, q.Text, q.Images, api.AgentSessionStateQueued)
if err != nil {
slog.Error("start queued turn", "session", q.SessionID, "err", err)
}
@@ -467,12 +468,25 @@ func transition(ctx context.Context, st *store.Store, id string, from, to api.Ag
return err
}
func (m *Manager) message(ctx context.Context, l *live, actor, text string) error {
func (m *Manager) message(ctx context.Context, l *live, actor, text string, images []string) error {
imgs := []claude.Image{}
for _, id := range images {
b, data, err := m.store.ReadBlob(ctx, id)
if err != nil {
return err
}
imgs = append(imgs, claude.Image{MediaType: b.ContentType, Data: data})
}
head, err := git.Head(l.dir)
if err != nil {
return err
}
err = m.emit(ctx, l, actor, "user.message", map[string]any{"text": text, "head": head})
payload := map[string]any{"text": text, "head": head}
if len(images) > 0 {
payload["images"] = images
}
err = m.emit(ctx, l, actor, "user.message", payload)
if err != nil {
return err
}
@@ -485,7 +499,7 @@ func (m *Manager) message(ctx context.Context, l *live, actor, text string) erro
l.rateLimited = nil
m.mu.Unlock()
return l.sess.Send(text)
return l.sess.Send(text, imgs...)
}
func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task api.Task) (*live, error) {
+1 -1
View File
@@ -119,7 +119,7 @@ func (m *Manager) createTaskFromAgent(ctx context.Context, l *live, raw json.Raw
return "", err
}
_, err = m.create(ctx, task, creator, args.Prompt, model, agentActor)
_, err = m.create(ctx, task, creator, args.Prompt, model, agentActor, nil)
if err != nil {
return "", fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err)
}
+137 -69
View File
@@ -304,6 +304,14 @@ type Attachment struct {
// AttachmentKind defines model for Attachment.Kind.
type AttachmentKind string
// Blob defines model for Blob.
type Blob struct {
ContentType string `json:"content_type"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by"`
Id string `json:"id"`
}
// Error defines model for Error.
type Error struct {
Message string `json:"message"`
@@ -412,9 +420,14 @@ type InboxState string
// MessageCreate defines model for MessageCreate.
type MessageCreate struct {
// Images Blob ids from uploadBlob, sent to the agent as images alongside the text.
Images *[]string `json:"images,omitempty"`
// Model Model for this turn and later ones. When it differs from the session's model, the session switches before the message is sent.
Model *string `json:"model,omitempty"`
Text string `json:"text"`
// Text May be empty when images are given.
Text string `json:"text"`
}
// Project defines model for Project.
@@ -436,8 +449,10 @@ type ProjectCreate struct {
// SessionCreate defines model for SessionCreate.
type SessionCreate struct {
Model *string `json:"model,omitempty"`
Prompt string `json:"prompt"`
// Images Blob ids from uploadBlob, sent to the agent as images alongside the prompt.
Images *[]string `json:"images,omitempty"`
Model *string `json:"model,omitempty"`
Prompt string `json:"prompt"`
}
// Status defines model for Status.
@@ -581,6 +596,12 @@ type ServerInterface interface {
// (GET /api/admin/status)
GetStatus(w http.ResponseWriter, r *http.Request)
// (POST /api/blobs)
UploadBlob(w http.ResponseWriter, r *http.Request)
// (GET /api/blobs/{blobId})
GetBlob(w http.ResponseWriter, r *http.Request, blobId string)
// (GET /api/events)
StreamEvents(w http.ResponseWriter, r *http.Request, params StreamEventsParams)
@@ -677,6 +698,46 @@ func (siw *ServerInterfaceWrapper) GetStatus(w http.ResponseWriter, r *http.Requ
handler.ServeHTTP(w, r)
}
// UploadBlob operation middleware
func (siw *ServerInterfaceWrapper) UploadBlob(w http.ResponseWriter, r *http.Request) {
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.UploadBlob(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// GetBlob operation middleware
func (siw *ServerInterfaceWrapper) GetBlob(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
// ------------- Path parameter "blobId" -------------
var blobId string
err = runtime.BindStyledParameterWithOptions("simple", "blobId", r.PathValue("blobId"), &blobId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "blobId", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetBlob(w, r, blobId)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// StreamEvents operation middleware
func (siw *ServerInterfaceWrapper) StreamEvents(w http.ResponseWriter, r *http.Request) {
@@ -1555,6 +1616,8 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/messages", wrapper.SendMessage)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/compact", wrapper.CompactSession)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/interrupt", wrapper.InterruptSession)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/blobs", wrapper.UploadBlob)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/blobs/{blobId}", wrapper.GetBlob)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/inbox", wrapper.ListInbox)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/inbox/{itemId}", wrapper.GetInboxItem)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/inbox/responses", wrapper.RespondInbox)
@@ -1569,72 +1632,77 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
// const string: with thousands of chunks the chained `+` fold is several
// times slower for the Go compiler than parsing a slice literal.
var swaggerSpec = []string{
"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",
"sDuc+/v/CwAA//8=",
"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",
"cpN/2wrn4eFfAQAA//8=",
}
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
+27 -2
View File
@@ -5,6 +5,7 @@ import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
@@ -359,10 +360,34 @@ func (s *Session) ID() string {
return s.id
}
func (s *Session) Send(text string) error {
type Image struct {
MediaType string
Data []byte
}
func (s *Session) Send(text string, images ...Image) error {
var content any = text
if len(images) > 0 {
blocks := []map[string]any{}
for _, img := range images {
blocks = append(blocks, map[string]any{
"type": "image",
"source": map[string]any{
"type": "base64",
"media_type": img.MediaType,
"data": base64.StdEncoding.EncodeToString(img.Data),
},
})
}
if text != "" {
blocks = append(blocks, map[string]any{"type": "text", "text": text})
}
content = blocks
}
return s.write(map[string]any{
"type": "user",
"message": map[string]any{"role": "user", "content": text},
"message": map[string]any{"role": "user", "content": content},
"parent_tool_use_id": nil,
"session_id": s.ID(),
})
+29
View File
@@ -1,8 +1,13 @@
package claude_test
import (
"bytes"
"context"
"encoding/json"
"image"
"image/color"
"image/draw"
"image/png"
"os"
"os/exec"
"path/filepath"
@@ -198,6 +203,30 @@ func TestInterrupt(t *testing.T) {
}
}
func TestImageInput(t *testing.T) {
t.Parallel()
ctx := testContext(t)
s := start(t, ctx, claude.Options{Cwd: scratchRepo(t)})
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
draw.Draw(img, img.Bounds(), &image.Uniform{C: color.RGBA{R: 255, A: 255}}, image.Point{}, draw.Src)
buf := bytes.Buffer{}
err := png.Encode(&buf, img)
if err != nil {
t.Fatalf("encode png: %v", err)
}
err = s.Send("Without using any tools, what single colour fills the attached image? Reply with just the colour name.", claude.Image{MediaType: "image/png", Data: buf.Bytes()})
if err != nil {
t.Fatalf("send: %v", err)
}
text := waitResult(t, ctx, s, nil)
if !strings.Contains(strings.ToLower(text), "red") {
t.Fatalf("model didn't see the image; assistant said %q", text)
}
}
func TestResume(t *testing.T) {
t.Parallel()
ctx := testContext(t)
File diff suppressed because one or more lines are too long
+83
View File
@@ -0,0 +1,83 @@
package server
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"g.fc.run/misc/byre/internal/store"
)
const maxBlobBytes = 5 << 20
var imageTypes = map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/gif": true,
"image/webp": true,
}
func (s *Server) UploadBlob(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBlobBytes))
maxErr := &http.MaxBytesError{}
if errors.As(err, &maxErr) {
writeError(w, http.StatusRequestEntityTooLarge, fmt.Errorf("images are limited to %d MiB", maxBlobBytes>>20))
return
}
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
contentType := http.DetectContentType(data)
if !imageTypes[contentType] {
writeError(w, http.StatusBadRequest, fmt.Errorf("unsupported image type %s; use PNG, JPEG, GIF or WebP", contentType))
return
}
b, err := s.store.CreateBlob(r.Context(), contentType, data, actor(r))
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusCreated, b)
}
func (s *Server) GetBlob(w http.ResponseWriter, r *http.Request, blobID string) {
b, path, err := s.store.GetBlob(r.Context(), blobID)
if err != nil {
writeStoreError(w, err)
return
}
f, err := os.Open(path)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
defer f.Close()
w.Header().Set("Content-Type", b.ContentType)
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
http.ServeContent(w, r, "", b.CreatedAt, f)
}
func (s *Server) images(w http.ResponseWriter, r *http.Request, ids *[]string) ([]string, bool) {
if ids == nil {
return nil, true
}
for _, id := range *ids {
_, _, err := s.store.GetBlob(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusBadRequest, err)
return nil, false
}
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return nil, false
}
}
return *ids, true
}
+14 -4
View File
@@ -495,7 +495,12 @@ func (s *Server) CreateSession(w http.ResponseWriter, r *http.Request, taskID st
model = *req.Model
}
sess, err := s.agents.Create(r.Context(), t, u, req.Prompt, model)
images, ok := s.images(w, r, req.Images)
if !ok {
return
}
sess, err := s.agents.Create(r.Context(), t, u, req.Prompt, model, images...)
if err != nil {
writeAgentError(w, err)
return
@@ -518,8 +523,13 @@ func (s *Server) SendMessage(w http.ResponseWriter, r *http.Request, sessionID s
return
}
if strings.TrimSpace(req.Text) == "" {
writeError(w, http.StatusBadRequest, errors.New("text is required"))
images, ok := s.images(w, r, req.Images)
if !ok {
return
}
if strings.TrimSpace(req.Text) == "" && len(images) == 0 {
writeError(w, http.StatusBadRequest, errors.New("text or images are required"))
return
}
@@ -528,7 +538,7 @@ func (s *Server) SendMessage(w http.ResponseWriter, r *http.Request, sessionID s
model = *req.Model
}
sess, err := s.agents.Send(r.Context(), sessionID, actor(r), req.Text, model)
sess, err := s.agents.Send(r.Context(), sessionID, actor(r), req.Text, model, images...)
if err != nil {
writeAgentError(w, err)
return
+64
View File
@@ -0,0 +1,64 @@
package store
import (
"context"
"database/sql"
"errors"
"os"
"path/filepath"
"time"
"g.fc.run/misc/byre/internal/api"
)
func (s *Store) CreateBlob(ctx context.Context, contentType string, data []byte, actor string) (api.Blob, error) {
b := api.Blob{
Id: NewID(),
ContentType: contentType,
CreatedBy: actor,
CreatedAt: time.Now().UTC(),
}
rel := filepath.Join("blobs", b.Id)
err := os.MkdirAll(filepath.Join(s.dataDir(), "blobs"), 0o755)
if err != nil {
return b, err
}
err = os.WriteFile(filepath.Join(s.dataDir(), rel), data, 0o644)
if err != nil {
return b, err
}
_, err = s.db.ExecContext(ctx, `INSERT INTO blobs (id, content_type, path, created_by, created_at) VALUES (?, ?, ?, ?, ?)`,
b.Id, b.ContentType, rel, b.CreatedBy, formatTime(b.CreatedAt))
return b, err
}
func (s *Store) GetBlob(ctx context.Context, id string) (api.Blob, string, error) {
b := api.Blob{}
rel := ""
createdAt := ""
err := s.db.QueryRowContext(ctx, `SELECT id, content_type, path, created_by, created_at FROM blobs WHERE id = ?`, id).
Scan(&b.Id, &b.ContentType, &rel, &b.CreatedBy, &createdAt)
if errors.Is(err, sql.ErrNoRows) {
return b, "", errors.Join(ErrNotFound, errors.New("no blob "+id))
}
if err != nil {
return b, "", err
}
b.CreatedAt, err = parseTime(createdAt)
return b, filepath.Join(s.dataDir(), rel), err
}
func (s *Store) ReadBlob(ctx context.Context, id string) (api.Blob, []byte, error) {
b, path, err := s.GetBlob(ctx, id)
if err != nil {
return b, nil, err
}
data, err := os.ReadFile(path)
return b, data, err
}
func (s *Store) dataDir() string {
return filepath.Dir(s.path)
}
+63
View File
@@ -0,0 +1,63 @@
package store_test
import (
"context"
"path/filepath"
"slices"
"testing"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/store"
)
func TestQueuedTurnKeepsImages(t *testing.T) {
ctx := context.Background()
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), func(api.Event) {})
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
now := time.Now().UTC()
_, err = st.EnsureUser(ctx, "alice", "Alice")
if err != nil {
t.Fatalf("ensure user: %v", err)
}
err = st.CreateProject(ctx, api.Project{Id: "p1", Name: "demo", RepoPath: "/nonexistent", DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now})
if err != nil {
t.Fatalf("create project: %v", err)
}
err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "t1", Branch: "t1", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now})
if err != nil {
t.Fatalf("create task: %v", err)
}
err = st.CreateSession(ctx, api.AgentSession{Id: "s1", TaskId: "t1", Vendor: api.Claude, VendorSessionId: "s1", State: api.AgentSessionStateIdle, Role: api.Implementer, CreatedBy: "alice", CreatedAt: now}, "p1")
if err != nil {
t.Fatalf("create session: %v", err)
}
b, err := st.CreateBlob(ctx, "image/png", []byte("not really a png"), "alice")
if err != nil {
t.Fatalf("create blob: %v", err)
}
got, data, err := st.ReadBlob(ctx, b.Id)
if err != nil {
t.Fatalf("read blob: %v", err)
}
if got.ContentType != "image/png" || got.CreatedBy != "alice" || string(data) != "not really a png" {
t.Fatalf("blob round trip: %+v %q", got, data)
}
err = st.EnqueueTurn(ctx, store.QueuedTurn{SessionID: "s1", Actor: "alice", Text: "look", Images: []string{b.Id}, CreatedAt: now})
if err != nil {
t.Fatalf("enqueue: %v", err)
}
queued, err := st.QueuedTurns(ctx)
if err != nil {
t.Fatalf("queued turns: %v", err)
}
if len(queued) != 1 || !slices.Equal(queued[0].Images, []string{b.Id}) {
t.Fatalf("queued turn lost its images: %+v", queued)
}
}
+38
View File
@@ -156,6 +156,44 @@ func TestMigrateRunsMigrationAddedBeforeApplied(t *testing.T) {
}
}
func TestBlobsMigrationOverPositionalCopy(t *testing.T) {
db := openTestDB(t, filepath.Join(t.TempDir(), "byre.db"))
err := migrate(db, migrations[:9])
if err != nil {
t.Fatalf("migrate: %v", err)
}
insertReport(t, db, "r1", `{"text":"x"}`)
_, err = db.Exec(`
CREATE TABLE blobs (id TEXT PRIMARY KEY, content_type TEXT NOT NULL, path TEXT NOT NULL, created_by TEXT NOT NULL REFERENCES users(login), created_at TEXT NOT NULL);
INSERT INTO blobs VALUES ('b1', 'image/png', 'blobs/b1', 'alice', 'x');
ALTER TABLE turn_queue ADD COLUMN images TEXT NOT NULL DEFAULT '[]';
INSERT INTO turn_queue (session_id, actor, text, created_at) VALUES ('s1', 'alice', 'queued', 'x');
`)
if err != nil {
t.Fatalf("positional copy: %v", err)
}
err = migrate(db, migrations)
if err != nil {
t.Fatalf("migrate: %v", err)
}
text := ""
images := ""
err = db.QueryRow(`SELECT text, images FROM turn_queue WHERE session_id = 's1'`).Scan(&text, &images)
if err != nil {
t.Fatalf("queued turn: %v", err)
}
if text != "queued" || images != "[]" {
t.Fatalf("queued turn %q %q", text, images)
}
blobs := 0
err = db.QueryRow(`SELECT count(*) FROM blobs`).Scan(&blobs)
if err != nil || blobs != 1 {
t.Fatalf("blobs %d: %v", blobs, err)
}
}
func TestFlattenReportPayloadsRunsTwice(t *testing.T) {
db := openTestDB(t, filepath.Join(t.TempDir(), "byre.db"))
err := migrate(db, migrations)
+15 -4
View File
@@ -3,6 +3,7 @@ package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
@@ -14,6 +15,7 @@ type QueuedTurn struct {
Vendor api.AgentSessionVendor
Actor string
Text string
Images []string
CreatedAt time.Time
}
@@ -44,16 +46,20 @@ func (s *Store) CountRunning(ctx context.Context) (map[api.AgentSessionVendor]in
}
func (s *Store) EnqueueTurn(ctx context.Context, q QueuedTurn) error {
images, err := json.Marshal(append([]string{}, q.Images...))
if err != nil {
return err
}
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
_, err := tx.ExecContext(ctx, `INSERT INTO turn_queue (session_id, actor, text, created_at) VALUES (?, ?, ?, ?)`,
q.SessionID, q.Actor, q.Text, formatTime(q.CreatedAt))
_, err := tx.ExecContext(ctx, `INSERT INTO turn_queue (session_id, actor, text, images, created_at) VALUES (?, ?, ?, ?, ?)`,
q.SessionID, q.Actor, q.Text, string(images), formatTime(q.CreatedAt))
return err
})
}
func (s *Store) QueuedTurns(ctx context.Context) ([]QueuedTurn, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT q.session_id, s.vendor, q.actor, q.text, q.created_at
SELECT q.session_id, s.vendor, q.actor, q.text, q.images, q.created_at
FROM turn_queue q JOIN agent_sessions s ON s.id = q.session_id
ORDER BY q.created_at
`)
@@ -66,8 +72,13 @@ func (s *Store) QueuedTurns(ctx context.Context) ([]QueuedTurn, error) {
for rows.Next() {
q := QueuedTurn{}
vendor := ""
images := ""
createdAt := ""
err = rows.Scan(&q.SessionID, &vendor, &q.Actor, &q.Text, &createdAt)
err = rows.Scan(&q.SessionID, &vendor, &q.Actor, &q.Text, &images, &createdAt)
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(images), &q.Images)
if err != nil {
return nil, err
}
+19
View File
@@ -140,6 +140,25 @@ UPDATE tasks SET status = 'discarded' WHERE status = 'completed';
`}, {"dismiss-inbox-of-closed-tasks", `
UPDATE inbox_items SET state = 'dismissed', resolved_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), snoozed_until = NULL
WHERE state = 'open' AND task_id IN (SELECT id FROM tasks WHERE status != 'active');
`}, {"add-blobs-and-queued-images", `
CREATE TABLE IF NOT EXISTS blobs (
id TEXT PRIMARY KEY,
content_type TEXT NOT NULL,
path TEXT NOT NULL,
created_by TEXT NOT NULL REFERENCES users(login),
created_at TEXT NOT NULL
);
CREATE TABLE turn_queue_next (
session_id TEXT PRIMARY KEY REFERENCES agent_sessions(id),
actor TEXT NOT NULL,
text TEXT NOT NULL,
images TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL
);
INSERT INTO turn_queue_next (session_id, actor, text, created_at) SELECT session_id, actor, text, created_at FROM turn_queue;
DROP TABLE turn_queue;
ALTER TABLE turn_queue_next RENAME TO turn_queue;
`}}
type Store struct {