Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@

### Added

- Added a clone-based direct code review workflow with a `git-clone`
prepare hook and bundled `code-review-clone` assignment.
- Added first-class run groups across manifests, CLI, daemon APIs, and the
web dashboard. Fresh child runs inherit the parent run group by default,
`--group-id` overrides fresh run/init grouping, and
Expand Down Expand Up @@ -329,6 +331,10 @@

### Fixed

- Hardened the `git-clone` hook against option-like clone inputs, rejected
credential-bearing HTTPS URLs before manifest persistence, made managed
checkouts reusable across initialized reconfigure runs, and removed managed
clone checkouts when runs are deleted.
- Daemon-connected fresh `init --message-file` and fresh
`run --message-file` now forward the file contents in
`overrides.message` instead of snapshotting overrides before the
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -504,12 +504,13 @@ Agents (under `agents/`):
Assignments (under `assignments/`):

- `repo-orientation`, `test`, `plan-feature`, `plan-review`,
`code-review`, `code-review-direct`, `doc-review`, `familiarize`
`code-review`, `code-review-direct`, `code-review-clone`,
`doc-review`, `familiarize`

Shared task definitions (under `tasks/`):

- reusable `review/architecture` through `review/docs-drift`
code-review dimensions used by both code-review assignments
code-review dimensions used by the bundled code-review assignments

Walkthrough in [docs/examples.md](docs/examples.md).

Expand Down
182 changes: 181 additions & 1 deletion apps/web/src/app.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { RunAttachment } from "@task-runner/core/contracts/attachments.js";
import type { RunAuditHistory, RunTimelineHistory } from "@task-runner/core/contracts/events.js";
import type {
RunAuditHistory,
RunTimelineAttempt,
RunTimelineHistory,
} from "@task-runner/core/contracts/events.js";
import type { RunInputSurface } from "@task-runner/core/contracts/run-input-surface.js";
import type { RunDetail, RunSummary } from "@task-runner/core/contracts/runs.js";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
Expand Down Expand Up @@ -2028,6 +2032,15 @@ describe("web app", () => {
}),
});

await waitFor(() => {
expect(screen.getByRole("tab", { name: "Response" })).toHaveAttribute(
"aria-selected",
"true",
);
});
expect(screen.queryByRole("tab", { name: "Live" })).not.toBeInTheDocument();
expect(screen.getByText("Waiting for live response text…")).toBeInTheDocument();

let timelineSource: MockEventSource | undefined;
await waitFor(() => {
timelineSource = MockEventSource.instances.find((candidate) =>
Expand Down Expand Up @@ -2155,6 +2168,173 @@ describe("web app", () => {
expect(screen.getByText("Waiting for live response text…")).toBeInTheDocument();
});

it("refreshes and selects a resumed attempt when detail reports the run live first", async () => {
const completedSession = {
sessionIndex: 0,
status: "success" as const,
startedAt: "2026-04-13T05:00:00.000Z",
endedAt: "2026-04-13T05:02:00.000Z",
exitCode: 0,
message: null,
firstAttemptNumber: 1,
lastAttemptNumber: 1,
attemptCount: 1,
maxAttemptsPerSession: 3,
backendSessionIdAtStart: "thread-1",
backendSessionIdAtEnd: "thread-1",
};
const resumedSession = {
sessionIndex: 1,
status: "running" as const,
startedAt: "2026-04-13T05:03:00.000Z",
endedAt: null,
exitCode: null,
message: "Continue with the fix.",
firstAttemptNumber: 2,
lastAttemptNumber: 2,
attemptCount: 1,
maxAttemptsPerSession: 3,
backendSessionIdAtStart: "thread-2",
backendSessionIdAtEnd: null,
};
const initialDetail = makeDetail({
status: "success",
effectiveStatus: "success",
isLive: false,
endedAt: "2026-04-13T05:02:00.000Z",
exitCode: 0,
totalAttemptCount: 1,
totalSessionCount: 1,
currentSession: null,
lastSession: completedSession,
sessions: [completedSession],
});
const resumedDetail = makeDetail({
status: "running",
effectiveStatus: "running",
isLive: true,
endedAt: null,
exitCode: null,
totalAttemptCount: 1,
totalSessionCount: 2,
currentSession: resumedSession,
lastSession: resumedSession,
sessions: [completedSession, resumedSession],
});
const completedAttempt: RunTimelineAttempt = {
attemptNumber: 1,
attemptIndexInSession: 0,
sessionIndex: 0,
startedAt: "2026-04-13T05:00:00.000Z",
endedAt: "2026-04-13T05:02:00.000Z",
prompt: "Initial prompt",
transcript: "Attempt one output\n",
notices: "",
exitCode: 0,
timedOut: false,
live: false,
};
const resumedAttempt: RunTimelineAttempt = {
attemptNumber: 2,
attemptIndexInSession: 0,
sessionIndex: 1,
startedAt: "2026-04-13T05:03:00.000Z",
endedAt: null,
prompt: "## Resume prompt",
transcript: "",
notices: "",
exitCode: null,
timedOut: false,
live: true,
};
const state = {
runs: [
makeRun({
status: "success",
effectiveStatus: "success",
endedAt: "2026-04-13T05:02:00.000Z",
totalAttemptCount: 1,
totalSessionCount: 1,
currentSession: null,
lastSession: completedSession,
}),
],
details: { "run-1": initialDetail },
timelineHistories: {
"run-1": {
runId: "run-1",
lastCursor: 1,
attempts: [completedAttempt],
},
},
};
const fetchMock = installFetchMock(state);

const user = userEvent.setup();
await renderApp();
await user.click(await findRunCard("Build dashboard"));
await user.click(screen.getByRole("button", { name: "Attempts" }));

await user.click(await screen.findByRole("tab", { name: "Prompt" }));
expect(screen.getByRole("region", { name: "Attempt prompt" })).toHaveTextContent(
"Initial prompt",
);
expect(fetchCallCount(fetchMock, (url) => url.endsWith("/api/runs/run-1/timeline"))).toBe(1);

state.details["run-1"] = resumedDetail;
state.timelineHistories["run-1"] = {
runId: "run-1",
lastCursor: 2,
attempts: [completedAttempt, resumedAttempt],
};
const detailSource = findEventSource("/api/runs/run-1/events/detail");
detailSource.emitMessage({ type: "detail_updated", detail: resumedDetail });

await waitFor(() => {
expect(screen.getByRole("tab", { name: "Response" })).toHaveAttribute(
"aria-selected",
"true",
);
});
expect(screen.queryByRole("tab", { name: "Live" })).not.toBeInTheDocument();
expect(screen.getByText("Waiting for live response text…")).toBeInTheDocument();

let timelineSource: MockEventSource | undefined;
await waitFor(() => {
timelineSource = MockEventSource.instances.find((candidate) =>
candidate.url.endsWith("/api/runs/run-1/events/timeline"),
);
expect(timelineSource).toBeDefined();
});
if (!timelineSource) {
throw new Error("expected timeline EventSource after resume start");
}
timelineSource.emitOpen();

await waitFor(() => {
expect(fetchCallCount(fetchMock, (url) => url.endsWith("/api/runs/run-1/timeline"))).toBe(2);
});
await waitFor(() => {
expect(screen.getByRole("tab", { name: "Session 2" })).toHaveAttribute(
"aria-selected",
"true",
);
});
expect(screen.getByRole("tab", { name: "Response" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("Waiting for live response text…")).toBeInTheDocument();

timelineSource.emitMessage({
runId: "run-1",
cursor: 3,
event: {
type: "agent_message_delta",
text: "streamed after resume",
},
});

expect(await screen.findByText("streamed after resume")).toBeInTheDocument();
});

it("separates transcript and backend notices instead of gluing them together", async () => {
installFetchMock({
runs: [makeRun()],
Expand Down
59 changes: 49 additions & 10 deletions apps/web/src/components/run-detail-drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ import { RunTaskList } from "./run-task-list.js";
import { StatusBadge } from "./status-badge.js";

type TimelineTab = "message" | "prompt" | "response" | "diagnostics";
type AttemptSelection = number | "pending" | null;
type AttemptSelection = number | "live" | "pending" | null;
type SummaryRow = readonly [label: string, value: string];
type DataTab = "vars" | "hookState";
type AuditFilter = "all" | "hooks" | "tasks" | "run";
Expand Down Expand Up @@ -683,18 +683,33 @@ export function RunDetailDrawer({
(run.status === "initialized" || run.status === "ready") &&
run.totalAttemptCount === 0 &&
timelineAttempts.length === 0;
const currentSessionHasTimelineAttempt =
run.currentSession !== null &&
timelineAttempts.some((attempt) => attempt.sessionIndex === run.currentSession?.sessionIndex);
const liveAttemptPendingAvailable =
run.status === "running" &&
run.currentSession !== null &&
run.currentSession.status === "running" &&
!currentSessionHasTimelineAttempt &&
!timelineAttempts.some((attempt) => attempt.live);
const selectedAttemptRecord =
(typeof selectedAttempt === "number"
typeof selectedAttempt === "number"
? timelineAttempts.find((attempt) => attempt.attemptNumber === selectedAttempt)
: null) ??
timelineAttempts[timelineAttempts.length - 1] ??
null;
: selectedAttempt === null ||
(selectedAttempt === "pending" && !pendingAttemptAvailable) ||
(selectedAttempt === "live" && !liveAttemptPendingAvailable)
? timelineAttempts[timelineAttempts.length - 1]
: null;
const selectedPendingAttempt = pendingAttemptAvailable && selectedAttemptRecord === null;
const selectedLiveAttempt =
liveAttemptPendingAvailable && selectedAttempt === "live" && selectedAttemptRecord === null;
const selectedAttemptNumber = selectedAttemptRecord?.attemptNumber ?? null;
const selectedAttemptResponse = selectedAttemptRecord?.transcript ?? "";
const selectedAttemptDiagnostics = selectedAttemptRecord?.notices ?? "";
const selectedAttemptLive = selectedAttemptRecord?.live ?? false;
const selectedSessionIndex = selectedAttemptRecord?.sessionIndex ?? null;
const selectedSessionIndex =
selectedAttemptRecord?.sessionIndex ??
(selectedLiveAttempt ? run.currentSession?.sessionIndex : null);
const selectedTimelineSession =
selectedSessionIndex === null
? null
Expand Down Expand Up @@ -1094,6 +1109,17 @@ export function RunDetailDrawer({
setSelectedAttempt(timelineAttempts[timelineAttempts.length - 1]?.attemptNumber ?? null);
return;
}
if (selectedAttempt === "live") {
if (liveAttemptPendingAvailable) {
return;
}
setSelectedAttempt(timelineAttempts[timelineAttempts.length - 1]?.attemptNumber ?? null);
return;
}
if (liveAttemptPendingAvailable) {
setSelectedAttempt("live");
return;
}
if (selectedAttempt !== null && availableAttempts.has(selectedAttempt)) {
return;
}
Expand All @@ -1102,14 +1128,20 @@ export function RunDetailDrawer({
return;
}
setSelectedAttempt(timelineAttempts[timelineAttempts.length - 1]?.attemptNumber ?? null);
}, [pendingAttemptAvailable, selectedAttempt, timelineAttempts]);
}, [liveAttemptPendingAvailable, pendingAttemptAvailable, selectedAttempt, timelineAttempts]);

useEffect(() => {
const latestAttempt = timelineAttempts[timelineAttempts.length - 1]?.attemptNumber ?? null;
if (activeSection !== "events") {
latestAttemptRef.current = latestAttempt;
return;
}
if (liveAttemptPendingAvailable) {
latestAttemptRef.current = latestAttempt;
setSelectedAttempt("live");
setTimelineTab("response");
return;
}
if (latestAttempt === null) {
latestAttemptRef.current = null;
return;
Expand All @@ -1121,7 +1153,7 @@ export function RunDetailDrawer({
return;
}
latestAttemptRef.current = latestAttempt;
}, [activeSection, timelineAttempts]);
}, [activeSection, liveAttemptPendingAvailable, timelineAttempts]);

useEffect(() => {
if (
Expand Down Expand Up @@ -2670,10 +2702,11 @@ export function RunDetailDrawer({
<p className="muted-inline">No attempt history is available for this run yet.</p>
) : null}

{selectedAttemptRecord || selectedPendingAttempt ? (
{selectedAttemptRecord || selectedPendingAttempt || selectedLiveAttempt ? (
<div className="timeline-attempt-panel">
<div className="timeline-sticky-controls">
{selectedPendingAttempt || timelineAttempts.length > 1 ? (
{selectedPendingAttempt ||
(!selectedLiveAttempt && timelineAttempts.length > 1) ? (
<div className="timeline-attempts">
{selectedPendingAttempt ? (
<div className="timeline-attempt-row">
Expand Down Expand Up @@ -2888,6 +2921,8 @@ export function RunDetailDrawer({
No prompt preview is available for this run yet.
</p>
)
) : selectedLiveAttempt ? (
<p className="task-empty">Waiting for attempt prompt…</p>
) : selectedAttemptRecord?.prompt ? (
<section aria-label="Attempt prompt">
<MarkdownContent
Expand All @@ -2901,6 +2936,8 @@ export function RunDetailDrawer({
) : timelineTab === "response" ? (
selectedPendingAttempt ? (
<p className="task-empty">No response yet — this run has not started.</p>
) : selectedLiveAttempt ? (
<p className="task-empty">Waiting for live response text…</p>
) : selectedAttemptResponse ? (
<section aria-label="Attempt response">
<MarkdownContent
Expand All @@ -2917,6 +2954,8 @@ export function RunDetailDrawer({
)
) : selectedPendingAttempt ? (
<p className="task-empty">No diagnostics yet — this run has not started.</p>
) : selectedLiveAttempt ? (
<p className="task-empty">No diagnostics have arrived yet.</p>
) : selectedAttemptDiagnostics ? (
<section aria-label="Attempt diagnostics">
<MarkdownContent
Expand Down
Loading