diff --git a/packages/core/src/runner/runner.ts b/packages/core/src/runner/runner.ts index eca9e27b..6a17db4e 100644 --- a/packages/core/src/runner/runner.ts +++ b/packages/core/src/runner/runner.ts @@ -259,6 +259,8 @@ export function getRun(id: string): Run { return toRun(row); } +const UNFINISHED = new Set(["queued", "running", "paused"]); + export function listRuns(opts: { agentId?: string; status?: string; conversationId?: string; parentRunId?: string; limit?: number } = {}): Run[] { const where: string[] = []; const params: (string | number)[] = []; @@ -274,17 +276,17 @@ export function listRuns(opts: { agentId?: string; status?: string; conversation where.push("conversation_id = ?"); params.push(opts.conversationId); } - if (opts.status) { - const statuses = opts.status.split(",").map((s) => s.trim()).filter(Boolean); - if (statuses.length) { - where.push(`status IN (${statuses.map(() => "?").join(", ")})`); - params.push(...statuses); - } + const statuses = (opts.status ?? "").split(",").map((s) => s.trim()).filter(Boolean); + if (statuses.length) { + where.push(`status IN (${statuses.map(() => "?").join(", ")})`); + params.push(...statuses); } + // Unfinished runs are a handful: find them by status instead of walking every run newest first. + const order = statuses.length && statuses.every((s) => UNFINISHED.has(s)) ? "+created_at" : "created_at"; const limit = Math.min(Math.max(1, Math.floor(opts.limit ?? 50)), 500); params.push(limit); const runs = all( - `SELECT * FROM runs ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at DESC, rowid DESC LIMIT ?`, + `SELECT * FROM runs ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY ${order} DESC, rowid DESC LIMIT ?`, ...params, ).map(toRun); // Why a paused run stands still: paused by the human, waiting for Claude's limit, or for the human's answer. diff --git a/packages/core/src/services/conversations.ts b/packages/core/src/services/conversations.ts index a3fe06a5..e7e604b1 100644 --- a/packages/core/src/services/conversations.ts +++ b/packages/core/src/services/conversations.ts @@ -512,9 +512,11 @@ export function listConversations( } const limit = Math.min(Math.max(1, Math.floor(opts.limit ?? 100)), 500); params.push(limit); + const order = `${opts.archived ? "" : "c.pinned DESC, "}COALESCE(c.last_message_at, c.created_at) DESC`; + // Pick the page first: the preview and the derived columns are then looked up for those chats only, not for every one. const rows = all( - `SELECT c.*, ${PREVIEW_SQL}, ${FOLLOWUP_SQL}, ${PAUSE_SQL}, ${DELEGATED_SQL} FROM ${FROM_SQL} WHERE ${where.join(" AND ")} - ORDER BY ${opts.archived ? "" : "c.pinned DESC, "}COALESCE(c.last_message_at, c.created_at) DESC LIMIT ?`, + `SELECT c.*, ${PREVIEW_SQL}, ${FOLLOWUP_SQL}, ${PAUSE_SQL}, ${DELEGATED_SQL} FROM ${FROM_SQL} + WHERE c.id IN (SELECT c.id FROM ${FROM_SQL} WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT ?) ORDER BY ${order}`, ...params, ); return rows.map(toConversation); diff --git a/packages/core/test/conversations.test.ts b/packages/core/test/conversations.test.ts index e05f63a1..fac0172a 100644 --- a/packages/core/test/conversations.test.ts +++ b/packages/core/test/conversations.test.ts @@ -68,6 +68,9 @@ describe("conversation list", () => { expect(list.slice(0, 3).map((x) => x.id)).toEqual([c.id, b.id, a.id]); expect(list.find((x) => x.id === a.id)!.preview).toBe("hello from alpha"); expect(list.every((x) => x.running === false)).toBe(true); + const page = listConversations({ agentId: agent.id, limit: 2 }); + expect(page.map((x) => x.id)).toEqual([c.id, b.id]); + expect(page[1]!.preview).toBe("find the needle here"); expect(listConversations({ search: "needle" }).map((x) => x.id)).toEqual([b.id]); expect(listConversations({ search: "alph" }).map((x) => x.id)).toEqual([a.id]); diff --git a/packages/core/test/runner.test.ts b/packages/core/test/runner.test.ts index b8af10d6..8d50b039 100644 --- a/packages/core/test/runner.test.ts +++ b/packages/core/test/runner.test.ts @@ -30,6 +30,7 @@ import { findRunLog, getRun, listActiveRuns, + listRuns, recoverInterruptedRuns, waitForRun, } from "../src/runner/runner"; @@ -592,3 +593,26 @@ describe("recoverInterruptedRuns", () => { expect(all("SELECT 1 FROM message_images WHERE message_id = ?", messageId)).toEqual([]); }); }); + +describe("listRuns", () => { + test("filters by status newest first, unfinished and finished alike", () => { + const conv = createConversation({ agentId: agent.id }); + const at = (m: number) => `2031-01-01T00:0${m}:00.000Z`; + const rows = [ + ["run_list_test_000001", "succeeded", at(1)], + ["run_list_test_000002", "running", at(2)], + ["run_list_test_000003", "queued", at(3)], + ["run_list_test_000004", "succeeded", at(4)], + ["run_list_test_000005", "running", at(4)], + ] as const; + for (const [id, status, created] of rows) insert("runs", { id, agent_id: agent.id, conversation_id: conv.id, trigger: "chat", status, prompt: "x", created_at: created }); + expect(listRuns({ conversationId: conv.id, status: "queued,running,paused" }).map((r) => r.id)).toEqual([ + "run_list_test_000005", + "run_list_test_000003", + "run_list_test_000002", + ]); + expect(listRuns({ conversationId: conv.id, status: "running", limit: 1 }).map((r) => r.id)).toEqual(["run_list_test_000005"]); + expect(listRuns({ conversationId: conv.id, status: "succeeded" }).map((r) => r.id)).toEqual(["run_list_test_000004", "run_list_test_000001"]); + sql("DELETE FROM runs WHERE id LIKE 'run_list_test_%'"); + }); +});