fix(cli): include __start__ message in nerve thread show

When 'nerve thread show' is called without --before, the initial user
prompt (__start__ message) is now displayed first, followed by the most
recent role rounds within the budget.

- Add getThreadStartMessage() to LogStore
- Modify buildThreadCommandOutput to accept optional startRow
- Pass start message from threadShowCommand when before===0
- Add tests for new behavior

Fixes #231
This commit is contained in:
2026-04-28 15:08:13 +00:00
parent a0a91d1699
commit ce79dbea7e
11 changed files with 123 additions and 4 deletions
@@ -533,6 +533,25 @@ describe("buildThreadCommandOutput", () => {
expect(paginationHint).toContain("--budget 400");
});
it("formats startRow first (chronologically before role rounds) and consumes budget first", () => {
const start: ThreadRoundRow = {
round: 0,
logId: 1,
timestamp: 100,
message: { role: "__start__", content: "go", meta: {}, timestamp: 100 },
};
const desc = [row(2, "bbb"), row(1, "aaa")];
const { lines, paginationHint } = buildThreadCommandOutput([], desc, 50_000, "run-s", start);
const text = lines.join("");
const idxStart = text.indexOf("[#0 __start__]");
const idxA = text.indexOf("\naaa\n");
const idxB = text.indexOf("\nbbb\n");
expect(idxStart).toBeGreaterThan(-1);
expect(idxA).toBeGreaterThan(idxStart);
expect(idxB).toBeGreaterThan(idxA);
expect(paginationHint).toBeNull();
});
it("default budget constant matches workflow command default", () => {
expect(DEFAULT_THREAD_BUDGET_CHARS).toBe(8000);
});
+3 -1
View File
@@ -117,8 +117,9 @@ const threadShowCommand = defineCommand({
process.exit(1);
}
const startRow = before === 0 ? store.getThreadStartMessage(args.runId) : null;
const totalRoleRounds = store.getThreadRoundCount(args.runId);
if (totalRoleRounds === 0) {
if (totalRoleRounds === 0 && startRow === null) {
process.stdout.write(
`🧵 Workflow thread: ${run.runId}\n workflow: ${run.workflow}\n status: ${run.status}\n\n📭 No role rounds recorded for this run.\n`,
);
@@ -143,6 +144,7 @@ const threadShowCommand = defineCommand({
descRows,
budgetChars,
args.runId,
startRow,
);
for (const line of lines) {
+23 -3
View File
@@ -278,20 +278,34 @@ function buildTruncatedSingleRound(
/**
* Build stdout lines for `nerve thread show`: newest-first selection from
* `descRows` until `budgetChars` (including `prefixLines`), then chronological order.
* When `startRow` is set (typically the persisted `__start__` frame on the first page only),
* it is formatted first and its length is subtracted from the budget before consuming `descRows`.
*/
export function buildThreadCommandOutput(
prefixLines: string[],
descRows: ThreadRoundRow[],
budgetChars: number,
runId: string,
startRow: ThreadRoundRow | null = null,
): ThreadCommandOutput {
const prefixText = prefixLines.join("");
let remaining = Math.max(0, budgetChars - prefixText.length);
const picked: ThreadRoundRow[] = [];
const leadingRoundBlocks: string[] = [];
const budgetFlag =
budgetChars === DEFAULT_THREAD_BUDGET_CHARS ? "" : ` --budget ${String(budgetChars)}`;
if (startRow !== null) {
const startBlock = formatThreadRoundBlock(startRow);
if (startBlock.length <= remaining) {
leadingRoundBlocks.push(startBlock);
remaining -= startBlock.length;
} else {
return buildTruncatedSingleRound(startRow, remaining, prefixLines, runId, budgetFlag);
}
}
const picked: ThreadRoundRow[] = [];
for (const row of descRows) {
const block = formatThreadRoundBlock(row);
if (block.length <= remaining) {
@@ -300,7 +314,13 @@ export function buildThreadCommandOutput(
continue;
}
if (picked.length === 0) {
return buildTruncatedSingleRound(row, remaining, prefixLines, runId, budgetFlag);
return buildTruncatedSingleRound(
row,
remaining,
[...prefixLines, ...leadingRoundBlocks],
runId,
budgetFlag,
);
}
break;
}
@@ -312,7 +332,7 @@ export function buildThreadCommandOutput(
paginationHint = `\n⏩ Older rounds not shown. Fetch with:\n nerve thread show ${runId} --before ${String(shownMinRound)}${budgetFlag}\n`;
}
return { lines: [...prefixLines, ...blocksAsc], paginationHint };
return { lines: [...prefixLines, ...leadingRoundBlocks, ...blocksAsc], paginationHint };
}
// ---------------------------------------------------------------------------
@@ -102,6 +102,7 @@ function makeLogStore(
),
getThreadRoundCount: vi.fn(() => 0),
getThreadRounds: vi.fn(() => []),
getThreadStartMessage: vi.fn(() => null),
archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })),
close: vi.fn(),
getAllWorkflowRuns: vi.fn(() => []),
@@ -89,6 +89,7 @@ function makeLogStore() {
getThreadMessages: vi.fn(() => []),
getThreadRoundCount: vi.fn(() => 0),
getThreadRounds: vi.fn(() => []),
getThreadStartMessage: vi.fn(() => null),
archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })),
close: vi.fn(),
getAllWorkflowRuns: vi.fn(() => []),
@@ -80,6 +80,7 @@ function makeMockLogStore() {
getThreadMessages: vi.fn(() => []),
getThreadRoundCount: vi.fn(() => 0),
getThreadRounds: vi.fn(() => []),
getThreadStartMessage: vi.fn(() => null),
archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })),
close: vi.fn(),
};
@@ -96,6 +96,7 @@ function makeLogStore() {
getThreadMessages: vi.fn(() => []),
getThreadRoundCount: vi.fn(() => 0),
getThreadRounds: vi.fn(() => []),
getThreadStartMessage: vi.fn(() => null),
archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })),
close: vi.fn(),
};
@@ -77,6 +77,7 @@ function makeLogStore() {
getThreadMessages: vi.fn(() => []),
getThreadRoundCount: vi.fn(() => 0),
getThreadRounds: vi.fn(() => []),
getThreadStartMessage: vi.fn(() => null),
archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })),
close: vi.fn(),
getAllWorkflowRuns: vi.fn(() => []),
@@ -255,5 +255,51 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
expect(store.getThreadRoundCount("missing")).toBe(0);
expect(store.getThreadRounds("missing", { before: 0, limit: 10 })).toHaveLength(0);
});
it("getThreadStartMessage returns __start__ row with round 0 and excludes it from getThreadRounds", () => {
store.append({
source: "workflow",
type: "thread_workflow_message",
refId: "run-start",
payload: JSON.stringify({
role: "__start__",
content: "launch",
meta: { prompt: "hi" },
timestamp: 50,
}),
timestamp: 50,
});
store.append({
source: "workflow",
type: "thread_command_event",
refId: "run-start",
payload: JSON.stringify({ type: "step_a", role: "alpha", content: "hello", meta: {} }),
timestamp: 51,
});
expect(store.getThreadRoundCount("run-start")).toBe(1);
const start = store.getThreadStartMessage("run-start");
expect(start).not.toBeNull();
expect(start?.round).toBe(0);
expect(start?.message.role).toBe("__start__");
expect(start?.message.content).toBe("launch");
const rounds = store.getThreadRounds("run-start", { before: 0, limit: 50 });
expect(rounds).toHaveLength(1);
expect(rounds[0].round).toBe(1);
expect(rounds[0].message.role).toBe("alpha");
});
it("getThreadStartMessage returns null when no __start__ message", () => {
store.append({
source: "workflow",
type: "thread_command_event",
refId: "run-no-start",
payload: JSON.stringify({ type: "step_a", role: "alpha", content: "x", meta: {} }),
timestamp: 1,
});
expect(store.getThreadStartMessage("run-no-start")).toBeNull();
});
});
});
+25
View File
@@ -166,6 +166,11 @@ export type LogStore = {
* with `round` from ROW_NUMBER() OVER (ORDER BY id ASC). No schema migration — numbering is computed in SQL.
*/
getThreadRounds: (runId: string, params: GetThreadRoundsParams) => ThreadRoundRow[];
/**
* The workflow `__start__` message for a run (if persisted), as a {@link ThreadRoundRow}
* with `round` 0 — not part of {@link getThreadRoundCount} / {@link getThreadRounds} numbering.
*/
getThreadStartMessage: (runId: string) => ThreadRoundRow | null;
/**
* Export logs older than the retention window to `data/archive/logs/YYYY-MM-DD.jsonl`,
* then delete those rows and advance `meta.archived_up_to` in one transaction per day
@@ -396,6 +401,15 @@ export function createLogStore(dbPath: string): LogStore {
LIMIT @lim`,
);
const getThreadStartMessageStmt = sqlite.prepare(
`SELECT id, timestamp, payload FROM logs
WHERE source = 'workflow' AND type IN ('thread_command_event', 'thread_workflow_message') AND ref_id = ?
AND payload IS NOT NULL AND json_valid(payload) = 1
AND COALESCE(json_extract(payload, '$.role'), '') = '__start__'
ORDER BY id ASC
LIMIT 1`,
);
const getActiveWorkflowRunsStmt = sqlite.prepare(
"SELECT run_id, workflow, status, timestamp, exit_code FROM workflow_runs WHERE status IN ('queued', 'started') ORDER BY timestamp ASC",
);
@@ -676,6 +690,16 @@ export function createLogStore(dbPath: string): LogStore {
return out;
}
function getThreadStartMessage(runId: string): ThreadRoundRow | null {
const row = getThreadStartMessageStmt.get(runId) as
| { id: number; timestamp: number; payload: string | null }
| undefined;
if (row === undefined || row.payload === null) return null;
const message = parseRoundPayload(row.payload, row.timestamp);
if (message === null) return null;
return { round: 0, logId: row.id, timestamp: row.timestamp, message };
}
function archiveDayTx(day: string, start: number, endExclusive: number): void {
runInTransaction(sqlite, () => {
deleteLogsForDayStmt.run({ start, endExclusive });
@@ -743,6 +767,7 @@ export function createLogStore(dbPath: string): LogStore {
getThreadMessages,
getThreadRoundCount,
getThreadRounds,
getThreadStartMessage,
archiveLogs,
close,
};
+2
View File
@@ -3,6 +3,8 @@ export { createCursorRole } from "./role-cursor.js";
export { createHermesRole } from "./role-hermes.js";
export { createLlmRole } from "./role-llm.js";
export { createReActRole } from "./role-react.js";
export { cursorAgent } from "./shared/cursor-agent.js";
export { llmExtract } from "./shared/llm-extract.js";
export {
nerveAgentContext,
readNerveYaml,