Skip to content
Merged
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
1 change: 1 addition & 0 deletions FEATURES-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ happens while nobody is at the keyboard.
- An agent is a URL you can paste, reload and bookmark
- The agent names itself; the branch is renamed to match
- Ready-for-merge flips the agent's badge
- The agent reports what it could not get past: a red line in the log where it hit it, and a running error count on the session
- Live spend readout per agent
- See the exact system prompt the agent ran under

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
import type { FrameworkEvent } from '../../src/index.js'
import { GitStatusBar } from './GitStatusBar.js'
import { AgentActionsMenu } from './AgentActionsMenu.js'
import { AgentErrorCount } from './AgentErrorCount.js'
import { agentStatusPill } from '../lib/agent-status.js'
import { cn } from '../lib/utils.js'

Expand Down Expand Up @@ -84,6 +85,10 @@ export function AgentActionBar({
grows but never shrinks (#1030), so a tight row takes its width from the label. */}
<div className="grow shrink-0" />
<div className="flex shrink-0 items-center gap-2">
{/* What the agent could not get past (#1500): the log scrolls, this row does not. It sits
with the controls rather than among the branch facts, which give up width as the row
fills — a count is only useful if it is whole. */}
<AgentErrorCount events={events} />
{/* The handoff's next step stays visible — the one thing here that moves the session forward
rather than just opening it somewhere. Everything else is in the ⋮ menu. */}
{actions}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
How many errors the agent reported, shown in the session's header — with the latest headline beside it, and every headline behind a hover.

## Flows

- Rendered where the header stays put while the log scrolls: the run's action bar and the project page's agent overview. A session that reported none shows nothing.
- The count only ever grows within a session, because each error is something that happened rather than a state that can be fixed; the errors themselves stay in the log, at the point in the run where the agent hit them.

## Before modifying/creating SPEC.md files

You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Covers the header's error count: nothing for a session that reported none, the count and the latest headline once it has, and singular/plural wording.

## Before modifying/creating SPEC.md files

You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { FrameworkEvent } from '../../src/index.js'
import { afterEach, describe, expect, test } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { AgentErrorCount } from './AgentErrorCount.js'

afterEach(cleanup)

// The count is the session header's half of the error capability (#1500): the log carries the
// errors themselves, this says how many there were without the reader scrolling for them.
describe('AgentErrorCount', () => {
test('a session that reported no errors shows nothing', () => {
const { container } = render(<AgentErrorCount events={[{ kind: 'ready-for-merge' }]} />)
expect(container.textContent).toBe('')
})

test('one error reads singular, with its headline where the row has room', () => {
render(<AgentErrorCount events={[{ kind: 'error', headline: 'gh is not logged in' }]} headline />)
expect(screen.getByText('1 error')).toBeTruthy()
expect(screen.getByText(/gh is not logged in/)).toBeTruthy()
})

test('a tight row shows the count alone — a clipped headline is worse than none', () => {
render(<AgentErrorCount events={[{ kind: 'error', headline: 'gh is not logged in' }]} />)
expect(screen.getByText('1 error')).toBeTruthy()
expect(screen.queryByText(/gh is not logged in/)).toBeNull()
})

test('several errors read plural, and the LATEST headline is the one shown', () => {
const events: FrameworkEvent[] = [
{ kind: 'error', headline: 'first thing broke' },
{ kind: 'error', headline: 'second thing broke' },
]
render(<AgentErrorCount events={events} headline />)
expect(screen.getByText('2 errors')).toBeTruthy()
expect(screen.getByText(/second thing broke/)).toBeTruthy()
expect(screen.queryByText(/first thing broke/)).toBeNull()
})
})
34 changes: 34 additions & 0 deletions packages/the-framework/dashboard/components/AgentErrorCount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { TriangleAlert } from 'lucide-react'
import type { FrameworkEvent } from '../../src/index.js'
import { agentErrors } from '../../src/client.js'
import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js'

// How many errors the agent reported, said in the session's header (#1500) — on the run page's
// action bar and on the project page's overview, the two places that stay put while the log
// scrolls. The errors themselves stay in the log, at the point in the run where they happened;
// this is only the count, plus the last headline so it says *what* without being opened.
export function AgentErrorCount({ events, headline = false }: { events: FrameworkEvent[]; headline?: boolean }) {
const errors = agentErrors(events)
if (errors.length === 0) return null
const latest = errors[errors.length - 1]!
return (
<Tooltip>
<TooltipTrigger
render={
// Never shrinks: a count clipped to "1 erro" is worse than no count at all, and this row
// fills up with the branch and its summary long before it runs out of width.
<span role="alert" className="flex shrink-0 items-center gap-1.5 text-danger">
<TriangleAlert className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="font-medium">
{errors.length} {errors.length === 1 ? 'error' : 'errors'}
</span>
{/* Only where the row has room for it: in a tight bar the headline is the hover. */}
{headline && <span className="min-w-0 truncate text-muted-foreground">· {latest.headline}</span>}
</span>
}
/>
{/* Every headline, so a run with several says which — the detail stays in the log. */}
<TooltipContent className="whitespace-pre-line">{errors.map(e => e.headline).join('\n')}</TooltipContent>
</Tooltip>
)
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
The agent-overview cards projected from the event stream — the status line and an honestly-labelled link to the live session — each rendered only once its data has arrived.
The agent-overview cards projected from the event stream — the status line, what the agent reported as broken, and an honestly-labelled link to the live session — each rendered only once its data has arrived.

## Flows

- The errors the agent reported are counted here, next to the latest of them, so a failure stays in sight after the log has scrolled past it. The errors themselves stay in the log, in their place in the story.

## Before modifying/creating SPEC.md files

Expand Down
13 changes: 11 additions & 2 deletions packages/the-framework/dashboard/components/AgentOverview.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { FrameworkEvent } from '../../src/index.js'
import { sessionInfo, agentProgress } from '../../src/client.js'
import { sessionInfo, agentProgress, agentErrors } from '../../src/client.js'
import { AgentErrorCount } from './AgentErrorCount.js'
import { agentStatusPill } from '../lib/agent-status.js'
import { describeSessionLink } from '../lib/session-link.js'
import { cn } from '../lib/utils.js'
Expand All @@ -13,13 +14,14 @@ export function AgentOverview({ events }: { events: FrameworkEvent[] }) {
const session = sessionInfo(events)
const progress = agentProgress(events)
const status = agentStatusPill(events)
const errors = agentErrors(events)

// The "Open session" link, labeled honestly: a headless Claude Code run has no per-session
// URL, so the generic app entry (claude.ai/code) is shown as "Open Claude Code" with the id
// surfaced separately, not as a deep link to that id. See {@link describeSessionLink}.
const sessionLink = describeSessionLink(session)

if (!sessionLink && !status) return null
if (!sessionLink && !status && errors.length === 0) return null

return (
<div className="grid gap-3 border-b border-border p-4 md:grid-cols-2">
Expand All @@ -30,6 +32,13 @@ export function AgentOverview({ events }: { events: FrameworkEvent[] }) {
<span className={cn('text-xs', status.tone)}>{status.label}</span>
</div>
)}
{/* What went wrong, kept where the log cannot scroll it away (#1500). The rows themselves
stay in the log, at the point in the run where the agent hit them. */}
{errors.length > 0 && (
<div className="text-sm md:col-span-2">
<AgentErrorCount events={events} headline />
</div>
)}
{sessionLink && (
<a
href={sessionLink.href}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ The agent's transcript, shared by the live stream and the replay of a finished o

- The user's prompts read YOU (blue) and the agent's replies AGENT, both as Markdown. A long message collapses to its first line and expands in place, and the system prompt hides behind a character count.
- The agent's first prompt is hoisted to the very top, so the log opens with what the user asked rather than the machinery that preceded it; later turns stay where they happened.
- An error the agent reported is a red line in the transcript like any other failure, at the point in the run where it hit it — nothing dismisses it or clears it, because it is a record of what happened rather than a state of the agent now.
- Colour carries meaning: failures red, the user's own turn blue, decision badges amber, milestones green, the surfaces the agent pushes (views, previews, browser rows) primary — and a stopped agent is not an error, since stopping was asked for.
- When the transcript knows its agent, a decision row IS the interaction: an open question is answerable in place, an answered one collapses to a ✓ card, and one whose agent ended stays plain text — its audience is gone.
- The latest browser row hosts the live inline preview — there is one screencast, and a page announced again replaces its earlier row rather than stacking. The live log follows the newest row but yields the moment the reader scrolls up.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Covers the transcript's behaviour: YOU/AGENT conversation rows with Markdown and collapse, failure/stopped colour semantics, badge tinting, first-prompt hoisting, the pinned tail, and the inline decision and browser rows — interactive or live only while the agent is, plain text once it is not.
Covers the transcript's behaviour: YOU/AGENT conversation rows with Markdown and collapse, failure/stopped colour semantics including the errors an agent reports itself, badge tinting, first-prompt hoisting, the pinned tail, and the inline decision and browser rows — interactive or live only while the agent is, plain text once it is not.

## Before modifying/creating SPEC.md files

Expand Down
10 changes: 10 additions & 0 deletions packages/the-framework/dashboard/components/EventList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ describe('EventList row colour', () => {
expect(row.className).toContain('text-danger')
})

test('an error the agent reported itself renders in red (#1500)', () => {
render(<EventList events={[{ kind: 'error', headline: 'gh is not logged in' }]} stick={false} />)
expect(screen.getByText(/gh is not logged in/).className).toContain('text-danger')
})

test('a failed run renders in red (#1199)', () => {
render(<EventList events={[{ kind: 'end', ok: false, detail: 'exited 1' }]} stick={false} />)
expect(screen.getByText(/failed: exited 1/).className).toContain('text-danger')
Expand Down Expand Up @@ -281,6 +286,11 @@ describe('EventList row wash (#1508)', () => {
expect(container.querySelector('[class*="bg-info/10"]')).toBeTruthy()
})

test('an agent-reported error gets the red wash too (#1500)', () => {
const { container } = render(<EventList events={[{ kind: 'error', headline: 'push rejected' }]} stick={false} />)
expect(container.querySelector('[class*="bg-danger/10"]')).toBeTruthy()
})

test('a failure gets the red wash', () => {
const { container } = render(<EventList events={[{ kind: 'end', ok: false, detail: 'exited 1' }]} stick={false} />)
expect(container.querySelector('[class*="bg-danger/10"]')).toBeTruthy()
Expand Down
3 changes: 3 additions & 0 deletions packages/the-framework/dashboard/components/EventList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ function isTurnBoundary(e: FrameworkEvent): boolean {
*/
function isFailure(e: FrameworkEvent): boolean {
if (e.kind === 'driver') return e.event.type === 'error'
// An error the agent reported itself (#1500) is a failure like any other: the log already has
// one red lane, and a second vocabulary for the same thing would only make both quieter.
if (e.kind === 'error') return true
return e.kind === 'end' && !e.ok && !e.stopped
}

Expand Down
2 changes: 1 addition & 1 deletion packages/the-framework/prompts/presets/update_tickets.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Note the current UTC time before you fetch anything, in ISO 8601. That is the ti
Read `tickets/meta.json` for `lastImportedAt`.

Do one of the following:
- [Error] If there are existing `tickets/*.md` but `lastImportedAt` is missing, or `gh` is missing or logged out, show an error to the user and abort
- [Error] If there are existing `tickets/*.md` but `lastImportedAt` is missing, or `gh` is missing or logged out, report the error — say which of those it is — and abort
- [Empty] If `tickets/` is empty (or doesn't exist), treat it as a first import and bring every open issue across
- [Update] Fetch only what changed: `gh issue list --state all --limit 500 --json number,title,body,state,labels,updatedAt --search "updated:>=<lastImportedAt>"`, and the discussion with `gh api --paginate "repos/{owner}/{repo}/issues/comments?since=<lastImportedAt>"`

Expand Down
9 changes: 9 additions & 0 deletions packages/the-framework/prompts/protocols/signal.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,12 @@ Whenever you emit `ready-for-merge`, emit an `open-pr` block too, naming and des
<what changed, and why — markdown, as long as it needs to be>
```
Without it the pull request has no name for your work and can only repeat the prompt you were given, which does not say what the work turned out to be. The Framework supplies the rest: the ticket's issue reference where there is one, and recording the number so every surface shows the same pull request. You do not stop, and you can re-emit it as the work changes — the last one is used. Opening the pull request yourself instead still works; you then own all of the above.

## Reporting an error
When you hit something only the user can fix — a missing file you were told to read, a command that will not run, a login you do not have — emit an `error` block saying what is wrong, then carry on or stop as the task requires. The first line is the headline; anything below it is the detail.
```error
<what is wrong, in one line>

<the detail: what you ran, what it said>
```
The Framework marks it in the session log and counts it on the session, so the user sees it without reading the whole log. It does not stop your turn and it does not ask the user anything — use `AWAIT` for a question. Report the same thing once: a re-emitted identical block is ignored.
5 changes: 3 additions & 2 deletions packages/the-framework/src/agent-view.SPEC.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
Distills an agent's event stream into the dashboard's summary cards: the agent's lifecycle progress, what it will do with its work when it ends, and the agent session behind it.
Distills an agent's event stream into the dashboard's summary cards: the agent's lifecycle progress, what went wrong along the way, what it will do with its work when it ends, and the agent session behind it.

## User Stories

- The user reads an agent's card at a glance: the name it chose, and a badge that flips when the agent signals ready for merge.
- The user sees what an agent will do with its work when it ends — push, open a pull request, merge — exactly as armed.
- The user sees how many errors an agent hit and the latest of them, without reading its log.
- The user opens a past agent's record and sees the identical summary a live viewer saw.

## Flows

- The cards are pure folds over the same events the log renders, so a live dashboard and a replay of a past agent always show the identical summary.
- Latest wins throughout: the agent may rename its session or re-arm its handoff at any point.
- Latest wins throughout: the agent may rename its session or re-arm its handoff at any point. Errors are the exception — they only accumulate, because an error is something that happened and nothing can un-happen it.
- The publish state reads as armed (push and pull request) even for a stream that never says so, because that is what such an agent will actually do; merging is the opposite — opt-in, so silence reads as off.
- A stored snapshot can seed the publish state for a viewer who attached after the agent's opening events — so a session the launcher armed push-only never shows as one that will open a pull request; an event in the stream always wins over the snapshot.
- Resuming a finished agent needs to know where the conversation lived, and its working copy is deleted — so the workspace comes from the events, the surviving record, never from the disk.
Expand Down
2 changes: 1 addition & 1 deletion packages/the-framework/src/agent-view.test.SPEC.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Tests the event-stream projections behind the dashboard cards: latest-wins folding of review status, names, deploy plans, session and model; the armed-by-default publish state with opt-in merge; snapshot seeding losing to real events; and handoff outcomes surviving into the summary.
Tests the event-stream projections behind the dashboard cards: latest-wins folding of review status, names, deploy plans, session and model; the armed-by-default publish state with opt-in merge; snapshot seeding losing to real events; the accumulating list of errors the agent reported; and handoff outcomes surviving into the summary.

## Before modifying/creating SPEC.md files

Expand Down
15 changes: 14 additions & 1 deletion packages/the-framework/src/agent-view.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { sessionInfo, agentProgress, handoffState } from './agent-view.js'
import { sessionInfo, agentProgress, agentErrors, handoffState } from './agent-view.js'
import type { FrameworkEvent } from './events.js'

test('agentProgress starts building with no name and flips to ready on setReadyForMerge (#326)', () => {
Expand Down Expand Up @@ -102,3 +102,16 @@ test('handoffState carries the outcome once the handoff has run (#1102)', () =>
const skipped: FrameworkEvent[] = [{ kind: 'handoff', outcome: 'skipped', reason: 'no-remote' }]
assert.deepEqual(handoffState(skipped).result, { outcome: 'skipped', reason: 'no-remote' })
})

test('agentErrors folds the errors the agent reported, oldest first (#1500)', () => {
assert.deepEqual(agentErrors([]), [])
const events: FrameworkEvent[] = [
{ kind: 'error', headline: 'gh is not logged in', detail: 'ran `gh auth status`' },
{ kind: 'session-name', name: 'update-tickets' },
{ kind: 'error', headline: 'tickets/meta.json has no lastImportedAt' },
]
assert.deepEqual(agentErrors(events), [
{ headline: 'gh is not logged in', detail: 'ran `gh auth status`' },
{ headline: 'tickets/meta.json has no lastImportedAt' },
])
})
24 changes: 24 additions & 0 deletions packages/the-framework/src/agent-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,30 @@ export function agentProgress(events: readonly FrameworkEvent[]): AgentProgress
return progress
}

/** One error the agent reported through an `error` block (#1500). */
export interface AgentError {
/** What is wrong, in one line. */
headline: string
/** What it ran and what that said, when the agent wrote any. */
detail?: string
}

/**
* Every error the agent reported (#1500), oldest first — the count the dashboard shows on the
* session, and the latest headline it shows beside it.
*
* A fold over the log rather than state of its own: an error is an event that happened, so the
* list only ever grows, and reopening a finished agent shows exactly what it showed while it ran.
*/
export function agentErrors(events: readonly FrameworkEvent[]): AgentError[] {
const errors: AgentError[] = []
for (const event of events) {
if (event.kind !== 'error') continue
errors.push({ headline: event.headline, ...(event.detail ? { detail: event.detail } : {}) })
}
return errors
}

/** What a session will do with its work when it ends (#1102), and what it did. */
export interface HandoffState {
/** Push the branch to `origin` on finish. */
Expand Down
Loading
Loading