From 9d166e3fc9c9ceb5302d7193109bc6ccc6b5a40a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 02:31:43 -0400 Subject: [PATCH] Q158: run history and run output come from the database, not the snapshot window Co-Authored-By: Claude Opus 5.5 --- src/main/app/ipc-contracts/organization.ts | 4 ++ src/main/app/ipc-handlers.ts | 2 + src/main/services/provider-request-store.ts | 3 +- src/main/services/run-history.ts | 35 ++++++++++ src/main/services/snapshot-service.ts | 2 +- src/preload/index.ts | 1 + src/renderer/src/app/App.tsx | 2 +- src/renderer/src/i18n/zh.ts | 2 + src/renderer/src/pages/RunsPage.tsx | 68 ++++++++++++++++--- .../src/pages/runs/RunRequestList.tsx | 24 +------ .../src/pages/runs/use-run-requests.ts | 25 +++++++ src/shared/desktop-api.ts | 4 +- src/shared/domain.ts | 14 ++++ src/shared/ipc.ts | 1 + src/shared/provider-runtime-domain.ts | 3 + tests/e2e/ceo-chat.spec.ts | 14 ++++ tests/integration/run-requests.test.ts | 3 + tests/unit/run-history.test.ts | 50 ++++++++++++++ 18 files changed, 222 insertions(+), 35 deletions(-) create mode 100644 src/main/services/run-history.ts create mode 100644 src/renderer/src/pages/runs/use-run-requests.ts create mode 100644 tests/unit/run-history.test.ts diff --git a/src/main/app/ipc-contracts/organization.ts b/src/main/app/ipc-contracts/organization.ts index 68a89eda..21b1afc0 100644 --- a/src/main/app/ipc-contracts/organization.ts +++ b/src/main/app/ipc-contracts/organization.ts @@ -48,6 +48,10 @@ export const organizationContracts = { setTaskBlocked: z.strictObject({ orgId: id, taskId: id, reason: z.string().nullable() }) satisfies z.ZodType, taskDeleteImpact: z.strictObject({ orgId: id, taskId: id }), cancelQueuedRun: z.strictObject({ orgId: id, runId: id }), + runHistory: z.union([ + z.strictObject({ orgId: id, beforeRunId: id.nullable(), limit: z.number().int().min(1).max(500) }), + z.strictObject({ orgId: id, beforeRunId: id.nullable(), from: z.strictObject({ createdAt: z.string(), seq: z.number().int() }) }), + ]), updateTaskStatus: z.strictObject({ orgId: id, taskId: id, status: z.enum(TASK_STATUS) }) satisfies z.ZodType, runTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType, createGoal: z.strictObject({ orgId: id, title: z.string(), description: z.string(), ownerAgentId: nullableId }) satisfies z.ZodType, diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index bdf1b935..389d12df 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -38,6 +38,7 @@ import { normalizeExternalBrowserUrl } from "./external-url"; import { resumeGoal } from "../services/goal-review-service"; import { stopAllRuns, stopRun } from "../services/run-control"; import { cancelQueuedRun } from "../services/run-cancel"; +import { runHistory } from "../services/run-history"; export interface IpcDeps { connection: DatabaseConnection; @@ -168,6 +169,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { handle("setTaskBlocked", (input) => mutate(() => organization.setTaskBlocked(input))); handle("taskDeleteImpact", (input) => ok(organization.taskDeleteImpact(input.orgId, input.taskId))); handle("cancelQueuedRun", (input) => mutate(() => cancelQueuedRun(deps.connection.db, input.orgId, input.runId))); + handle("runHistory", (input) => ok(runHistory(deps.connection.db, input.orgId, input.beforeRunId, "limit" in input ? { limit: input.limit } : { from: input.from }))); handle("deleteTask", (input) => mutate(() => organization.deleteTask(input))); handle("updateTaskStatus", (input) => mutate(() => organization.updateTaskStatus(input.orgId, input.taskId, input.status))); handle("runTask", async (input) => { diff --git a/src/main/services/provider-request-store.ts b/src/main/services/provider-request-store.ts index 91646e02..805a041f 100644 --- a/src/main/services/provider-request-store.ts +++ b/src/main/services/provider-request-store.ts @@ -40,7 +40,8 @@ export type RequestPurpose = "agent_turn" | "goal_audit" | "model_check" | "manu export function requestsForRun(db: DatabaseSync, orgId: string, runId: string): RunRequestRecord[] { return db.prepare(` select id, purpose, model, state, error_message as errorMessage, input_tokens as inputTokens, output_tokens as outputTokens, - cost_cents as costCents, created_at as createdAt, completed_at as completedAt + cost_cents as costCents, created_at as createdAt, completed_at as completedAt, + input_char_count as inputCharCount, output_char_count as outputCharCount, response_text as responseText from provider_requests where org_id = ? and task_run_id = ? order by rowid `).all(orgId, runId) as unknown as RunRequestRecord[]; } diff --git a/src/main/services/run-history.ts b/src/main/services/run-history.ts new file mode 100644 index 00000000..417da6db --- /dev/null +++ b/src/main/services/run-history.ts @@ -0,0 +1,35 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { RunCursor, RunHistoryPage, TaskRunRecord } from "../../shared/domain"; +import { camel } from "./snapshot-service"; + +/** Which older runs to return: the next page before the cursor, or every run from a position (which survives its run's deletion) up to it. */ +export type RunHistoryScope = { limit: number } | { from: RunCursor }; + +/** Runs before a loaded one (or the newest when none is loaded), oldest first, in time then insertion order, and whether older ones exist. */ +export function runHistory(db: DatabaseSync, orgId: string, beforeRunId: string | null, scope: RunHistoryScope): RunHistoryPage { + const conditions = ["org_id = ?"]; + const params: Array = [orgId]; + let boundary: RunCursor | null = null; + if (beforeRunId !== null) { + const before = db.prepare("select created_at, rowid from task_runs where id = ? and org_id = ?").get(beforeRunId, orgId) as { created_at: string; rowid: number } | undefined; + if (!before) throw new Error("The oldest loaded run was not found."); + conditions.push("(created_at, rowid) < (?, ?)"); + params.push(before.created_at, before.rowid); + boundary = { createdAt: before.created_at, seq: before.rowid }; + } + if ("from" in scope) { + conditions.push("(created_at, rowid) >= (?, ?)"); + params.push(scope.from.createdAt, scope.from.seq); + } + const limit = "limit" in scope ? scope.limit : -1; + const rows = db.prepare(`select *, rowid as seq from task_runs where ${conditions.join(" and ")} order by created_at desc, rowid desc limit ?`) + .all(...params, limit === -1 ? -1 : limit + 1) as Array>; + const page = (limit === -1 ? rows : rows.slice(0, limit)).reverse(); + const first = page[0]; + const oldest = first ? { createdAt: first.created_at as string, seq: first.seq as number } : null; + // A range is measured from its pinned position, which holds even when the runs at it were deleted. + const hasMore = "limit" in scope ? rows.length > limit : db.prepare(` + select 1 from task_runs where org_id = ? and (created_at, rowid) < (?, ?) limit 1 + `).get(orgId, scope.from.createdAt, scope.from.seq) !== undefined; + return { runs: page.map(({ seq: _seq, ...row }) => camel(row) as unknown as TaskRunRecord), hasMore, oldest, boundary }; +} diff --git a/src/main/services/snapshot-service.ts b/src/main/services/snapshot-service.ts index 7a203efc..f00ba14d 100644 --- a/src/main/services/snapshot-service.ts +++ b/src/main/services/snapshot-service.ts @@ -95,7 +95,7 @@ export class SnapshotService { workspaces, goals: list(this.db, "goals", "created_at asc"), projects: list(this.db, "projects", "created_at asc"), - taskRuns: recentList(this.db, "task_runs", "created_at desc", 200).reverse(), + taskRuns: recentList(this.db, "task_runs", "created_at desc, rowid desc", 200).reverse(), approvals: list(this.db, "approvals", "created_at desc"), chatSpaces: list(this.db, "chat_spaces", "created_at asc"), chatMemberships: list(this.db, "chat_memberships", "space_id asc, principal_type asc, principal_id asc"), diff --git a/src/preload/index.ts b/src/preload/index.ts index 5479571f..fe3cfbec 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -93,6 +93,7 @@ const api: DesktopApi = { setTaskBlocked: (input) => invoke(IPC_CHANNELS.setTaskBlocked, input), taskDeleteImpact: (input) => invoke(IPC_CHANNELS.taskDeleteImpact, input), cancelQueuedRun: (input) => invoke(IPC_CHANNELS.cancelQueuedRun, input), + runHistory: (input) => invoke(IPC_CHANNELS.runHistory, input), revokeConnector: (input) => invoke(IPC_CHANNELS.revokeConnector, input), refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input), search: (input) => invoke(IPC_CHANNELS.search, input), diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index 8c875012..c08dff49 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -96,7 +96,7 @@ export function App() { office: , settings: , }[route]; - const pageKey = focus ? `${route}:${focus.kind}:${focus.id}` : route; + const pageKey = `${snapshot.activeOrgId}:${route}${focus ? `:${focus.kind}:${focus.id}` : ""}`; return ( diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index c9e54b1a..3f09474c 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -52,6 +52,8 @@ export const zh: Record = { "Showing {shown} of {total}": "显示 {total} 项中的 {shown} 项", "Show earlier decisions": "显示更早的决定", "Cancel run": "取消运行", + "Show older runs": "显示更早的运行", + "These are all the runs.": "已显示全部运行。", "Running…": "运行中…", "A cancelled session can start again on the next heartbeat; block the task to keep it stopped.": "取消的会话可能在下一次心跳时再次开始;如需保持停止,请阻塞该任务。", "A new session on the task picks up from what earlier sessions did and where they stopped.": "任务的新会话会接着之前会话做过的工作和停下的位置继续。", diff --git a/src/renderer/src/pages/RunsPage.tsx b/src/renderer/src/pages/RunsPage.tsx index 25587fa6..b0dd1aa7 100644 --- a/src/renderer/src/pages/RunsPage.tsx +++ b/src/renderer/src/pages/RunsPage.tsx @@ -1,7 +1,8 @@ import { RunRequestList } from "./runs/RunRequestList"; -import { useMemo, useState } from "react"; +import { useRunRequests } from "./runs/use-run-requests"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Avatar, Button, Pill, Segmented } from "../shared/ui"; -import type { AgentRecord, ProviderRecord, TaskRecord, TaskRunRecord } from "../../../shared/domain"; +import type { AgentRecord, ProviderRecord, RunCursor, TaskRecord, TaskRunRecord } from "../../../shared/domain"; import type { PageProps } from "./types"; import { activeOrgId, formatRelative } from "./types"; import { t } from "../i18n/i18n"; @@ -17,11 +18,48 @@ const FILTERS: Array<{ id: StatusFilter; label: string }> = [ export function RunsPage({ snapshot, actions, navigate, focus }: PageProps) { const orgId = activeOrgId(snapshot); - const taskRuns = useMemo(() => snapshot.taskRuns.filter((r) => r.orgId === orgId), [snapshot.taskRuns, orgId]); + const recentRuns = useMemo(() => snapshot.taskRuns.filter((r) => r.orgId === orgId), [snapshot.taskRuns, orgId]); + // Older runs page in from the database; their range, from a pinned position, refreshes with each reload. + const [pinned, setPinned] = useState(null); + const [olderRuns, setOlderRuns] = useState([]); + const [olderDone, setOlderDone] = useState(false); + const [historyError, setHistoryError] = useState(null); + const anchorId = recentRuns.length > 0 ? recentRuns[0].id : null; + useEffect(() => { + if (!pinned) return; + let cancelled = false; + window.agentCompany.runHistory({ orgId, beforeRunId: anchorId, from: pinned }).then((result) => { + if (cancelled) return; + if (!result.ok) throw new Error(result.error.message); + setOlderRuns(result.data.runs); + setOlderDone(!result.data.hasMore); + setHistoryError(null); + }).catch((error: unknown) => { + if (!cancelled) setHistoryError(error instanceof Error ? error.message : String(error)); + }); + return () => { + cancelled = true; + }; + }, [orgId, anchorId, pinned, recentRuns]); + const taskRuns = useMemo(() => [...olderRuns, ...recentRuns], [olderRuns, recentRuns]); + const oldestShownId = taskRuns.length > 0 ? taskRuns[0].id : null; + const loadOlder = useCallback(() => { + window.agentCompany.runHistory({ orgId, beforeRunId: oldestShownId, limit: 100 }).then((result) => { + if (!result.ok) throw new Error(result.error.message); + setHistoryError(null); + // With nothing older, the oldest shown run's position is pinned so later reloads keep the range. + const position = result.data.oldest ?? result.data.boundary; + if (position) setPinned(position); + setOlderDone(!result.data.hasMore); + }).catch((error: unknown) => setHistoryError(error instanceof Error ? error.message : String(error))); + }, [orgId, oldestShownId]); + // While the snapshot holds none of this company's runs and nothing is pinned, each reload asks the database for the newest page. + useEffect(() => { + if (recentRuns.length === 0 && pinned === null) loadOlder(); + }, [recentRuns, pinned, loadOlder]); const tasks = useMemo(() => snapshot.tasks.filter((t) => t.orgId === orgId), [snapshot.tasks, orgId]); const agents = useMemo(() => snapshot.agents.filter((a) => a.orgId === orgId), [snapshot.agents, orgId]); const providers = useMemo(() => snapshot.providers.filter((p) => p.orgId === orgId), [snapshot.providers, orgId]); - const providerRequests = useMemo(() => snapshot.providerRequests.filter((r) => r.orgId === orgId), [snapshot.providerRequests, orgId]); const [filter, setFilter] = useState("all"); const [selectedId, setSelectedId] = useState(focus?.kind === "run" ? focus.id : null); const [search, setSearch] = useState(""); @@ -62,7 +100,8 @@ export function RunsPage({ snapshot, actions, navigate, focus }: PageProps) {
- {filtered.map((run) => { + {/* Newest first, so "show older runs" continues below. */} + {[...filtered].reverse().map((run) => { const task = tasks.find((t) => t.id === run.taskId); const agent = agents.find((a) => a.id === run.agentId); const active = run.id === selectedId; @@ -86,11 +125,17 @@ export function RunsPage({ snapshot, actions, navigate, focus }: PageProps) { ); })} {filtered.length === 0 &&
{t("No runs")}
} + {historyError &&
{historyError}
} +
+ {olderDone + ? {t("These are all the runs.")} + : } +
{selected ? ( - navigate("work", { kind: "task", id: taskId })} /> + navigate("work", { kind: "task", id: taskId })} /> ) : (
{t("Select a run")} @@ -101,19 +146,22 @@ export function RunsPage({ snapshot, actions, navigate, focus }: PageProps) { ); } -function RunDetail({ run, tasks, agents, providers, providerRequests, actions, onOpenTask }: { +function RunDetail({ run, tasks, agents, providers, actions, onOpenTask }: { actions: PageProps["actions"]; onOpenTask: (taskId: string) => void; run: TaskRunRecord; tasks: TaskRecord[]; agents: AgentRecord[]; providers: ProviderRecord[]; - providerRequests: Array<{ id: string; orgId: string; providerId: string; model: string; state: string; responseText: string | null; inputCharCount: number; outputCharCount: number; createdAt: string; inputTokens: number | null; outputTokens: number | null; costCents: number | null }>; }) { const task = tasks.find((t) => t.id === run.taskId); const agent = agents.find((a) => a.id === run.agentId); const provider = providers.find((p) => p.id === run.providerId); - const request = run.providerRequestId ? providerRequests.find((r) => r.id === run.providerRequestId) : null; + // The run's calls come from the database, so an old run still shows its output; refetched with each snapshot reload. + const loadedRequests = useRunRequests(run.orgId, run.id, run); + const request = loadedRequests && "requests" in loadedRequests && run.providerRequestId + ? loadedRequests.requests.find((item) => item.id === run.providerRequestId) ?? null + : null; const started = run.startedAt ? new Date(run.startedAt) : null; const completed = run.completedAt ? new Date(run.completedAt) : null; @@ -165,7 +213,7 @@ function RunDetail({ run, tasks, agents, providers, providerRequests, actions, o {t("Context")}
{run.executionContextId}
- + {run.state === "superseded" && run.errorMessage && (
diff --git a/src/renderer/src/pages/runs/RunRequestList.tsx b/src/renderer/src/pages/runs/RunRequestList.tsx index 48ca0d5c..9abb836a 100644 --- a/src/renderer/src/pages/runs/RunRequestList.tsx +++ b/src/renderer/src/pages/runs/RunRequestList.tsx @@ -1,5 +1,4 @@ -import { useEffect, useState } from "react"; -import type { RunRequestRecord } from "../../../../shared/provider-runtime-domain"; +import type { LoadedRequests } from "./use-run-requests"; import { Pill } from "../../shared/ui"; import { t } from "../../i18n/i18n"; @@ -12,25 +11,8 @@ function purposeLabel(purpose: string): string { } /** Every model call the run made, in order, so a turn or an audit can be found by its task. */ -export function RunRequestList({ orgId, runId, state }: { orgId: string; runId: string; state: string }) { - // Fetched calls are kept with the run they belong to, so switching runs never shows another run's chain. - const [loaded, setLoaded] = useState<{ runId: string; requests: RunRequestRecord[] } | { runId: string; error: string } | null>(null); - useEffect(() => { - let cancelled = false; - window.agentCompany.runRequests({ orgId, runId }) - .then((result) => { - if (cancelled) return; - setLoaded(result.ok ? { runId, requests: result.data } : { runId, error: result.error.message }); - }) - .catch((reason: unknown) => { - if (!cancelled) setLoaded({ runId, error: reason instanceof Error ? reason.message : String(reason) }); - }); - return () => { - cancelled = true; - }; - }, [orgId, runId, state]); - - if (!loaded || loaded.runId !== runId) return null; +export function RunRequestList({ loaded }: { loaded: LoadedRequests | null }) { + if (!loaded) return null; if ("error" in loaded) return
{loaded.error}
; const { requests } = loaded; return ( diff --git a/src/renderer/src/pages/runs/use-run-requests.ts b/src/renderer/src/pages/runs/use-run-requests.ts new file mode 100644 index 00000000..9849358b --- /dev/null +++ b/src/renderer/src/pages/runs/use-run-requests.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from "react"; +import type { RunRequestRecord } from "../../../../shared/provider-runtime-domain"; + +export type LoadedRequests = { runId: string; requests: RunRequestRecord[] } | { runId: string; error: string }; + +/** Every model call of one run, read from the database (not the snapshot's window), refetched whenever `refreshKey` changes. */ +export function useRunRequests(orgId: string, runId: string, refreshKey: unknown): LoadedRequests | null { + // Fetched calls are kept with the run they belong to, so switching runs never shows another run's chain. + const [loaded, setLoaded] = useState(null); + useEffect(() => { + let cancelled = false; + window.agentCompany.runRequests({ orgId, runId }) + .then((result) => { + if (cancelled) return; + setLoaded(result.ok ? { runId, requests: result.data } : { runId, error: result.error.message }); + }) + .catch((reason: unknown) => { + if (!cancelled) setLoaded({ runId, error: reason instanceof Error ? reason.message : String(reason) }); + }); + return () => { + cancelled = true; + }; + }, [orgId, runId, refreshKey]); + return loaded && loaded.runId === runId ? loaded : null; +} diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index 1d6da67d..2a457227 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -1,4 +1,4 @@ -import type { TaskDeleteImpact } from "./domain"; +import type { RunCursor, RunHistoryPage, TaskDeleteImpact } from "./domain"; import type { SpaceHistoryPage, ThreadHistory } from "./chat-domain"; import type { RunRequestRecord } from "./provider-runtime-domain"; import type { ApprovalDrift } from "./approval-pin"; @@ -207,6 +207,8 @@ export interface DesktopApi { taskDeleteImpact(input: { orgId: string; taskId: string }): Promise>; /** Cancels a run that has not started yet. */ cancelQueuedRun(input: { orgId: string; runId: string }): Promise>; + /** Runs older than a loaded one, for history beyond the snapshot's recent runs. */ + runHistory(input: { orgId: string; beforeRunId: string | null } & ({ limit: number } | { from: RunCursor })): Promise>; chatSpaceHistory(input: { orgId: string; spaceId: string; beforeMessageId: string | null } & ({ limit: number } | { fromMessageId: string })): Promise>; revokeConnector(input: RevokeConnectorInput): Promise>; search(input: SearchInput): Promise>; diff --git a/src/shared/domain.ts b/src/shared/domain.ts index eb62bc12..83a6b9f3 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -105,6 +105,20 @@ export interface TaskDeleteImpact { pendingApprovals: number; } +/** A position in run history (time, then insertion), which stays valid after its run is deleted. */ +export interface RunCursor { + createdAt: string; + seq: number; +} + +/** A page of older runs, oldest first: whether older ones exist, the position of its oldest run, and of the run it was read before. */ +export interface RunHistoryPage { + runs: TaskRunRecord[]; + hasMore: boolean; + oldest: RunCursor | null; + boundary: RunCursor | null; +} + export interface TaskRecord { id: string; orgId: string; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 13989bd9..869aacc2 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -86,6 +86,7 @@ export const IPC_CHANNELS = { setTaskBlocked: "agentcompany:set-task-blocked", taskDeleteImpact: "agentcompany:task-delete-impact", cancelQueuedRun: "agentcompany:cancel-queued-run", + runHistory: "agentcompany:run-history", revokeConnector: "agentcompany:revoke-connector", search: "agentcompany:search", rendererEvent: "agentcompany:renderer-event", diff --git a/src/shared/provider-runtime-domain.ts b/src/shared/provider-runtime-domain.ts index 428df2b9..dd3fc414 100644 --- a/src/shared/provider-runtime-domain.ts +++ b/src/shared/provider-runtime-domain.ts @@ -56,4 +56,7 @@ export interface RunRequestRecord { costCents: number | null; createdAt: string; completedAt: string | null; + inputCharCount: number; + outputCharCount: number; + responseText: string | null; } diff --git a/tests/e2e/ceo-chat.spec.ts b/tests/e2e/ceo-chat.spec.ts index 5730eb1a..54587fb1 100644 --- a/tests/e2e/ceo-chat.spec.ts +++ b/tests/e2e/ceo-chat.spec.ts @@ -290,6 +290,20 @@ test("the pinned CEO conversation is the Messages front door", async () => { await expect(start).toBeVisible(); await expect(chatLog.getByText("General line 0", { exact: true })).toBeVisible(); + // Runs older than the loaded window page in from the database, down to the first one. + await page.getByRole("button", { name: "Runs" }).click(); + const runRows = page.locator(".pane-scroll .nav-row"); + await expect(runRows).toHaveCount(200); + await page.getByRole("button", { name: "Show older runs" }).click(); + await expect.poll(async () => runRows.count()).toBeGreaterThan(200); + for (let click = 0; click < 10 && await page.getByRole("button", { name: "Show older runs" }).isVisible(); click += 1) { + await page.getByRole("button", { name: "Show older runs" }).click(); + await page.waitForTimeout(300); + } + await expect(page.getByText("These are all the runs.")).toBeVisible(); + await page.getByRole("button", { name: "Messages" }).click(); + await page.locator(".convo-item", { hasText: "general" }).click(); + // The header lists the real members, how each agent replies, and who can read. await page.getByRole("button", { name: /^Members \(/ }).click(); const roster = page.getByRole("menu"); diff --git a/tests/integration/run-requests.test.ts b/tests/integration/run-requests.test.ts index a7cc0919..c4f7761c 100644 --- a/tests/integration/run-requests.test.ts +++ b/tests/integration/run-requests.test.ts @@ -40,6 +40,9 @@ describe("a run's model calls", () => { }); const runId = claimNextQueuedRun(connection)!.id; await expect(execution.executeRun(orgId, runId)).rejects.toThrow(); + // Each call carries its own output, so the run's detail does not depend on a snapshot window. + const turnCall = requestsForRun(connection.db, orgId, runId).find((request) => request.purpose === "agent_turn" && request.state === "succeeded")!; + expect([typeof turnCall.responseText, typeof turnCall.inputCharCount, typeof turnCall.outputCharCount]).toEqual(["string", "number", "number"]); expect(requestsForRun(connection.db, orgId, runId).map((request) => [request.purpose, request.state])).toEqual([ ["model_check", "succeeded"], ["agent_turn", "succeeded"], diff --git a/tests/unit/run-history.test.ts b/tests/unit/run-history.test.ts new file mode 100644 index 00000000..5731499e --- /dev/null +++ b/tests/unit/run-history.test.ts @@ -0,0 +1,50 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { SnapshotService } from "../../src/main/services/snapshot-service"; +import { runHistory } from "../../src/main/services/run-history"; +import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain"; + +describe("run history", () => { + test("pages back past the snapshot's recent runs to the very first", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-run-history-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + let orgId = ""; + let taskId = ""; + let agentId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Yang", mission: "m", goal: "g" }); + agentId = organization.createAgent({ orgId, name: "Ada", role: "Engineer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: null }); + taskId = organization.createTask({ + orgId, title: "Work", description: "d", status: TASK_STATUS.BACKLOG, assigneeAgentId: agentId, risk: RISK_LEVEL.LOW, + projectId: null, goalId: null, workspaceId: null, priority: TASK_PRIORITY.NORMAL, requiresFilesystem: false, + }); + // Every run shares one instant, so only insertion order can page them correctly. + const insert = connection.db.prepare("insert into task_runs(id, org_id, task_id, agent_id, trigger, state, created_at) values (?, ?, ?, ?, 'manual', 'completed', '2026-04-01T00:00:00.000Z')"); + for (let index = 0; index < 230; index += 1) insert.run(`run_${String(index).padStart(3, "0")}`, orgId, taskId, agentId); + }); + const recent = new SnapshotService(connection.db).load().taskRuns; + expect(recent).toHaveLength(200); + expect(recent[0].id).toBe("run_030"); + const older = runHistory(connection.db, orgId, recent[0].id, { limit: 20 }); + expect(older.runs.map((run) => run.id)).toEqual(Array.from({ length: 20 }, (_, index) => `run_${String(index + 10).padStart(3, "0")}`)); + expect(older.hasMore).toBe(true); + const oldest = runHistory(connection.db, orgId, older.runs[0].id, { limit: 20 }); + expect(oldest.runs.map((run) => run.id)).toEqual(Array.from({ length: 10 }, (_, index) => `run_${String(index).padStart(3, "0")}`)); + expect(oldest.hasMore).toBe(false); + expect(() => runHistory(connection.db, orgId, "run_missing", { limit: 20 })).toThrow("The oldest loaded run was not found."); + // The loaded range refreshes in one query from its oldest run up to the cursor. + const range = runHistory(connection.db, orgId, recent[0].id, { from: oldest.oldest! }); + expect([range.runs.length, range.runs[0].id, range.runs.at(-1)!.id, range.hasMore]).toEqual([30, "run_000", "run_029", false]); + // The pinned position holds after its run is deleted, and the range still says whether older runs exist. + connection.db.prepare("delete from task_runs where id in ('run_000', 'run_001')").run(); + expect(runHistory(connection.db, orgId, recent[0].id, { from: older.oldest! })).toMatchObject({ hasMore: true }); + expect(runHistory(connection.db, orgId, recent[0].id, { from: oldest.oldest! }).runs[0].id).toBe("run_002"); + // With nothing loaded, the newest runs come first. + expect(runHistory(connection.db, orgId, null, { limit: 2 }).runs.map((run) => run.id)).toEqual(["run_228", "run_229"]); + connection.close(); + }); +});