Add change summaries, inline diff hunks and screenshots to reviews

This commit is contained in:
Ian Gulliver
2026-10-15 19:02:33 -07:00
parent ed40d91a42
commit a2067c2ba0
14 changed files with 442 additions and 32 deletions
-3
View File
@@ -6,9 +6,6 @@
# M3 — Second vendor and review
- Visual review output
Structured output that leans visual rather than quoting code: a summary; highlights, each with a category, severity and anchors to files or lines so the UI can jump to them; Mermaid diagrams of changed data flow, call graphs, state machines and schemas; screenshots or recordings of the running result (Playwright MCP for web projects, `xcrun simctl io` screenshot and recordVideo for iOS simulator projects); before/after pairs from the base ref and the current snapshot where possible. The UI should encourage, in increasing order: file diffs, diff stats, an agent-generated summary, agent highlights with visual explanations.
# M4 — Merging and history
- Merge-resolution stage
+4 -3
View File
@@ -89,7 +89,7 @@ curl -H 'Tailscale-User-Login: you@example.com' -H 'Tailscale-User-Name: You' ht
`POST /api/tasks/{id}/sessions` starts a Claude session in the task's worktree and sends the prompt as its first turn; `POST /api/sessions/{id}/messages` starts later turns and `POST /api/sessions/{id}/interrupt` ends the current one. A message sent while a task session's turn is running is queued (`Manager.Message` in `internal/agents/queue.go`) in `task_inputs`, keyed by task so it survives a handoff to a new session, and the request returns the running session with 202. A model it carries is applied to the session at once and takes effect from the next turn. The agent gets queued messages after its next tool call, the same way as inbox answers (see "Inbox"): each is recorded as its own `user.message` with the sender as actor and `mid_turn: true`, and the agent reads them, headed "<sender> sent you this message while you were working", in the hook's `additionalContext` (Codex: `turn/steer`). Messages are handed over oldest first, stopping at the first one with images, since mid-turn delivery carries only text; none are handed over during a handoff turn. Whatever is still queued when the turn ends goes out one message per turn, ahead of held inbox answers, from `Deliver`; startup does the same for idle sessions. Closing the task deletes its queued messages. `sqlite3 byre.db 'SELECT * FROM task_inputs'` lists them. A task has at most one session that isn't `completed`. A message may carry `model`; when it differs from the session's, byre writes it to `agent_sessions.model`, records a `session.model` event (shown as a note), and sends the live CLI a `set_model` control request before the message, so the switch applies from that turn and survives a resume (`--model`) or compaction. The composer's model menu sends `model` only when the choice differs from the session's model, or from the `session.context` model when the session has none. The session's commits use the git identity of the user who created it, so that user must have set one with `PUT /api/me`.
Images: `POST /api/blobs` takes a raw image body (type sniffed from the bytes, PNG, JPEG, GIF or WebP only, at most 5 MiB, `maxBlobBytes` in `internal/server/blobs.go`) and returns its id; `GET /api/blobs/{id}` serves it. A message or session prompt may carry `images`, a list of blob ids (unknown ids are a 400); a message may then have empty text. byre reads each blob when the turn is sent and passes it before the text: to Claude as a base64 `image` content block (`claude.Session.Send`), to Codex as an `image` input with a `data:` URL (`codex.Session.Send`). The `user.message` event records the ids as `images`. In the composer, pasting or dropping an image adds a thumbnail with a × to remove it; images upload when the message is sent, and the transcript shows them on the user's message, each linking to the full image.
Images: `POST /api/blobs` takes a raw image body (type sniffed from the bytes, PNG, JPEG, GIF or WebP only, at most 5 MiB; `ImageTypes` and `MaxBlobBytes` in `internal/store/blob.go`) and returns its id; `GET /api/blobs/{id}` serves it. A message or session prompt may carry `images`, a list of blob ids (unknown ids are a 400); a message may then have empty text. byre reads each blob when the turn is sent and passes it before the text: to Claude as a base64 `image` content block (`claude.Session.Send`), to Codex as an `image` input with a `data:` URL (`codex.Session.Send`). The `user.message` event records the ids as `images`. In the composer, pasting or dropping an image adds a thumbnail with a × to remove it; images upload when the message is sent, and the transcript shows them on the user's message, each linking to the full image.
Compaction is vendor-agnostic: `POST /api/sessions/{id}/compact` (the transcript's **Compact** button, always shown and enabled only while the task has an idle session) sends the agent `handoffPrompt` (`internal/agents/compact.go`) as a `byre` message, asking for a handoff covering the goal, decisions and why, current state, dead ends, open questions and files. That turn files no inbox summary and isn't bounced for questions. When it ends normally, its `turn.ended` carries `handoff: true`, the session is completed, and a new session on the same task and model starts with `handoffPreamble` plus the handoff as its first message (actor `byre`; its `user.message` carries `continuation: true`). The context meter tracks the new session. The request's `user.message` carries `handoff: true`, and from it the transcript puts the request and everything the agent does in that turn into one collapsed note reading "writing a handoff…", without streaming the agent's text; when the turn ends its summary becomes the `turn.ended` note, or "handoff not written" if the turn ended without one. The transcript never draws the new session's first message (`continuation: true`), so that note, which holds the handoff, is the only marker of the switch. If byre restarts during the handoff turn, the continuation is lost and the old session simply goes idle.
@@ -238,10 +238,11 @@ The fork's session guidance gets one more line (`forkGuidance` in `internal/agen
Every new snapshot of a task gets a review: a separate agent session on the vendor the task's agent doesn't use reads the change and reports on it (`internal/agents/review.go`, `internal/store/review.go`). A turn that changes no files makes no snapshot and so no review, and a snapshot whose change (the diff below) has no files, such as a task's first snapshot taken before its agent changed anything, gets no review either. Nothing else starts a review, and nothing waits for one: reviews ride along for the human to look at when they want.
- **Starting.** After `takeSnapshot` creates a snapshot, `afterSnapshot` runs `startReviewer`: it picks the vendor opposite the task's latest implementer session (`reviewVendor`) and that vendor's model in `reviewModels`, checks the snapshot's commit out detached at `worktrees/<project-id>/review-<review-id>`, creates an `agent_sessions` row with `role` `reviewer` on the task (created by the task's creator), a `reviews` row (`running`, one per snapshot), and sends `reviewPrompt` as its first turn (actor `byre`): the change as `git diff` from the previous snapshot (the snapshot commit's parent for the first) with its changed files, the merge-base diff of all the task's work for context, and the task's first message to its agent.
- **Running.** Reviewer sessions get `reviewGuidance` and only the `submit_review` tool, whose arguments (`reviewSchema`: a Markdown `summary`, which the guidance and schema require to be exactly "No significant findings." when there are no findings and otherwise a bulleted list with one line per finding, and `findings`, each with `severity`, `category`, `title`, `detail`, `path` and `line`) become the review's `result` and mark it `done`. They run without the Stop-hook bounce, mid-turn delivery, end-of-turn commit, snapshots or reports, and their `user.message` events carry no `head`. When the reviewer's turn ends byre completes its session, kills its process and removes the worktree; a review still `running` then fails with why (no submission, with the final message; an error; an interrupt). A process that exits mid-turn fails the review the same way. Merging or discarding the task fails its running reviews ("the task was closed") and ends their reviewers. On startup, a running reviewer resumes like any session, and a running review whose reviewer isn't `running` fails.
- **Running.** Reviewer sessions get `reviewGuidance` and only the `submit_review` tool, whose arguments (`reviewSchema`: `change`, a few sentences of Markdown on what the change does, required and non-empty; a Markdown `summary`, which the guidance and schema require to be exactly "No significant findings." when there are no findings and otherwise a bulleted list with one line per finding; `findings`, each with `severity`, `category`, `title`, `detail`, `path`, `line`, `end_line` and `images`; and top-level `images`) become the review's `result` and mark it `done`. The guidance encourages, without requiring, Mermaid blocks for a data flow, call graph, state machine or schema the change alters, and screenshots when the change alters something visible.
- **Screenshots.** Reviewer sessions also get the Playwright MCP server (`playwrightMCP` in `internal/agents/review.go`: `npx @playwright/mcp` at a pinned version, headless Chrome, isolated profile, absolute file paths), passed to Claude as a `stdio` entry in `--mcp-config` and to Codex as `mcp_servers` in the thread's `config`. Its automatically named files go to `worktrees/<project-id>/review-<review-id>-media`, removed with the checkout. Each `images` entry is `{path, caption}`, the path absolute or relative to the checkout; at `submit_review` byre reads every one, refuses the whole submission (nothing recorded) for a missing file or one that isn't one of `store.ImageTypes` within `store.MaxBlobBytes`, then stores each as a blob and replaces its `path` with `blob`, since the files are deleted with the checkout. They run without the Stop-hook bounce, mid-turn delivery, end-of-turn commit, snapshots or reports, and their `user.message` events carry no `head`. When the reviewer's turn ends byre completes its session, kills its process and removes the worktree; a review still `running` then fails with why (no submission, with the final message; an error; an interrupt). A process that exits mid-turn fails the review the same way. Merging or discarding the task fails its running reviews ("the task was closed") and ends their reviewers. On startup, a running reviewer resumes like any session, and a running review whose reviewer isn't `running` fails.
- **Questions.** A question's `reviews` is the review of the first snapshot the task took after the question was filed, the one at the end of the turn that asked it, computed when the item is read; the Inbox tab shows it under the question once it starts.
- **Events.** `review.started` and `review.finished` (state `done` or `failed`) carry the whole review, with the task's ids and no `agent_session_id`. A reviewer's own events carry its session id and the task's ids: the transcript skips every event of a session whose `session.created` has `role` `reviewer`, and draws each `review.finished` as a note with its outcome; the lead's `read_transcript` leaves the reviewer's events out and shows each `review.finished` with its summary and findings (`reviewLine`). `GET /api/tasks/{taskId}/sessions` and everything that looks for the task's agent (`ListSessions`) see only implementer sessions.
- **UI.** The **Reviews** tab (`web/src/reviews.ts`) shows one review at a time, picked by snapshot (see "Snapshots" for the picker): its snapshot, state or severity counts, error, summary and findings. Each finding has a **fix** button (also on review cards in the inbox) that sends the finding's severity, title, location and detail to the task's agent as a message from the user: through `POST /api/sessions/{id}/messages` when its latest live session is idle, starting a session when it has none, and refused with an alert while the agent is mid-turn. It loads when it opens, when the task changes, and on each `review.*` event in the task's stream. When the finished review of the highest-numbered snapshot has findings, the tab (or the More menu holding it) is highlighted with a badge of the finding count. Each snapshot row's **review** button gets the same treatment from that snapshot's `review.finished`: highlighted, with the count in a badge over its top-right corner. `GET /api/tasks/{taskId}/reviews` lists a task's reviews oldest first. `sqlite3 byre.db "SELECT id, task_id, snapshot_n, vendor, state, error FROM reviews ORDER BY created_at DESC LIMIT 20"` shows recent ones.
- **UI.** The **Reviews** tab (`web/src/reviews.ts`) shows one review at a time, picked by snapshot (see "Snapshots" for the picker): its snapshot, state or severity counts, error, `change`, summary, screenshots and findings. A finding with `path` and `line` shows, under its title, the lines `line` to `end_line` plus 3 either side from that file's diff for the snapshot's step (`changes?snapshot=<n>&step=true`, the diff the reviewer was given), with new-file line numbers and the finding's own lines marked; it shows nothing when those lines aren't in the diff, and "diff not available" with the error once the task is closed. Screenshots link to the full image. Each finding has a **fix** button (also on review cards in the inbox) that sends the finding's severity, title, location and detail to the task's agent as a message from the user: through `POST /api/sessions/{id}/messages` when its latest live session is idle, starting a session when it has none, and refused with an alert while the agent is mid-turn. It loads when it opens, when the task changes, and on each `review.*` event in the task's stream. When the finished review of the highest-numbered snapshot has findings, the tab (or the More menu holding it) is highlighted with a badge of the finding count. Each snapshot row's **review** button gets the same treatment from that snapshot's `review.finished`: highlighted, with the count in a badge over its top-right corner. `GET /api/tasks/{taskId}/reviews` lists a task's reviews oldest first. `sqlite3 byre.db "SELECT id, task_id, snapshot_n, vendor, state, error FROM reviews ORDER BY created_at DESC LIMIT 20"` shows recent ones.
## Events
+4 -2
View File
@@ -516,6 +516,7 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
}
tools := []mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.createTaskTool(l), m.setMergeTitleTool(l), m.exposePortTool(l)}
env := portEnv(task)
stdio := []mcp.Stdio{}
if sess.Role == api.Reviewer {
r, err := m.store.ReviewBySession(ctx, sess.Id)
if err != nil {
@@ -526,6 +527,7 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
g = reviewGuidance
tools = []mcp.Tool{m.submitReviewTool(l)}
env = nil
stdio = []mcp.Stdio{playwrightMCP(m.reviewMediaDir(task.ProjectId, r.Id))}
}
if l.owner != "" {
l.dir = filepath.Join(m.leadRoot, url.PathEscape(l.owner))
@@ -544,9 +546,9 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
switch l.vendor {
case api.Codex:
l.sess, err = m.startCodex(sctx, sess, l, g, servers, env)
l.sess, err = m.startCodex(sctx, sess, l, g, servers, stdio, env)
default:
l.sess, err = m.startClaude(sctx, sess, l, g, servers, env)
l.sess, err = m.startClaude(sctx, sess, l, g, servers, stdio, env)
}
if err != nil {
return nil, err
+4 -2
View File
@@ -55,7 +55,7 @@ type claudeProcess struct {
s *claude.Session
}
func (m *Manager) startClaude(ctx context.Context, sess api.AgentSession, l *live, g string, servers []*mcp.Server, env []string) (agentProcess, error) {
func (m *Manager) startClaude(ctx context.Context, sess api.AgentSession, l *live, g string, servers []*mcp.Server, stdio []mcp.Stdio, env []string) (agentProcess, error) {
hooks := map[string][]claude.HookMatcher{
"UserPromptSubmit": {{Func: remindGuidance(g)}},
"Stop": {{Func: m.bounceProseQuestion(l)}},
@@ -79,6 +79,7 @@ func (m *Manager) startClaude(ctx context.Context, sess api.AgentSession, l *liv
Env: env,
Guidance: g,
MCPServers: servers,
StdioMCP: stdio,
Hooks: hooks,
OnBadLine: func(line []byte, reason string) { m.recordBadLine(l, line, reason) },
}
@@ -151,7 +152,7 @@ type codexProcess struct {
s *codex.Session
}
func (m *Manager) startCodex(ctx context.Context, sess api.AgentSession, l *live, g string, servers []*mcp.Server, env []string) (agentProcess, error) {
func (m *Manager) startCodex(ctx context.Context, sess api.AgentSession, l *live, g string, servers []*mcp.Server, stdio []mcp.Stdio, env []string) (agentProcess, error) {
err := os.MkdirAll(m.codexHome, 0o700)
if err != nil {
return nil, err
@@ -166,6 +167,7 @@ func (m *Manager) startCodex(ctx context.Context, sess api.AgentSession, l *live
Guidance: g,
Reminder: g,
MCPServers: servers,
StdioMCP: stdio,
OnBadLine: func(line []byte, reason string) { m.recordBadLine(l, line, reason) },
}
switch {
+128 -5
View File
@@ -6,6 +6,8 @@ import (
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@@ -25,13 +27,28 @@ var severities = []string{"critical", "high", "medium", "low", "info"}
const reviewGuidance = `You are a reviewer running inside byre. Another agent is doing a task; you review the change it just made, and a human may read your review in byre's web UI.
- Your working directory is a detached checkout of the snapshot under review. Read, build and run whatever you need there, but don't edit, commit or push: the checkout is discarded when you finish, and the task's own worktree belongs to its agent.
- Report with byre's submit_review tool, exactly once, then end your turn with one line. A review that isn't submitted is lost.
- Each finding says what is wrong, where (path and line), why it matters and what to do about it. Leave out praise, nits and any description of the change. No findings is a fine result.
- With no findings, the summary is exactly "No significant findings." and nothing else. With findings, the summary is a bulleted Markdown list, one line per finding, and nothing else.`
- Start the review with ` + "`change`" + `: what the change does, in a few sentences, for someone who hasn't read it.
- Each finding says what is wrong, where (path and line), why it matters and what to do about it. Leave out praise and nits. No findings is a fine result.
- With no findings, the summary is exactly "No significant findings." and nothing else. With findings, the summary is a bulleted Markdown list, one line per finding, and nothing else.
- Show rather than quote. Give a finding's file and lines and byre shows that part of the diff, so don't paste code. When a picture would explain something better than prose, such as a data flow, call graph, state machine or schema the change alters, draw it in a ` + "```mermaid" + ` block. When the change alters something you can see, you can run it (on any free port on 127.0.0.1) and attach screenshots taken with the Playwright tools. Most reviews need neither; leave out a diagram or screenshot that adds nothing to the text.`
func reviewSchema() json.RawMessage {
images := map[string]any{
"type": "array",
"description": "Screenshots, only when they show something the text can't.",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "A PNG, JPEG, GIF or WebP file of at most 5 MiB you saved: absolute, or relative to your working directory. byre keeps a copy."},
"caption": map[string]any{"type": "string", "description": "One line saying what it shows."},
},
"required": []any{"path"},
},
}
b, err := json.Marshal(map[string]any{
"type": "object",
"properties": map[string]any{
"change": map[string]any{"type": "string", "description": "What the change does, in a few sentences of Markdown, for someone who hasn't read it."},
"summary": map[string]any{"type": "string", "description": "Exactly \"No significant findings.\" when findings is empty; otherwise a bulleted Markdown list with one line per finding, and nothing else."},
"findings": map[string]any{
"type": "array",
@@ -43,13 +60,16 @@ func reviewSchema() json.RawMessage {
"title": map[string]any{"type": "string", "description": "One line."},
"detail": map[string]any{"type": "string", "description": "What is wrong, why it matters and what to do, in Markdown."},
"path": map[string]any{"type": "string", "description": "Repo-relative path of the file, if the finding is about one."},
"line": map[string]any{"type": "integer"},
"line": map[string]any{"type": "integer", "description": "Line in the file as of this snapshot; byre shows the diff around it."},
"end_line": map[string]any{"type": "integer", "description": "Last line, when the finding covers a range."},
"images": images,
},
"required": []any{"severity", "title", "detail"},
},
},
"images": images,
},
"required": []any{"summary", "findings"},
"required": []any{"change", "summary", "findings"},
})
if err != nil {
panic(err)
@@ -83,6 +103,18 @@ func (m *Manager) reviewDir(projectID, reviewID string) string {
return filepath.Join(m.worktreeRoot, projectID, "review-"+reviewID)
}
func (m *Manager) reviewMediaDir(projectID, reviewID string) string {
return m.reviewDir(projectID, reviewID) + "-media"
}
func playwrightMCP(outputDir string) mcp.Stdio {
return mcp.Stdio{
Name: "playwright",
Command: "npx",
Args: []string{"--yes", "@playwright/mcp@0.0.83", "--headless", "--isolated", "--browser", "chrome", "--file-paths", "absolute", "--output-dir", outputDir},
}
}
func (m *Manager) implementerVendor(ctx context.Context, taskID string) api.AgentSessionVendor {
sessions, err := m.store.ListSessions(ctx, taskID)
if err != nil || len(sessions) == 0 {
@@ -120,6 +152,11 @@ func (m *Manager) startReviewer(ctx context.Context, task api.Task, snap api.Sna
if err != nil {
return err
}
err = os.MkdirAll(m.reviewMediaDir(project.Id, reviewID), 0o755)
if err != nil {
m.removeReviewDir(project, reviewID)
return err
}
id := newUUID()
sess := api.AgentSession{
@@ -238,13 +275,42 @@ func (m *Manager) submitReview(ctx context.Context, l *live, raw json.RawMessage
if err != nil {
return "", fmt.Errorf("submit_review takes a JSON object: %w", err)
}
if c, ok := result["change"].(string); !ok || strings.TrimSpace(c) == "" {
return "", errors.New("submit_review rejected, nothing was recorded: change must say what the change does")
}
if _, ok := result["summary"].(string); !ok {
return "", errors.New("submit_review rejected, nothing was recorded: summary must be a string")
}
if _, ok := result["findings"].([]any); !ok {
findings, ok := result["findings"].([]any)
if !ok {
return "", errors.New("submit_review rejected, nothing was recorded: findings must be a list")
}
holders := []map[string]any{result}
for _, f := range findings {
fm, ok := f.(map[string]any)
if !ok {
return "", errors.New("submit_review rejected, nothing was recorded: each finding must be an object")
}
holders = append(holders, fm)
}
pending := []reviewImage{}
for _, holder := range holders {
imgs, err := m.readReviewImages(l, holder["images"])
if err != nil {
return "", fmt.Errorf("submit_review rejected, nothing was recorded: %w", err)
}
pending = append(pending, imgs...)
}
for _, img := range pending {
b, err := m.store.CreateBlob(ctx, img.contentType, img.data, agentActor)
if err != nil {
return "", err
}
img.entry["blob"] = b.Id
delete(img.entry, "path")
}
err = m.store.FinishReview(ctx, l.reviewID, api.ReviewStateDone, result, "")
if errors.Is(err, store.ErrNotFound) {
return "", errors.New("this review was already submitted or has ended")
@@ -255,6 +321,56 @@ func (m *Manager) submitReview(ctx context.Context, l *live, raw json.RawMessage
return "Review recorded. End your turn now with one line.", nil
}
type reviewImage struct {
entry map[string]any
contentType string
data []byte
}
func (m *Manager) readReviewImages(l *live, raw any) ([]reviewImage, error) {
if raw == nil {
return nil, nil
}
list, ok := raw.([]any)
if !ok {
return nil, errors.New("images must be a list")
}
out := []reviewImage{}
for _, item := range list {
entry, ok := item.(map[string]any)
if !ok {
return nil, errors.New("each image must be an object with a path")
}
p, ok := entry["path"].(string)
if !ok || p == "" {
return nil, errors.New("each image needs a path")
}
if !filepath.IsAbs(p) {
p = filepath.Join(l.dir, p)
}
info, err := os.Stat(p)
if err != nil {
return nil, fmt.Errorf("image %s: %w", p, err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("image %s is not a file", p)
}
if info.Size() > store.MaxBlobBytes {
return nil, fmt.Errorf("image %s is %d bytes; images are limited to %d MiB", p, info.Size(), store.MaxBlobBytes>>20)
}
data, err := os.ReadFile(p)
if err != nil {
return nil, fmt.Errorf("image %s: %w", p, err)
}
contentType := http.DetectContentType(data)
if !store.ImageTypes[contentType] {
return nil, fmt.Errorf("image %s is %s; use PNG, JPEG, GIF or WebP", p, contentType)
}
out = append(out, reviewImage{entry: entry, contentType: contentType, data: data})
}
return out, nil
}
func (m *Manager) reviewTurnEnded(ctx context.Context, l *live, res endResult) error {
m.mu.Lock()
interrupted := l.interrupted
@@ -312,6 +428,10 @@ func (m *Manager) removeReviewDir(project api.Project, reviewID string) {
if err != nil {
slog.Error("remove review worktree", "review", reviewID, "err", err)
}
err = os.RemoveAll(m.reviewMediaDir(project.Id, reviewID))
if err != nil {
slog.Error("remove review media", "review", reviewID, "err", err)
}
}
func (m *Manager) failReview(ctx context.Context, reviewID, reason string) {
@@ -380,6 +500,9 @@ func reviewLine(r api.Review) string {
}
b := &strings.Builder{}
fmt.Fprintf(b, "review of snapshot %d: %s", r.SnapshotN, clip(strings.TrimSpace(fmt.Sprint(res["summary"])), 600))
if c, ok := res["change"].(string); ok && c != "" {
fmt.Fprintf(b, "\n change: %s", clip(strings.TrimSpace(c), 600))
}
findings, _ := res["findings"].([]any)
for _, f := range findings {
fm, _ := f.(map[string]any)
+4
View File
@@ -41,6 +41,7 @@ type Options struct {
Guidance string
Hooks map[string][]HookMatcher
MCPServers []*mcp.Server
StdioMCP []mcp.Stdio
Transcript io.Writer
OnBadLine func(line []byte, reason string)
}
@@ -303,6 +304,9 @@ func (s *Session) buildArgs(opts Options) ([]string, error) {
s.servers[srv.Name] = srv
servers[srv.Name] = map[string]any{"type": "sdk", "name": srv.Name, "alwaysLoad": true}
}
for _, srv := range opts.StdioMCP {
servers[srv.Name] = map[string]any{"type": "stdio", "command": srv.Command, "args": srv.Args}
}
mcpConfig, err := json.Marshal(map[string]any{"mcpServers": servers})
if err != nil {
+15
View File
@@ -33,6 +33,7 @@ type Options struct {
Guidance string
Reminder string
MCPServers []*mcp.Server
StdioMCP []mcp.Stdio
Transcript io.Writer
OnBadLine func(line []byte, reason string)
}
@@ -193,6 +194,7 @@ func (s *Session) open(ctx context.Context, opts Options) error {
ApprovalPolicy: json.RawMessage(`"never"`),
Sandbox: SandboxModeDangerFullAccess,
DeveloperInstructions: opts.Guidance,
Config: stdioConfig(opts.StdioMCP),
}, &resp)
if err != nil {
return fmt.Errorf("resume thread: %w", err)
@@ -211,6 +213,7 @@ func (s *Session) open(ctx context.Context, opts Options) error {
ApprovalPolicy: json.RawMessage(`"never"`),
Sandbox: SandboxModeDangerFullAccess,
DeveloperInstructions: opts.Guidance,
Config: stdioConfig(opts.StdioMCP),
}, &resp)
if err != nil {
return fmt.Errorf("fork thread: %w", err)
@@ -227,6 +230,7 @@ func (s *Session) open(ctx context.Context, opts Options) error {
Sandbox: SandboxModeDangerFullAccess,
DeveloperInstructions: opts.Guidance,
DynamicTools: dynamicTools(opts.MCPServers),
Config: stdioConfig(opts.StdioMCP),
}, &resp)
if err != nil {
return fmt.Errorf("start thread: %w", err)
@@ -235,6 +239,17 @@ func (s *Session) open(ctx context.Context, opts Options) error {
return nil
}
func stdioConfig(servers []mcp.Stdio) map[string]any {
if len(servers) == 0 {
return nil
}
cfg := map[string]any{}
for _, srv := range servers {
cfg[srv.Name] = map[string]any{"command": srv.Command, "args": srv.Args}
}
return map[string]any{"mcp_servers": cfg}
}
func dynamicTools(servers []*mcp.Server) []DynamicToolSpec {
specs := []DynamicToolSpec{}
for _, srv := range servers {
+6
View File
@@ -21,6 +21,12 @@ type Server struct {
Tools []Tool
}
type Stdio struct {
Name string
Command string
Args []string
}
type request struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
+3 -12
View File
@@ -10,20 +10,11 @@ import (
"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))
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, store.MaxBlobBytes))
maxErr := &http.MaxBytesError{}
if errors.As(err, &maxErr) {
writeError(w, http.StatusRequestEntityTooLarge, fmt.Errorf("images are limited to %d MiB", maxBlobBytes>>20))
writeError(w, http.StatusRequestEntityTooLarge, fmt.Errorf("images are limited to %d MiB", store.MaxBlobBytes>>20))
return
}
if err != nil {
@@ -32,7 +23,7 @@ func (s *Server) UploadBlob(w http.ResponseWriter, r *http.Request) {
}
contentType := http.DetectContentType(data)
if !imageTypes[contentType] {
if !store.ImageTypes[contentType] {
writeError(w, http.StatusBadRequest, fmt.Errorf("unsupported image type %s; use PNG, JPEG, GIF or WebP", contentType))
return
}
+9
View File
@@ -11,6 +11,15 @@ import (
"g.fc.run/misc/byre/internal/api"
)
const MaxBlobBytes = 5 << 20
var ImageTypes = map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/gif": true,
"image/webp": true,
}
func (s *Store) CreateBlob(ctx context.Context, contentType string, data []byte, actor string) (api.Blob, error) {
b := api.Blob{
Id: NewID(),
+93 -3
View File
@@ -40,14 +40,104 @@ function reviewResult(r, taskId) {
const res = (r.result ?? {});
const findings = findingsOf(r);
const md = { taskId };
return h("div", { class: "review-result" }, markdown(String(res["summary"] ?? ""), "md review-summary", md), findings.length > 0
return h("div", { class: "review-result" }, typeof res["change"] === "string" && res["change"] ? markdown(res["change"], "md review-change", md) : null, markdown(String(res["summary"] ?? ""), "md review-summary", md), reviewImages(res["images"]), findings.length > 0
? h("ol", { class: "findings" }, ...findings.map((f) => {
const severity = String(f["severity"] ?? "");
const where = typeof f["path"] === "string" && f["path"] ? `${f["path"]}${typeof f["line"] === "number" && f["line"] > 0 ? `:${f["line"]}` : ""}` : "";
return h("li", { class: `finding ${severity}` }, h("div", { class: "finding-head" }, h("span", { class: `severity ${severity}` }, severity), typeof f["category"] === "string" && f["category"] ? h("span", { class: "finding-category" }, f["category"]) : null, h("span", { class: "finding-title" }, String(f["title"] ?? "")), where ? h("code", { class: "finding-where" }, where) : null, taskId ? fixButton(taskId, r, f, where) : null), markdown(String(f["detail"] ?? ""), "md finding-detail", md));
const path = typeof f["path"] === "string" ? f["path"] : "";
const line = typeof f["line"] === "number" && f["line"] > 0 ? f["line"] : 0;
const end = typeof f["end_line"] === "number" && f["end_line"] > line ? f["end_line"] : line;
const where = path ? `${path}${line ? `:${line}${end > line ? `–${end}` : ""}` : ""}` : "";
return h("li", { class: `finding ${severity}` }, h("div", { class: "finding-head" }, h("span", { class: `severity ${severity}` }, severity), typeof f["category"] === "string" && f["category"] ? h("span", { class: "finding-category" }, f["category"]) : null, h("span", { class: "finding-title" }, String(f["title"] ?? "")), where ? h("code", { class: "finding-where" }, where) : null, taskId ? fixButton(taskId, r, f, where) : null), taskId && path && line ? findingDiff(taskId, r.snapshot_n, path, line, end) : null, markdown(String(f["detail"] ?? ""), "md finding-detail", md), reviewImages(f["images"]));
}))
: null);
}
function reviewImages(v) {
if (!Array.isArray(v) || v.length === 0) {
return null;
}
return h("div", { class: "review-images" }, ...v.map((img) => {
const src = `/api/blobs/${encodeURIComponent(String(img["blob"] ?? ""))}`;
const caption = typeof img["caption"] === "string" ? img["caption"] : "";
return h("figure", {}, h("a", { href: src, target: "_blank", rel: "noopener" }, h("img", { src, alt: caption || "screenshot", loading: "lazy" })), caption ? h("figcaption", {}, caption) : null);
}));
}
const stepChanges = new Map();
const stepPatches = new Map();
function snapshotPatch(taskId, n, path) {
const key = `${taskId}/${n}/${path}`;
const cached = stepPatches.get(key);
if (cached) {
return cached;
}
const fetched = (async () => {
const ck = `${taskId}/${n}`;
let pending = stepChanges.get(ck);
if (!pending) {
pending = get("/api/tasks/{taskId}/changes", { path: { taskId }, query: { snapshot: n, step: true } });
stepChanges.set(ck, pending);
}
const changes = await pending.catch((err) => {
stepChanges.delete(ck);
throw err;
});
const f = changes.files.find((c) => c.path === path);
if (!f || f.binary) {
return undefined;
}
const d = await get("/api/tasks/{taskId}/diff", { path: { taskId }, query: { base: changes.base, tree: changes.tree, path: f.path, old_path: f.old_path } });
return d.patch;
})();
stepPatches.set(key, fetched);
fetched.catch(() => stepPatches.delete(key));
return fetched;
}
function linesAround(patch, line, end) {
const lines = [];
let n = 0;
let inHunk = false;
for (const l of patch.split("\n")) {
const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(l);
if (m) {
n = Number(m[1]);
inHunk = true;
continue;
}
if (!inHunk) {
continue;
}
if (l.startsWith("+")) {
lines.push({ kind: "add", text: l, at: n++ });
}
else if (l.startsWith("-")) {
lines.push({ kind: "del", text: l, at: n });
}
else if (l.startsWith(" ")) {
lines.push({ kind: "ctx", text: l, at: n++ });
}
}
return lines.filter((d) => d.at >= line - 3 && d.at <= end + 3);
}
function findingDiff(taskId, n, path, line, end) {
const box = h("div", { class: "finding-diff" });
snapshotPatch(taskId, n, path).then((patch) => {
const lines = patch === undefined ? [] : linesAround(patch, line, end);
if (lines.length === 0) {
box.remove();
return;
}
const rows = [];
lines.forEach((d, i) => {
const prev = lines[i - 1];
if (prev && d.kind !== "del" && prev.kind !== "del" && d.at > prev.at + 1) {
rows.push(h("span", { class: "line hunk" }, "⋯"));
}
const mark = d.kind !== "del" && d.at >= line && d.at <= end;
rows.push(h("span", { class: `line ${d.kind}${mark ? " mark" : ""}` }, h("span", { class: "lineno" }, d.kind === "del" ? "" : String(d.at)), d.text || " "));
});
replace(box, h("pre", { class: "diff" }, ...rows));
}, (err) => replace(box, h("div", { class: "hint" }, `diff not available: ${errorText(err)}`)));
return box;
}
function fixButton(taskId, r, f, where) {
const button = h("button", { type: "button", class: "finding-fix", title: "Send this finding to the task's agent to fix" }, "fix");
button.onclick = async () => {
+1 -1
View File
File diff suppressed because one or more lines are too long
+112 -1
View File
@@ -66,14 +66,19 @@ function reviewResult(r: Review, taskId: string | undefined): HTMLElement {
return h(
"div",
{ class: "review-result" },
typeof res["change"] === "string" && res["change"] ? markdown(res["change"], "md review-change", md) : null,
markdown(String(res["summary"] ?? ""), "md review-summary", md),
reviewImages(res["images"]),
findings.length > 0
? h(
"ol",
{ class: "findings" },
...findings.map((f) => {
const severity = String(f["severity"] ?? "");
const where = typeof f["path"] === "string" && f["path"] ? `${f["path"]}${typeof f["line"] === "number" && f["line"] > 0 ? `:${f["line"]}` : ""}` : "";
const path = typeof f["path"] === "string" ? f["path"] : "";
const line = typeof f["line"] === "number" && f["line"] > 0 ? f["line"] : 0;
const end = typeof f["end_line"] === "number" && f["end_line"] > line ? f["end_line"] : line;
const where = path ? `${path}${line ? `:${line}${end > line ? `–${end}` : ""}` : ""}` : "";
return h(
"li",
{ class: `finding ${severity}` },
@@ -86,7 +91,9 @@ function reviewResult(r: Review, taskId: string | undefined): HTMLElement {
where ? h("code", { class: "finding-where" }, where) : null,
taskId ? fixButton(taskId, r, f, where) : null,
),
taskId && path && line ? findingDiff(taskId, r.snapshot_n, path, line, end) : null,
markdown(String(f["detail"] ?? ""), "md finding-detail", md),
reviewImages(f["images"]),
);
}),
)
@@ -94,6 +101,110 @@ function reviewResult(r: Review, taskId: string | undefined): HTMLElement {
);
}
function reviewImages(v: unknown): HTMLElement | null {
if (!Array.isArray(v) || v.length === 0) {
return null;
}
return h(
"div",
{ class: "review-images" },
...(v as Record<string, unknown>[]).map((img) => {
const src = `/api/blobs/${encodeURIComponent(String(img["blob"] ?? ""))}`;
const caption = typeof img["caption"] === "string" ? img["caption"] : "";
return h(
"figure",
{},
h("a", { href: src, target: "_blank", rel: "noopener" }, h("img", { src, alt: caption || "screenshot", loading: "lazy" })),
caption ? h("figcaption", {}, caption) : null,
);
}),
);
}
const stepChanges = new Map<string, Promise<Schemas["TaskChanges"]>>();
const stepPatches = new Map<string, Promise<string | undefined>>();
function snapshotPatch(taskId: string, n: number, path: string): Promise<string | undefined> {
const key = `${taskId}/${n}/${path}`;
const cached = stepPatches.get(key);
if (cached) {
return cached;
}
const fetched = (async () => {
const ck = `${taskId}/${n}`;
let pending = stepChanges.get(ck);
if (!pending) {
pending = get("/api/tasks/{taskId}/changes", { path: { taskId }, query: { snapshot: n, step: true } });
stepChanges.set(ck, pending);
}
const changes = await pending.catch((err: unknown) => {
stepChanges.delete(ck);
throw err;
});
const f = changes.files.find((c) => c.path === path);
if (!f || f.binary) {
return undefined;
}
const d = await get("/api/tasks/{taskId}/diff", { path: { taskId }, query: { base: changes.base, tree: changes.tree, path: f.path, old_path: f.old_path } });
return d.patch;
})();
stepPatches.set(key, fetched);
fetched.catch(() => stepPatches.delete(key));
return fetched;
}
type DiffLine = { kind: "add" | "del" | "ctx"; text: string; at: number };
function linesAround(patch: string, line: number, end: number): DiffLine[] {
const lines: DiffLine[] = [];
let n = 0;
let inHunk = false;
for (const l of patch.split("\n")) {
const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(l);
if (m) {
n = Number(m[1]);
inHunk = true;
continue;
}
if (!inHunk) {
continue;
}
if (l.startsWith("+")) {
lines.push({ kind: "add", text: l, at: n++ });
} else if (l.startsWith("-")) {
lines.push({ kind: "del", text: l, at: n });
} else if (l.startsWith(" ")) {
lines.push({ kind: "ctx", text: l, at: n++ });
}
}
return lines.filter((d) => d.at >= line - 3 && d.at <= end + 3);
}
function findingDiff(taskId: string, n: number, path: string, line: number, end: number): HTMLElement {
const box = h("div", { class: "finding-diff" });
snapshotPatch(taskId, n, path).then(
(patch) => {
const lines = patch === undefined ? [] : linesAround(patch, line, end);
if (lines.length === 0) {
box.remove();
return;
}
const rows: HTMLElement[] = [];
lines.forEach((d, i) => {
const prev = lines[i - 1];
if (prev && d.kind !== "del" && prev.kind !== "del" && d.at > prev.at + 1) {
rows.push(h("span", { class: "line hunk" }, "⋯"));
}
const mark = d.kind !== "del" && d.at >= line && d.at <= end;
rows.push(h("span", { class: `line ${d.kind}${mark ? " mark" : ""}` }, h("span", { class: "lineno" }, d.kind === "del" ? "" : String(d.at)), d.text || " "));
});
replace(box, h("pre", { class: "diff" }, ...rows));
},
(err: unknown) => replace(box, h("div", { class: "hint" }, `diff not available: ${errorText(err)}`)),
);
return box;
}
function fixButton(taskId: string, r: Review, f: Record<string, unknown>, where: string): HTMLElement {
const button = h("button", { type: "button", class: "finding-fix", title: "Send this finding to the task's agent to fix" }, "fix");
button.onclick = async () => {
+59
View File
@@ -1721,6 +1721,65 @@ pre.file-text .line::before {
margin-top: 4px;
}
.review-change {
margin: 8px 0;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.finding-diff {
margin-top: 6px;
border: 1px solid var(--border);
border-radius: 6px;
overflow-x: auto;
background: var(--bg);
}
.finding-diff .hint {
padding: 4px 10px;
color: var(--muted);
font-size: 11px;
}
pre.diff .lineno {
display: inline-block;
min-width: 4ch;
margin-right: 8px;
text-align: right;
color: var(--muted);
user-select: none;
}
pre.diff .mark {
box-shadow: inset 3px 0 0 var(--accent);
}
.review-images {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 8px 0;
}
.review-images figure {
margin: 0;
max-width: 100%;
}
.review-images img {
display: block;
max-width: 100%;
max-height: 360px;
border: 1px solid var(--border);
border-radius: 6px;
}
.review-images figcaption {
margin-top: 2px;
font-size: 11px;
color: var(--muted);
}
.finding-fix {
margin-left: auto;
padding: 0 8px;