Add ready_for_merge tool and prompt agents for it after review
This commit is contained in:
@@ -37,6 +37,8 @@ A task ends `merged` or `discarded`; either way its sessions are stopped, its sn
|
||||
|
||||
A task's summary is what its agent writes, before the task closes, about the whole task: what it set out to do, what was built or tried, the key decisions and why, and what was left out. The agent sets it with the `set_task_summary` MCP tool (`internal/agents/summary.go`), any time while the task is active; each call replaces `tasks.summary` and records a `task.summary` event carrying it, which the transcript shows as a note. The Merge turn's prompt and the discard turn's prompt (`summaryRequest`) both ask for it. Turn summaries don't stand in for it: each covers only its own turn, and a merged task's last one is always the Merge turn's. Tasks closed before the column existed have none. `sqlite3 byre.db "SELECT id, status, summary FROM tasks WHERE status != 'active' ORDER BY closed_at DESC LIMIT 5"` shows recent ones.
|
||||
|
||||
An implementer says its task is ready to merge with the `ready_for_merge` MCP tool (`internal/agents/ready.go`), whose `overview` covers the whole task for a human switching between tasks: a summary, tradeoffs and compromises, what was not done, and future TODOs. Each call records a `task.ready` event carrying it, which the lead gets as a signal and the transcript draws as a bubble (`ReadyBubble` in `mac/Sources/Byre/Chat.swift`) headed "ready for merge". The latest one, while the task is active and no snapshot other than an answer snapshot has been taken since it (newer work the overview doesn't cover), is drawn like an open question (the warn tint and border) and carries small Merge and Discard buttons that act like the header's, confirmation dialogs included; earlier ones are drawn like an answered question (the faint warn tint), without buttons. Recording it changes nothing else. byre asks for it (`readyPrompt`, sent as a byre message) when a task's state goes to `idle` (`Manager.Observe` calls `promptReady`) and the latest non-answer snapshot's review is `done` or `skipped`, once per snapshot. It checks and sends under the session's delivery lock (the one human messages and answers take), and only to an agent that isn't running, so the prompt never queues behind a turn that could add unreviewed work; `tasks.ready_prompt_n` holds the last snapshot asked about, set only once the prompt's turn started, so turns that change no files don't ask again and a new snapshot does. It doesn't ask for workflow stage tasks, a task with a merge after review set, or a review that failed, and it skips the `reviewing` → `idle` change of a review with findings, since those go to the agent; the turn that answers them asks once it ends without new work. The prompt tells the agent not to call the tool, and to say what's left, when the task isn't ready.
|
||||
|
||||
Agents find closed tasks with the `search_closed_tasks` MCP tool, which task agents get for their own project and the lead for one project (`project_id`) or all: a case-insensitive substring match of `query` on title, merge title, TODO item, summary and merge commit (`Store.SearchClosedTasks`), newest closed first, at most `limit` (default 20, at most 100), each with its id, outcome, merge commit, close time, creator, titles, TODO item, fork source and summary. The lead reads a closed task's transcript with `read_transcript`. There is no closed-task list in the UI; the human asks the lead.
|
||||
|
||||
`POST /api/tasks/{id}/discard` (the **Discard** button, the lead's `discard_task`, and byre itself for a finished workflow's other stages) doesn't remove anything at once: it gives the task's agent one dedicated turn to write its summary, and closes the task when that turn ends. The discard is a persisted state of the task, `tasks.discard_state`, and every step that changes it is one transaction in the store (`internal/store/discard.go`), which is what keeps it safe against turns starting, ending and byre restarting around it:
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
const leadGuidance = `You are the human's lead agent in byre, shown in the left pane of byre's app. The human talks to you there, and you coordinate every task agent on their behalf. You don't do task work yourself: task agents work in their own worktrees, and you read, direct and report on them.
|
||||
- Signals: byre sends you, as messages from byre, what happens across tasks: questions that task agents ask, errors and rate limits their sessions hit, turns they finish with their final message, questions the human answered, tasks created, reviews started and finished, a merge held behind another task's or set to start after the review (and that cancelled or dropped), starting to merge, merged or discarded, and every change of a task's state, as "<task> state: <before> → <now>." listed first among what happened at the same moment. The states are running, idle, review_pending, reviewing, merging, discarding, merged, discarded and no_agent. A finished turn arrives with its state change: review_pending when byre is about to review the turn's work, reviewing while the review runs; neither is done, since the review can still find problems, so don't treat a task's work as finished until its state goes to idle. Several may arrive in one message, followed by the current task list (as list_tasks gives it) and what your right pane shows, so you rarely need to call list_tasks after a signal. The human doesn't see signals. Messages from the human arrive separately.
|
||||
- Signals: byre sends you, as messages from byre, what happens across tasks: questions that task agents ask, errors and rate limits their sessions hit, turns they finish with their final message, a task's agent saying the task is ready to merge (with its overview of the whole task, which byre asks for once the review of its latest work has finished and its turn has ended), questions the human answered, tasks created, reviews started and finished, a merge held behind another task's or set to start after the review (and that cancelled or dropped), starting to merge, merged or discarded, and every change of a task's state, as "<task> state: <before> → <now>." listed first among what happened at the same moment. The states are running, idle, review_pending, reviewing, merging, discarding, merged, discarded and no_agent. A finished turn arrives with its state change: review_pending when byre is about to review the turn's work, reviewing while the review runs; neither is done, since the review can still find problems, so don't treat a task's work as finished until its state goes to idle. Several may arrive in one message, followed by the current task list (as list_tasks gives it) and what your right pane shows, so you rarely need to call list_tasks after a signal. The human doesn't see signals. Messages from the human arrive separately.
|
||||
- Don't respond to what the human just did. A signal about their own action (a question they answered, a task they created, merged, started merging or discarded) tells them nothing new: don't report, confirm or comment on it. Act on it only if it changes what you are doing, and then end the turn with one short line at most. When a batch of signals needs nothing from you to the human, make your final message exactly NO_REPLY and nothing else; byre folds that turn away so the human doesn't see it.
|
||||
- "Your right pane is free" means the human finished what was in the right pane. Show them the next thing that needs them, most urgent first (questions, then errors on the task's transcript tab, then finished turns, then tasks ready to merge), with one line saying what it is. If nothing needs them, show that project's project tab, then post a short status: one line per active task (what it's doing or waiting on), then up to three TODO items you'd start next (from list_tasks), each with a one-line reason. Write each item as a link to #/todo/<project_id>/<title, URL-encoded with parentheses as %28 and %29>, for example [Fix login redirect](#/todo/3f2a9c1b7d4e/Fix%20login%20redirect): byre draws it with a Start button the human can click, and once the task exists, its state. Start one with start_todo only when the human picks it. If that says nothing new since your last status, make your final message NO_REPLY.
|
||||
- Whenever you suggest new work that isn't in TODO.md (a task you think is worth doing, an improvement to byre or to your own role), write it as a link to #/suggest/<project_id>/<title>/<brief>, both URL-encoded with parentheses as %28 and %29, for example [Lead tool for admin status](#/suggest/3f2a9c1b7d4e/Lead%20tool%20for%20admin%20status/Add%20a%20lead%20tool%20that%20reads%20%2Fapi%2Fadmin%2Fstatus%20...). The brief is the new task agent's whole first message, so make it a complete brief, as for create_task. byre draws the link with two buttons: Start creates the task with that title and brief at once, without you, and Discuss sends you a message asking to talk the suggestion over. Once an active task has that title, it shows the task's state instead. Don't ask whether to start a suggestion; the buttons do that.
|
||||
@@ -320,8 +320,11 @@ func (m *Manager) warnLeadContext(ctx context.Context, l *live) error {
|
||||
func (m *Manager) Observe(events []api.Event) {
|
||||
batch := []api.Event{}
|
||||
for _, ev := range events {
|
||||
if ev.Kind == "task.state" && ev.Payload["to"] == string(api.TaskStateIdle) && ev.TaskId != nil {
|
||||
go m.promptReady(*ev.TaskId, ev.Payload["from"])
|
||||
}
|
||||
switch ev.Kind {
|
||||
case "inbox.created", "inbox.updated", "task.created", "task.merge_queued", "task.merge_after_review", "task.merge_after_review_cancelled", "task.merge_after_review_dropped", "task.merge_started", "task.merged", "task.discarded", "turn.ended", "task.workflow_stage", "task.workflow_finished", "review.started", "review.finished", "task.state":
|
||||
case "inbox.created", "inbox.updated", "task.created", "task.merge_queued", "task.merge_after_review", "task.merge_after_review_cancelled", "task.merge_after_review_dropped", "task.merge_started", "task.merged", "task.discarded", "turn.ended", "task.workflow_stage", "task.workflow_finished", "review.started", "review.finished", "task.state", "task.ready":
|
||||
case "user.message":
|
||||
if ev.TaskId == nil || !humanActor(ev.Actor) {
|
||||
continue
|
||||
@@ -528,6 +531,9 @@ func (m *Manager) signalText(ctx context.Context, ev api.Event) (string, error)
|
||||
case "task.discarded":
|
||||
return fmt.Sprintf("%s was discarded by %s.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor), nil
|
||||
|
||||
case "task.ready":
|
||||
return fmt.Sprintf("%s's agent says the task is ready to merge:\n%s", m.taskLabel(ctx, deref(ev.TaskId)), clip(fmt.Sprint(ev.Payload["overview"]), 3000)), nil
|
||||
|
||||
case "task.workflow_stage":
|
||||
return fmt.Sprintf("%s started stage %q of workflow %q.", m.taskLabel(ctx, deref(ev.TaskId)), fmt.Sprint(ev.Payload["stage"]), ev.Payload["workflow"]), nil
|
||||
|
||||
|
||||
@@ -747,7 +747,7 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api
|
||||
return nil, err
|
||||
}
|
||||
l.notes = "\n\n" + taskNotesGuidance + "\n\n" + notes
|
||||
tools = append(tools, m.suggestNoteTool(l))
|
||||
tools = append(tools, m.suggestNoteTool(l), m.readyForMergeTool(l))
|
||||
}
|
||||
if sess.Role == api.Implementer && sess.Member != "" {
|
||||
sessions, err := m.store.ListSessions(ctx, task.Id)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"g.fc.run/ai/byre/internal/api"
|
||||
"g.fc.run/ai/byre/internal/mcp"
|
||||
"g.fc.run/ai/byre/internal/store"
|
||||
)
|
||||
|
||||
const readyPrompt = "Your turn has ended and the review of your latest work has finished. If the task is ready to merge, call ready_for_merge with an overview of the whole task, then end your turn with one line. " +
|
||||
"If it isn't (work remains that you mean to do, or you're waiting on the human), don't call it: say what's left in one line and end your turn. Don't change any files this turn."
|
||||
|
||||
type readyArgs struct {
|
||||
Overview string `json:"overview"`
|
||||
}
|
||||
|
||||
func (m *Manager) readyForMergeTool(l *live) mcp.Tool {
|
||||
return mcp.Tool{
|
||||
Name: "ready_for_merge",
|
||||
Description: "Tell the human this task is ready to merge, with an overview of the whole task: all of the branch's work, not just what changed since your last call or the last discussion. " +
|
||||
"Write it for someone jumping between tasks who hasn't followed this one: a summary of what the change does, the tradeoffs and compromises made, what was not done, and follow-up work for later. " +
|
||||
"byre shows it in the task's transcript and passes it to the human's lead agent.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"overview": {"type": "string", "description": "Markdown, with sections for the summary, tradeoffs and compromises, not done, and future TODOs; leave out a section with nothing in it."}
|
||||
},
|
||||
"required": ["overview"]
|
||||
}`),
|
||||
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
args := readyArgs{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("malformed ready_for_merge call: %w", err)
|
||||
}
|
||||
overview := strings.TrimSpace(args.Overview)
|
||||
if overview == "" {
|
||||
return "", fmt.Errorf("%w: overview must not be empty", ErrInvalid)
|
||||
}
|
||||
err = m.store.MarkReady(ctx, l.taskID, overview, agentActor)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return "", fmt.Errorf("%w: this task is closed", ErrConflict)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "Recorded: the human sees this task as ready to merge, with your overview.", nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) promptReady(taskID string, from any) {
|
||||
ctx := context.Background()
|
||||
err := m.tryPromptReady(ctx, taskID, from == string(api.TaskStateReviewing))
|
||||
if err != nil {
|
||||
slog.Error("prompt ready for merge", "task", taskID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) tryPromptReady(ctx context.Context, taskID string, fromReview bool) error {
|
||||
sess, found, err := m.liveSession(ctx, taskID)
|
||||
if err != nil || !found {
|
||||
return err
|
||||
}
|
||||
unlock := m.lockDelivery(sess.Id)
|
||||
defer unlock()
|
||||
|
||||
task, err := m.store.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if task.Status != api.TaskStatusActive || task.State != api.TaskStateIdle || task.WorkflowStage != nil || task.MergeAfterReview {
|
||||
return nil
|
||||
}
|
||||
sess, err = m.store.GetSession(ctx, sess.Id)
|
||||
if err != nil || sess.State == api.AgentSessionStateRunning {
|
||||
return err
|
||||
}
|
||||
latest, err := m.store.LastSnapshotNot(ctx, taskID, api.SnapshotTriggerAnswer)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reviews, err := m.store.ListReviews(ctx, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var review *api.Review
|
||||
for _, r := range reviews {
|
||||
if r.SnapshotN == latest.N {
|
||||
review = &r
|
||||
}
|
||||
}
|
||||
if review == nil || (review.State != api.ReviewStateDone && review.State != api.ReviewStateSkipped) {
|
||||
return nil
|
||||
}
|
||||
if fromReview && review.State == api.ReviewStateDone && review.Result != nil {
|
||||
findings, _ := (*review.Result)["findings"].([]any)
|
||||
if len(findings) > 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
prompted, err := m.store.ReadyPromptN(ctx, taskID)
|
||||
if err != nil || prompted >= latest.N {
|
||||
return err
|
||||
}
|
||||
_, err = m.Send(ctx, sess.Id, "byre", readyPrompt, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.store.SetReadyPromptN(ctx, taskID, latest.N)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("ready for merge prompted", "task", taskID, "snapshot", latest.N, "session", sess.Id)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE tasks ADD COLUMN ready_prompt_n INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -746,6 +746,34 @@ func (s *Store) SetTaskSummary(ctx context.Context, id, summary, actor string) e
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) ReadyPromptN(ctx context.Context, id string) (int, error) {
|
||||
n := 0
|
||||
err := s.db.QueryRowContext(ctx, `SELECT ready_prompt_n FROM tasks WHERE id = ?`, id).Scan(&n)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, ErrNotFound
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *Store) SetReadyPromptN(ctx context.Context, id string, snapshotN int) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE tasks SET ready_prompt_n = ? WHERE id = ?`, snapshotN, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) MarkReady(ctx context.Context, id, overview, actor string) error {
|
||||
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||
projectID := ""
|
||||
err := tx.QueryRowContext(ctx, `SELECT project_id FROM tasks WHERE id = ? AND status = ?`, id, string(api.TaskStatusActive)).Scan(&projectID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emit(api.Event{Kind: "task.ready", ProjectId: &projectID, TaskId: &id, Actor: actor, Payload: map[string]any{"id": id, "overview": overview}})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) TaskSummary(ctx context.Context, id string) (string, error) {
|
||||
summary := ""
|
||||
err := s.db.QueryRowContext(ctx, `SELECT summary FROM tasks WHERE id = ?`, id).Scan(&summary)
|
||||
|
||||
@@ -472,6 +472,7 @@ private struct MergeButton: View {
|
||||
let task: ByreTask
|
||||
let busy: Bool
|
||||
let unmerged: Bool
|
||||
var small = false
|
||||
@Environment(Store.self) private var store
|
||||
@Environment(Overlays.self) private var overlays
|
||||
|
||||
@@ -488,12 +489,18 @@ private struct MergeButton: View {
|
||||
: task.parentTaskId != nil
|
||||
? "Merge this subtask into its parent task's branch, or into \(target) (⌘S)"
|
||||
: "The agent merges \(target) in, then byre squashes into one commit on \(target) and pushes it to origin; held while another task in the project is merging (⌘S)"
|
||||
Button(intoParent ? "Merging into parent" : held ? "Merge held" : task.mergeAfterReview ? "Merge after review" : "Merge") {
|
||||
let button = Button(intoParent ? "Merging into parent" : held ? "Merge held" : task.mergeAfterReview ? "Merge after review" : "Merge") {
|
||||
Task {
|
||||
await confirmMerge(task.id, store, overlays)
|
||||
}
|
||||
}
|
||||
.buttonStyle(WebButtonStyle())
|
||||
Group {
|
||||
if small {
|
||||
button.buttonStyle(SmallLinkButtonStyle())
|
||||
} else {
|
||||
button.buttonStyle(WebButtonStyle())
|
||||
}
|
||||
}
|
||||
.disabled(busy || held || intoParent || !unmerged)
|
||||
.help(help)
|
||||
}
|
||||
@@ -502,17 +509,24 @@ private struct MergeButton: View {
|
||||
private struct DiscardButton: View {
|
||||
let task: ByreTask
|
||||
let busy: Bool
|
||||
var small = false
|
||||
@Environment(Store.self) private var store
|
||||
@Environment(Overlays.self) private var overlays
|
||||
@ViewState private var frame: CGRect = .zero
|
||||
|
||||
var body: some View {
|
||||
Button("Discard") {
|
||||
let button = Button("Discard") {
|
||||
Task {
|
||||
await confirmDiscard(task, store, overlays, anchor: frame)
|
||||
}
|
||||
}
|
||||
.buttonStyle(WebButtonStyle(danger: true))
|
||||
Group {
|
||||
if small {
|
||||
button.buttonStyle(SmallLinkButtonStyle(color: Palette.danger))
|
||||
} else {
|
||||
button.buttonStyle(WebButtonStyle(danger: true))
|
||||
}
|
||||
}
|
||||
.disabled(busy)
|
||||
.help("The agent writes the task summary, then byre deletes the worktree and branch without merging")
|
||||
.onGeometryChange(for: CGRect.self) { g in
|
||||
@@ -1027,6 +1041,9 @@ struct RowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
case .ready(let when, let blocks):
|
||||
ReadyBubble(when: when, blocks: blocks, taskId: row.taskId, latest: row.id == model.builder.lastReady, unmerged: model.hasUnmerged)
|
||||
|
||||
case .handoff:
|
||||
DetailsBox(boxed: false) {
|
||||
NoteView(note: row.summary ?? Note(parts: []), cancelVisible: false)
|
||||
@@ -1115,6 +1132,42 @@ private struct QuestionBubble: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReadyBubble: View {
|
||||
let when: Date
|
||||
let blocks: [MDBlock]
|
||||
let taskId: String?
|
||||
let latest: Bool
|
||||
let unmerged: Bool
|
||||
@Environment(Store.self) private var store
|
||||
|
||||
var body: some View {
|
||||
let task = taskId.flatMap { store.findTask($0) }
|
||||
let current = latest && task?.status == .active
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Text("ready for merge")
|
||||
.font(Fonts.font(11, weight: current ? 700 : 400))
|
||||
.foregroundStyle(current ? Palette.warn : Palette.muted)
|
||||
WhenText(when: when)
|
||||
.font(Fonts.font(11))
|
||||
.foregroundStyle(Palette.muted)
|
||||
Spacer(minLength: 0)
|
||||
if current, let task {
|
||||
let busy = store.taskBusy(task.id)
|
||||
MergeButton(task: task, busy: busy, unmerged: unmerged, small: true)
|
||||
DiscardButton(task: task, busy: busy, small: true)
|
||||
}
|
||||
}
|
||||
MarkdownView(blocks: blocks, ctx: MDContext(taskId: taskId))
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(current ? Palette.warnTint : Palette.warnFaint, in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(current ? Palette.warn.opacity(0.55) : Palette.border))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolResult: View {
|
||||
let result: String?
|
||||
let unfinished: Bool
|
||||
|
||||
@@ -51,6 +51,7 @@ enum RowKind {
|
||||
case turnEnd(error: String?)
|
||||
case note(Note)
|
||||
case review(note: Note, md: [MDBlock])
|
||||
case ready(when: Date, md: [MDBlock])
|
||||
case handoff
|
||||
case snapshot(Snapshot)
|
||||
case question
|
||||
@@ -192,6 +193,7 @@ final class TranscriptBuilder {
|
||||
private(set) var context: [String: Usage] = [:]
|
||||
private(set) var open = false
|
||||
private(set) var lastMergeAfterReview: Int?
|
||||
private(set) var lastReady: Int?
|
||||
@ObservationIgnored private var ctx = Ctx()
|
||||
@ObservationIgnored private var sink: RowList?
|
||||
@ObservationIgnored private var stream: Row?
|
||||
@@ -460,6 +462,9 @@ final class TranscriptBuilder {
|
||||
let snap = try! decodePayload(ev.payload, as: Snapshot.self)
|
||||
let row = Row(.snapshot(snap), taskId: taskId)
|
||||
ctx.reviewButtons[snap.n] = row
|
||||
if snap.trigger != .answer && sink == nil {
|
||||
lastReady = nil
|
||||
}
|
||||
put(row)
|
||||
continue
|
||||
}
|
||||
@@ -540,6 +545,9 @@ final class TranscriptBuilder {
|
||||
if ev.kind == "task.merge_after_review" && sink == nil {
|
||||
lastMergeAfterReview = row.id
|
||||
}
|
||||
if ev.kind == "task.ready" && sink == nil {
|
||||
lastReady = row.id
|
||||
}
|
||||
put(row, keepHeld: ev.kind == "agent.thinking")
|
||||
}
|
||||
return contextChanged
|
||||
@@ -744,6 +752,9 @@ final class TranscriptBuilder {
|
||||
case "task.summary":
|
||||
return note([.text("\(ev.actor) set the task summary")])
|
||||
|
||||
case "task.ready":
|
||||
return Row(.ready(when: when, md: parseMarkdown(jsString(p["overview"]))), taskId: taskId)
|
||||
|
||||
case "task.discard_started":
|
||||
return note([.text("\(ev.actor) asked to discard the task; the agent writes the task summary first")])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user