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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ opt-out.
- Studio threads: each turn now shows its full input and output (chat messages render as bubbles) instead of the 200-char previews; a turn fetches its trace as it scrolls near the viewport, shows the preview labelled "Preview (truncated)" while loading or if the trace can't be read, and says so when the recorder truncated the turn's I/O.
- An open run's root start is kept when the reporter's buffer trims or a crash flush cuts to its newest 16 MB, so a run whose child spans overflow the buffer is still listed.
- Studio Scores and `GET /api/v1/scores?aggregate=eval`: `human` and `online` scores now pass only on a value of exactly 1, so a thumbs-down or an online rule returning 0 (or 0.9) without a comment counts as a failure instead of a pass; `eval` and `judge` scores keep their rule (no failure comment, or a perfect 1), and the table and the eval detail agree.
- Studio: the command palette and `?` shortcuts sheet now keep every keydown to themselves, so `S` no longer skips the annotation-queue item behind them; Traces, Threads, a Thread, Prompts and the Context Hub recover in place after the daemon restarts instead of staying on "daemon down"; and the queue review's trace tree no longer wraps a row name one letter per line at 1280px.
- The run inspector shows `error`, not `running`, for a failed step that has no end time.
- Studio trace viewer: an observation the crash sweep marked `interrupted` (or an open `error` one) now shows as terminal instead of "running" forever; its duration and the waterfall stop at the latest known end and the header clock stops ticking.
- The observe daemon refuses cross-site browser writes and DNS rebinding on `/api/*`: a loopback-bound daemon answers only loopback `Host` names (403), writes carrying a non-loopback `Origin` (including `null`) or `Sec-Fetch-Site: cross-site` get 403, and a write with a body must be `Content-Type: application/json` (415) — `curl -d` needs `-H "Content-Type: application/json"`. The SDK reporter, CLI and Studio already comply. See [Observability](docs/observability.md).
Expand Down
13 changes: 13 additions & 0 deletions apps/agent-forge/e2e/revamp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,3 +316,16 @@ for (const width of [860, 375]) {
});
}
}

test('queue review at 1280px: a trace tree row name never wraps one letter per line', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto(`${APP}#/evals/queues/e2e-queue`);
await expect(page.locator('[data-review-trace="e2e-trace"]')).toBeVisible();
const names = page.locator('[data-review-trace="e2e-trace"] .run-name');
await expect(names.first()).toBeVisible();
for (const name of await names.all()) {
const box = (await name.boundingBox())!;
const line = await name.evaluate((el) => parseFloat(getComputedStyle(el).lineHeight) || el.getBoundingClientRect().height);
expect(box.height, `${await name.textContent()} wraps`).toBeLessThanOrEqual(line * 2 + 1);
}
});
79 changes: 79 additions & 0 deletions apps/agent-forge/src/screens/__tests__/daemonRecovery.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { act } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { HubScreen } from '../hub/HubScreen';
import { PromptsScreen } from '../prompts/PromptsScreen';
import { ThreadsScreen } from '../threads/ThreadsScreen';
import { TracesScreen } from '../traces/TracesScreen';
import { render } from '../../ui/__tests__/render';
import { ToastProvider } from '../../ui/Toast';

/** The health probe always answers; every other request fails like a refused connection while `outage` holds. */
let outage: boolean;

function stubDaemon() {
outage = true;
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input), 'http://x');
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
if (url.pathname.endsWith('/health')) return json({ service: 'lousho-observe', ok: true });
if (outage) throw new TypeError('fetch failed');
if (url.pathname.endsWith('/api/v1/agents')) return json({ agents: [{ agent_id: 'a1', name: 'alpha', project: null }] });
if (url.pathname.endsWith('/api/v1/traces')) return json({ traces: [], total: 0, limit: 50, offset: 0 });
if (url.pathname.endsWith('/api/v1/sessions')) return json({ sessions: [], total: 0, limit: 50, offset: 0 });
if (url.pathname.includes('/api/v1/sessions/')) return json({ error: 'No session', code: 'LOUSHO_REQUEST_INVALID' }, 404);
if (url.pathname.endsWith('/api/v1/prompts')) return json({ prompts: [] });
if (url.pathname.includes('/api/v1/prompts/')) return json({ error: 'No prompt', code: 'LOUSHO_REQUEST_INVALID' }, 404);
if (url.pathname.endsWith('/api/v1/hub')) return json({ entries: [] });
return json({ error: 'unexpected' }, 500);
})
);
}

async function flush() {
for (let i = 0; i < 4; i++) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
}

beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
stubDaemon();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
window.history.replaceState(null, '', '/');
});

const CASES: [string, string, () => JSX.Element, string][] = [
['Traces', '#/traces', () => <TracesScreen />, 'No traces match these filters'],
['Threads', '#/threads', () => <ThreadsScreen />, 'No threads match these filters'],
['Thread', '#/threads/s1', () => <ThreadsScreen />, 'Thread not found'],
['Prompts', '#/prompts', () => <PromptsScreen />, 'No prompts yet'],
['Prompt', '#/prompts/mira', () => <PromptsScreen />, 'Prompt not found'],
['Hub', '#/hub', () => <HubScreen />, 'Context Hub'],
];

describe('observe screens recover in place after a daemon restart', () => {
it.each(CASES)('%s: a failed query shows daemon-down, then the screen returns without a reload', async (_name, hash, screen, back) => {
window.history.replaceState(null, '', `/${hash}`);
const container = render(<ToastProvider>{screen()}</ToastProvider>);
await flush();
expect(container.textContent).toContain('lousho observe start');
expect(window.location.hash).toBe(hash);

outage = false;
await act(async () => {
vi.advanceTimersByTime(5100);
});
await flush();
expect(container.textContent).not.toContain('lousho observe start');
expect(container.textContent).toContain(back);
expect(window.location.hash).toBe(hash);
});
});
7 changes: 4 additions & 3 deletions apps/agent-forge/src/screens/hub/HubScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import './hub.css';
import { useMemo, useState } from 'react';
import { matchRoute, navigate } from '../../router/router';
import { useRoute } from '../../router/useRoute';
import { useAgents, useDaemonHealth, useHubEntries } from '../../observe/useObserve';
import { useAgents, useDaemonDown, useDaemonHealth, useHubEntries } from '../../observe/useObserve';
import { ObserveDaemonDown, ObserveError } from '../../observe/chrome';
import { ObserveDaemonDownError, type HubEntryFilter } from '../../observe/observeClient';
import type { HubEntryFilter } from '../../observe/observeClient';
import type { ObserveAgentRow, ObserveHubEntry } from '../../observe/types';
import { fmtDateTime, fmtInt, fmtStarted } from '../../observe/format';
import { DataTable, type Column } from '../../ui/DataTable';
Expand Down Expand Up @@ -180,8 +180,9 @@ function HubContent({ namespace }: { namespace: string | undefined }) {
const filter: HubEntryFilter | undefined =
viewer === undefined ? undefined : { agent_id: viewer, namespace, scope: state.scope, limit: HUB_LIMIT };
const entries = useHubEntries(filter, refreshKey);
const down = useDaemonDown(false, [agents, entries]);

if (agents.error instanceof ObserveDaemonDownError || entries.error instanceof ObserveDaemonDownError) return <ObserveDaemonDown />;
if (down) return <ObserveDaemonDown />;
if (viewer === undefined) {
if (agents.loading || agents.error) return <ObserveError error={agents.error} />;
return (
Expand Down
6 changes: 4 additions & 2 deletions apps/agent-forge/src/screens/prompts/PromptDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
import { useState } from 'react';
import { navigate } from '../../router/router';
import { useRoute } from '../../router/useRoute';
import { usePromptVersions } from '../../observe/useObserve';
import { ObserveError } from '../../observe/chrome';
import { useDaemonDown, usePromptVersions } from '../../observe/useObserve';
import { ObserveDaemonDown, ObserveError } from '../../observe/chrome';
import { observeClient } from '../../observe/observeClient';
import type { ObservePromptVersion } from '../../observe/types';
import { fmtDateTime, fmtInt } from '../../observe/format';
Expand Down Expand Up @@ -171,9 +171,11 @@ export function PromptDetail({ param }: { param: string }) {
const route = useRoute();
const [refreshKey, setRefreshKey] = useState(0);
const history = usePromptVersions(name, refreshKey);
const down = useDaemonDown(false, [history]);
const versions = history.data?.versions ?? [];
const selected = resolveRef(versions, ref);
const diff = parseDiff(route.query);
if (down) return <ObserveDaemonDown />;

let body;
if (history.loading && !history.data) body = <p className="prompt-note">Loading prompt...</p>;
Expand Down
5 changes: 2 additions & 3 deletions apps/agent-forge/src/screens/prompts/PromptsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@
import './prompts.css';
import { matchRoute, navigate } from '../../router/router';
import { useRoute } from '../../router/useRoute';
import { useDaemonHealth, usePrompts } from '../../observe/useObserve';
import { useDaemonDown, useDaemonHealth, usePrompts } from '../../observe/useObserve';
import { ObserveDaemonDown, ObserveError } from '../../observe/chrome';
import { ObserveDaemonDownError } from '../../observe/observeClient';
import type { ObservePromptRow } from '../../observe/types';
import { fmtDateTime, fmtInt } from '../../observe/format';
import { DataTable, type Column } from '../../ui/DataTable';
Expand All @@ -31,7 +30,7 @@ const COLUMNS: Column<ObservePromptRow>[] = [

function PromptList() {
const prompts = usePrompts();
if (prompts.error instanceof ObserveDaemonDownError) return <ObserveDaemonDown />;
if (useDaemonDown(false, [prompts])) return <ObserveDaemonDown />;
return (
<section className="prompts-screen" aria-label="Prompts">
<h2 className="visually-hidden">Prompts</h2>
Expand Down
7 changes: 5 additions & 2 deletions apps/agent-forge/src/screens/threads/ThreadScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
* full root input/output (the session rows only carry 200-char previews).
*/
import { useRef, useState } from 'react';
import { useSession } from '../../observe/useObserve';
import { ObserveError } from '../../observe/chrome';
import { useDaemonDown, useSession } from '../../observe/useObserve';
import { ObserveDaemonDown, ObserveError } from '../../observe/chrome';
import type { ObserveTraceRow } from '../../observe/types';
import { fmtCost, fmtDuration, fmtInt, fmtStarted } from '../../observe/format';
import { EmptyState } from '../../ui/EmptyState';
Expand Down Expand Up @@ -67,6 +67,9 @@ function Turn({ trace, index, now }: { trace: ObserveTraceRow; index: number; no
export function ThreadScreen({ sessionId }: { sessionId: string }) {
const thread = useSession(sessionId);
const [now] = useState(() => Date.now());
// Only the session query counts: one turn's detail failing is that turn's own problem, not the daemon's.
const down = useDaemonDown(false, [thread]);
if (down) return <ObserveDaemonDown />;

const session = thread.data?.session;
const traces = thread.data?.traces ?? [];
Expand Down
6 changes: 3 additions & 3 deletions apps/agent-forge/src/screens/threads/ThreadsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,8 @@ import './threads.css';
import { useEffect, useMemo } from 'react';
import { matchRoute, navigate } from '../../router/router';
import { useRoute } from '../../router/useRoute';
import { useAgents, useDaemonHealth, useSessions } from '../../observe/useObserve';
import { useAgents, useDaemonDown, useDaemonHealth, useSessions } from '../../observe/useObserve';
import { ObserveDaemonDown, ObserveError } from '../../observe/chrome';
import { ObserveDaemonDownError } from '../../observe/observeClient';
import type { ObserveSessionRow } from '../../observe/types';
import { fmtCost, fmtDuration, fmtInt, fmtStarted } from '../../observe/format';
import { DataTable, type Column } from '../../ui/DataTable';
Expand Down Expand Up @@ -89,6 +88,7 @@ function ThreadsList() {
const filter = useMemo(() => toSessionFilter(state, now), [state, now]);
const sessions = useSessions(filter);
const agents = useAgents();
const down = useDaemonDown(false, [sessions, agents]);

useEffect(() => rememberThreadsQuery(formatThreadsQuery(state)), [state]);

Expand All @@ -100,7 +100,7 @@ function ThreadsList() {
agentFacet(agents.data ?? [], state.agent, (agent) => update({ agent })),
facet('status', 'Status', STATUSES, state.status, (status) => update({ status })),
];
if (sessions.error instanceof ObserveDaemonDownError) return <ObserveDaemonDown />;
if (down) return <ObserveDaemonDown />;

return (
<section className="threads-screen" aria-label="Threads">
Expand Down
38 changes: 25 additions & 13 deletions apps/agent-forge/src/screens/traces/TracesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import './traces.css';
import { useEffect, useMemo } from 'react';
import { navigate } from '../../router/router';
import { useRoute } from '../../router/useRoute';
import { useAgents, useDaemonHealth, useTraces } from '../../observe/useObserve';
import { useAgents, useDaemonDown, useDaemonHealth, useTraces } from '../../observe/useObserve';
import { ObserveDaemonDown, ObserveError } from '../../observe/chrome';
import { ObserveDaemonDownError, type TraceSort } from '../../observe/observeClient';
import type { ObserveTraceRow } from '../../observe/types';
import type { TraceSort } from '../../observe/observeClient';
import type { ObserveAgentRow, ObserveTraceRow } from '../../observe/types';
import { fmtCost, fmtDuration, fmtInt, fmtStarted } from '../../observe/format';
import { DataTable, type Column, type TableSort } from '../../ui/DataTable';
import { FilterBar } from '../../ui/FilterBar';
Expand Down Expand Up @@ -88,6 +88,24 @@ function PromptChip({ prompt, onRemove }: { prompt: string | undefined; onRemove
);
}

const toSort = (next: TableSort | undefined): TracesUrlState['sort'] =>
next && SORTABLE.includes(next.key) ? { key: next.key as TraceSort, order: next.order } : undefined;

/** The agent / status / model / project facets; model options come from the rows on screen. */
function traceFacets(
state: TracesUrlState,
rows: ObserveTraceRow[],
agentRows: ObserveAgentRow[],
setText: (key: 'agent' | 'status' | 'model' | 'project') => (v: string | undefined) => void
) {
return [
agentFacet(agentRows, state.agent, setText('agent')),
facet('status', 'Status', STATUSES, state.status, setText('status')),
facet('model', 'Model', uniqueSorted(rows.map((t) => t.model)), state.model, setText('model')),
facet('project', 'Project', uniqueSorted(agentRows.map((a) => a.project)), state.project, setText('project')),
];
}

function TracesList() {
const route = useRoute();
const queryString = route.query.toString();
Expand All @@ -97,6 +115,7 @@ function TracesList() {
const filter = useMemo(() => toTraceFilter(state, now), [state, now]);
const traces = useTraces(filter);
const agents = useAgents();
const down = useDaemonDown(false, [traces, agents]);

useEffect(() => rememberListQuery(formatTracesQuery(state)), [state]);

Expand All @@ -106,18 +125,11 @@ function TracesList() {
const setText = (key: 'agent' | 'status' | 'model' | 'project' | 'q' | 'prompt') => (v: string | undefined) => update({ [key]: v || undefined });

const range = useRangeFilter(state.window, '24h', (window) => update({ window }));
const onSort = (next: TableSort | undefined) =>
update({ sort: next && SORTABLE.includes(next.key) ? { key: next.key as TraceSort, order: next.order } : undefined });
const onSort = (next: TableSort | undefined) => update({ sort: toSort(next) });

const rows = traces.data?.traces ?? [];
const agentRows = agents.data ?? [];
const facets = [
agentFacet(agentRows, state.agent, setText('agent')),
facet('status', 'Status', STATUSES, state.status, setText('status')),
facet('model', 'Model', uniqueSorted(rows.map((t) => t.model)), state.model, setText('model')),
facet('project', 'Project', uniqueSorted(agentRows.map((a) => a.project)), state.project, setText('project')),
];
if (traces.error instanceof ObserveDaemonDownError) return <ObserveDaemonDown />;
const facets = traceFacets(state, rows, agents.data ?? [], setText);
if (down) return <ObserveDaemonDown />;

return (
<section className="traces-screen" aria-label="Traces">
Expand Down
11 changes: 7 additions & 4 deletions apps/agent-forge/src/ui/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,10 @@ function Dialog({
};
}, []);
const onKeyDown = (e: ReactKeyboardEvent) => {
// Modal: no keydown leaks to window-level handlers behind it (a queue's `S` skip, table row keys).
e.stopPropagation();
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose();
} else if (e.key === 'Tab') {
e.preventDefault(); // one focusable control per dialog: keep focus inside
Expand Down Expand Up @@ -188,7 +189,8 @@ function ShortcutSheet({ onClose }: { onClose: () => void }) {

/**
* The global command palette (Ctrl/Cmd+K) and `?` shortcut sheet. Mount once
* in the app shell; it owns the window keydown listener for both.
* in the app shell; it owns the window keydown listener for both. While either
* is open it stops every keydown inside it from reaching window-level handlers.
*/
export function CommandPalette() {
const { agents } = useAppState();
Expand All @@ -204,8 +206,9 @@ export function CommandPalette() {
e.preventDefault();
setOpen((o) => (id === 'palette' && o === 'palette' ? null : id));
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
// Capture phase: the dialog stops every keydown it gets from bubbling to window, so the toggle must not wait for it.
window.addEventListener('keydown', onKeyDown, true);
return () => window.removeEventListener('keydown', onKeyDown, true);
}, []);

const onClose = () => setOpen(null);
Expand Down
34 changes: 34 additions & 0 deletions apps/agent-forge/src/ui/__tests__/CommandPalette.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,40 @@ describe('CommandPalette edge cases', () => {
});
});

describe('CommandPalette modal keys', () => {
it.each([
['palette', () => key('k', { ctrlKey: true }), () => input()],
['? sheet', () => key('?'), () => document.querySelector('button.btn') as HTMLElement],
])('the %s keeps keydown away from window-level handlers behind it', (_name, open, focused) => {
const behind = vi.fn();
mount();
open();
window.addEventListener('keydown', behind);
try {
const target = focused();
for (const k of ['s', 'j', 'ArrowDown', 'Enter']) key(k, {}, target);
expect(behind).not.toHaveBeenCalled();
} finally {
window.removeEventListener('keydown', behind);
}
});

it('still toggles with Ctrl+K from inside the palette and lets keys through once closed', () => {
const behind = vi.fn();
mount();
key('k', { ctrlKey: true });
key('k', { ctrlKey: true }, input());
expect(dialog('Command palette')).toBeNull();
window.addEventListener('keydown', behind);
try {
key('s', {}, document.body);
expect(behind).toHaveBeenCalledTimes(1);
} finally {
window.removeEventListener('keydown', behind);
}
});
});

describe('matchShortcut', () => {
const ev = (k: string, init: KeyboardEventInit = {}) => new KeyboardEvent('keydown', { key: k, ...init });
it('maps Ctrl/Cmd+K and ? and nothing else', () => {
Expand Down
8 changes: 5 additions & 3 deletions apps/agent-forge/src/ui/run/run.css
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@
overflow: hidden;
}
.run-name {
flex: 1 1 auto;
min-width: 0;
flex: 1 1 8ch;
min-width: 8ch; /* never a letter per line in a narrow pane: the chips below wrap instead */
white-space: normal;
overflow-wrap: anywhere;
}
Expand Down Expand Up @@ -118,8 +118,9 @@
}
.run-row-main {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px;
gap: 2px 8px;
flex: 1 1 auto;
min-width: 0;
padding: 6px 8px;
Expand All @@ -131,6 +132,7 @@
cursor: pointer;
}
.run-row-virtual .run-row-main {
flex-wrap: nowrap;
align-items: center;
padding-top: 0;
padding-bottom: 0;
Expand Down
Loading