Fork tasks from snapshots; snapshot only when text follows tool calls

This commit is contained in:
Ian Gulliver
2026-10-15 11:36:51 -07:00
parent 3fe0374ac5
commit 0000db1f86
25 changed files with 893 additions and 421 deletions
+59 -2
View File
@@ -475,6 +475,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/tasks/{taskId}/snapshots/{n}/fork": {
parameters: {
query?: never;
header?: never;
path: {
taskId: string;
n: number;
};
cookie?: never;
};
get?: never;
put?: never;
/** @description Creates a new task whose branch and worktree start from the snapshot's commit, with an idle agent session that continues the snapshot's agent conversation as it stood at the snapshot (the vendor's own session fork). The new session's first turn is the first message sent to it. Refused while the task is closed, and for a snapshot with no recorded agent message at or before it. */
post: operations["forkSnapshot"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/tasks/{taskId}/sessions": {
parameters: {
query?: never;
@@ -768,6 +788,10 @@ export interface components {
merge_commit?: string;
/** @description The title of the TODO.md item the task was started from; absent for other tasks. */
todo_item?: string;
/** @description The task this one was forked from; absent for other tasks. */
forked_from?: string;
/** @description With forked_from: the n of the snapshot it was forked at. */
forked_at?: number;
};
Todo: {
/** @description Whether the default branch has a TODO.md at its root. */
@@ -875,8 +899,11 @@ export interface components {
* @description The task's latest event when the snapshot was started: the tree holds at least the work up to it.
*/
event_seq: number;
/** @enum {string} */
trigger: "tool_call" | "turn_end" | "ask_human" | "timer";
/**
* @description message: the agent wrote text after one or more tool calls; turn_end: after byre's end-of-turn commit. tool_call, ask_human and timer appear only on snapshots taken by earlier builds.
* @enum {string}
*/
trigger: "message" | "turn_end" | "tool_call" | "ask_human" | "timer";
/** Format: date-time */
created_at: string;
};
@@ -901,6 +928,10 @@ export interface components {
created_by: string;
/** Format: date-time */
created_at: string;
/** @description A fork's first start resumes this vendor session, cut at forked_at, under a new vendor_session_id; absent for other sessions. */
forked_from?: string;
/** @description With forked_from: the vendor's id of the last message the fork keeps. */
forked_at?: string;
};
SessionCreate: {
prompt: string;
@@ -1887,6 +1918,32 @@ export interface operations {
404: components["responses"]["Error"];
};
};
forkSnapshot: {
parameters: {
query?: never;
header?: never;
path: {
taskId: string;
n: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The new task. */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Task"];
};
};
400: components["responses"]["Error"];
404: components["responses"]["Error"];
409: components["responses"]["Error"];
};
};
listSessions: {
parameters: {
query?: never;
+51 -86
View File
@@ -1,7 +1,7 @@
import { get, post, type AgentSession, type ByreEvent, type Participant, type Snapshot } from "./api.js";
import { ago, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
import { markdown } from "./markdown.js";
import { currentSession, findTask, focusTab, onEach, pinSnapshot, setFocus, state, type Topic } from "./state.js";
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, state, type Topic } from "./state.js";
export type Usage = { used: number; window: number; model: string };
@@ -38,7 +38,6 @@ type Run = {
first: HTMLElement;
group: { el: HTMLDetailsElement; summary: HTMLElement; body: HTMLElement } | undefined;
calls: Call[];
snaps: number[];
parent: string;
};
@@ -48,21 +47,10 @@ type Subagent = {
status: HTMLElement;
};
type Anchor = {
seq: number;
turnEnd: boolean;
el: HTMLElement;
snaps: number[];
mark: HTMLElement | undefined;
run: Run | undefined;
};
type Ctx = {
tools: Map<string, HTMLElement>;
subagents: Map<string, Subagent>;
run: Run | undefined;
anchors: Anchor[];
unplaced: Snapshot[];
root: string;
taskId: string | undefined;
lastMessage: HTMLElement | undefined;
@@ -234,8 +222,6 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
tools: new Map(),
subagents: new Map(),
run: undefined,
anchors: [],
unplaced: [],
lastMessage: undefined,
turnStart: undefined,
keepTurnStart: false,
@@ -344,12 +330,12 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
pending = pending.filter((p) => p !== entry);
};
const runHead = (run: Run) => [subagentTag(run.parent, ctx), runSummary(run.calls), snapMark(run.snaps)];
const runHead = (run: Run) => [subagentTag(run.parent, ctx), runSummary(run.calls)];
const addToRun = (el: HTMLElement, call: Call | undefined, parent: string) => {
const run = ctx.run;
if (!run || run.parent !== parent) {
ctx.run = { first: el, group: undefined, calls: call ? [call] : [], snaps: [], parent };
ctx.run = { first: el, group: undefined, calls: call ? [call] : [], parent };
put(el);
return;
}
@@ -368,52 +354,6 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
replace(run.group.summary, ...runHead(run));
};
const addAnchor = (seq: number, el: HTMLElement, turnEnd: boolean) => {
const anchor: Anchor = { seq, turnEnd, el, snaps: [], mark: undefined, run: turnEnd ? undefined : ctx.run };
ctx.anchors.push(anchor);
if (!turnEnd) {
return;
}
const waiting = ctx.unplaced;
ctx.unplaced = [];
for (const snap of waiting) {
placeSnapshot(snap);
}
};
const markAnchor = (anchor: Anchor, n: number) => {
anchor.snaps.push(n);
anchor.mark?.remove();
anchor.mark = snapMark(anchor.snaps) ?? undefined;
if (anchor.mark) {
anchor.el.append(anchor.mark);
}
const run = anchor.run;
if (!run) {
return;
}
run.snaps.push(n);
if (run.group) {
replace(run.group.summary, ...runHead(run));
}
};
const placeSnapshot = (snap: Snapshot) => {
const before = ctx.anchors.findLast((a) => a.seq <= snap.event_seq);
if (snap.trigger !== "turn_end" || before?.turnEnd) {
if (before) {
markAnchor(before, snap.n);
}
return;
}
const after = ctx.anchors.find((a) => a.turnEnd && a.seq > snap.event_seq);
if (after) {
markAnchor(after, snap.n);
return;
}
ctx.unplaced.push(snap);
};
let stream: { el: HTMLElement; text: string; timer: number } | undefined;
const dropStream = () => {
if (!stream) {
@@ -495,20 +435,17 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
continue;
}
if (ev.kind === "snapshot.created") {
placeSnapshot(p as unknown as Snapshot);
ctx.run = undefined;
put(snapRow(p as unknown as Snapshot, ctx.taskId));
continue;
}
if (ev.kind === "agent.tool_call" && isSubagentCall(p)) {
ctx.run = undefined;
const el = renderSubagent(p, ctx);
put(el);
addAnchor(ev.seq, el.querySelector("summary")!, false);
put(renderSubagent(p, ctx));
continue;
}
if (ev.kind === "agent.tool_call") {
const el = renderToolCall(p, ctx);
addToRun(el, { name: String(p["name"]), target: toolTarget(p["input"], ctx.root) }, parent);
addAnchor(ev.seq, el.querySelector("summary")!, false);
addToRun(renderToolCall(p, ctx), { name: String(p["name"]), target: toolTarget(p["input"], ctx.root) }, parent);
continue;
}
const el = renderEvent(ev, ctx);
@@ -521,7 +458,7 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
}
ctx.run = undefined;
if (ev.kind === "turn.ended" && p["handoff"] === true && ctx.handoff) {
addAnchor(ev.seq, closeHandoff(el)!, true);
closeHandoff(el);
continue;
}
if (ev.kind === "turn.ended" || (ev.kind === "user.message" && p["mid_turn"] !== true)) {
@@ -546,9 +483,6 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
ctx.turnStart = el;
ctx.keepTurnStart = p["continuation"] === true;
}
if (ev.kind === "turn.ended" || ev.kind === "inbox.created") {
addAnchor(ev.seq, el, ev.kind === "turn.ended");
}
}
if (contextChanged) {
renderHead();
@@ -913,20 +847,28 @@ function claudeTaskStatus(p: Record<string, unknown>, ctx: Ctx): void {
}
}
function snapMark(ns: number[]): HTMLElement | null {
if (ns.length === 0) {
return null;
}
const lo = Math.min(...ns);
const hi = Math.max(...ns);
const b = h("button", { type: "button", class: "snap-mark", title: `Show snapshot ${hi} in the Changes tab` }, lo === hi ? `#${hi}` : `#${lo}–#${hi}`);
b.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
pinSnapshot(hi);
function snapRow(snap: Snapshot, taskId: string | undefined): HTMLElement {
const changes = h("button", { type: "button", title: `Show snapshot ${snap.n} in the Changes tab` }, "changes");
changes.onclick = () => {
pinSnapshot(snap.n);
focusTab("changes");
};
return b;
const fork = h("button", { type: "button", title: "Start a new task from this snapshot, its agent continuing the conversation as it stood here" }, "fork");
fork.onclick = async () => {
if (!taskId) {
return;
}
fork.disabled = true;
try {
const task = await post("/api/tasks/{taskId}/snapshots/{n}/fork", { path: { taskId, n: snap.n } });
focusTask(task.id);
} catch (err) {
fork.disabled = false;
void showAlert(errorText(err));
}
};
const time = new Date(snap.created_at).toLocaleTimeString([], { hour12: false });
return h("div", { class: "snap-row", title: `${snap.trigger.replace("_", " ")} · ${time}` }, `snapshot #${snap.n}`, changes, fork);
}
function runSummary(calls: Call[]): HTMLElement {
@@ -1040,6 +982,29 @@ function renderEvent(ev: ByreEvent, ctx: Ctx): HTMLElement | null {
return null;
}
case "task.created": {
if (typeof p["forked_from"] !== "string") {
return null;
}
const src = findTask(p["forked_from"]);
const title = src?.title ?? p["forked_from"];
const link =
src?.status === "active"
? h(
"a",
{
href: `#/task/${src.id}`,
onclick: (e: MouseEvent) => {
e.preventDefault();
focusTask(src.id);
},
},
title,
)
: title;
return h("div", { class: "note" }, `${ev.actor} forked this task from `, link, ` at snapshot #${String(p["forked_at"])}; its agent continues that task's conversation as it stood there `, when);
}
case "task.merge_title":
return h("div", { class: "note" }, `${ev.actor} set the merge commit title to "${String(p["merge_title"])}" `, when);