diff --git a/CLAUDE.md b/CLAUDE.md index 7edc19c6..1dcd1d45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,12 @@ out-of-band `relavium gate` cross-process resume landed (2.G — PR #47, behind history landed (2.I — PR #48, no new ADR); and CLI packaging, distribution & install verification landed (2.L — PR #49, behind ADR-0051) — the last gate-closing spine PR, **closing go/no-go #7 so all seven Phase-3 exit criteria now hold and Phase 3 may start**; and media host-wiring landed (2.S — PR #52, behind ADR-0042–0046, -no new ADR — `read_media` input access deferred to 2.M), **the first additive lane done**. The next pickup is 2.R +no new ADR), **the first additive lane done**; and the agent-first `relavium chat` REPL landed (2.M — PR #54, +2026-06-26, no new ADR — covered by ADR-0024/0047/0028/0050/0029; `read_media` **input** access split into a +dedicated, security-reviewed follow-up, so 2.M's REPL shipped without it); and the rest of the agent-first chat +family landed — `relavium chat-resume` (2.N), `chat-list` (2.O), `chat-export` + the in-REPL `/export` (2.P), +and `chat --json` + one-shot `agent run` (with `--fixture` cassette replay) (2.Q) — **PR #55, no new ADR**, +completing the agent-first CLI lane. The next pickup is 2.R (the inbound MCP client, ADR-0034 — off the M3 critical path and the Phase-3 go/no-go). For live status, per-PR history, milestone dates, and open obligations, see the canonical home [docs/roadmap/current.md](docs/roadmap/current.md); [README.md](README.md) is the diff --git a/README.md b/README.md index facd4bf6..7705788f 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,11 @@ provider/key commands (API keys in the OS keychain), the live `ink` streaming TU prompt + out-of-band `relavium gate` resume, the read commands (`list` / `logs` / `status` / `gate list`) over durable history, and the published, cross-OS-installable `npm i -g relavium` binary (packaging & install verification) have landed (milestone **M3** reached; with packaging shipped, all seven Phase-3 -go/no-go exit criteria now hold). For live status and the full roadmap, see +go/no-go exit criteria now hold). The first additive lanes have since landed too — media host-wiring +(a generative media-output fixture runs end-to-end on the CLI) and the full agent-first chat family: the +`relavium chat` REPL plus session resume / list / export, a headless `chat --json` event stream, and a +one-shot `agent run` with deterministic offline `--fixture` replay — the first user-facing `AgentSession` +surface. For live status and the full roadmap, see [docs/roadmap/current.md](docs/roadmap/current.md) and the [roadmap](docs/roadmap/README.md). diff --git a/apps/cli/src/chat/export.test.ts b/apps/cli/src/chat/export.test.ts new file mode 100644 index 00000000..b582d5ff --- /dev/null +++ b/apps/cli/src/chat/export.test.ts @@ -0,0 +1,184 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { createClient, createSessionStore, runMigrations, type DbClient } from '@relavium/db'; +import { AgentSchema, type AgentSessionRecord, type SessionMessage } from '@relavium/shared'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { isCliError } from '../process/errors.js'; +import { exportSession } from './export.js'; + +const ISO = '2026-06-25T00:00:00.000Z'; +const CTX = { workingDir: '/workspace', fsScopeTier: 'sandboxed' as const }; +const AGENT = AgentSchema.parse({ + id: 'coder', + model: 'claude-opus-4-8', + provider: 'anthropic', + system_prompt: 'You are concise.', +}); + +function record(overrides: Partial = {}): AgentSessionRecord { + return { + id: 's1', + agentSlug: AGENT.id, + agentSnapshot: AGENT, + title: 'My Session', + context: CTX, + status: 'active', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + createdAt: ISO, + updatedAt: ISO, + ...overrides, + }; +} + +const message = (seq: number, role: 'user' | 'assistant', text: string): SessionMessage => ({ + id: `m${seq}`, + sessionId: 's1', + sequenceNumber: seq, + role, + content: [{ type: 'text', text }], + timestamp: ISO, +}); + +describe('exportSession (2.P)', () => { + let client: DbClient; + let store: ReturnType; + let cwd: string; + + beforeEach(() => { + client = createClient(':memory:'); + runMigrations(client.db); + store = createSessionStore(client.db); + cwd = mkdtempSync(join(tmpdir(), 'relavium-export-')); + }); + afterEach(() => { + client.sqlite.close(); + rmSync(cwd, { recursive: true, force: true }); + }); + + function seedOneTurn(): void { + store.createSession(record()); + store.appendMessage(message(0, 'user', 'hello')); + store.appendMessage(message(1, 'assistant', 'hi there')); + } + + it('writes a .relavium.yaml scaffold named from the UNIQUE session id, and returns the path', () => { + seedOneTurn(); + const result = exportSession({ store, sessionId: 's1', cwd, force: false }); + + expect(result.workflowId).toBe('my-session'); // the IN-FILE id is the title-slug (renameable on the canvas) + expect(result.path).toBe(join(cwd, 's1.relavium.yaml')); // the FILENAME is the collision-free session id + expect(result.sequenceNumber).toBe(2); // one past the persisted MAX (1) + expect(existsSync(result.path)).toBe(true); + + const yaml = readFileSync(result.path, 'utf8'); + expect(yaml).toContain('schema_version:'); + expect(yaml).toContain('id: my-session'); + expect(yaml).toContain('type: agent'); // the one completed turn becomes an agent node + expect(yaml).toContain('relaviumExport'); // the full transcript is preserved in metadata + expect(yaml).toContain('hello'); // the transcript is in the metadata + }); + + it('keys the default filename on the session id, so same-titled sessions never collide', () => { + store.createSession(record({ id: 's1', title: 'Same Title' })); + store.createSession(record({ id: 's2', title: 'Same Title' })); + const a = exportSession({ store, sessionId: 's1', cwd, force: false }); + const b = exportSession({ store, sessionId: 's2', cwd, force: false }); + expect(a.path).toBe(join(cwd, 's1.relavium.yaml')); + expect(b.path).toBe(join(cwd, 's2.relavium.yaml')); + expect(a.path).not.toBe(b.path); // distinct files despite the shared title — no silent clobber + }); + + it('honors --out (relative to cwd) and auto-creates the parent directory', () => { + seedOneTurn(); + const result = exportSession({ + store, + sessionId: 's1', + cwd, + outPath: 'flows/out.yaml', + force: false, + }); + expect(result.path).toBe(join(cwd, 'flows/out.yaml')); + expect(existsSync(result.path)).toBe(true); // the missing flows/ dir was created (mkdir -p) + }); + + it('writes to an absolute --out path', () => { + seedOneTurn(); + const out = join(cwd, 'explicit.relavium.yaml'); + const result = exportSession({ store, sessionId: 's1', cwd, outPath: out, force: false }); + expect(result.path).toBe(out); + expect(existsSync(out)).toBe(true); + }); + + it('refuses to overwrite an existing file without force (exit-2 fault)', () => { + seedOneTurn(); + const path = join(cwd, 's1.relavium.yaml'); + writeFileSync(path, 'pre-existing', 'utf8'); + expect(() => exportSession({ store, sessionId: 's1', cwd, force: false })).toThrow( + /already exists — pass --force/, + ); + expect(readFileSync(path, 'utf8')).toBe('pre-existing'); // untouched + }); + + it('overwrites an existing file with force', () => { + seedOneTurn(); + const path = join(cwd, 's1.relavium.yaml'); + writeFileSync(path, 'pre-existing', 'utf8'); + exportSession({ store, sessionId: 's1', cwd, force: true }); + expect(readFileSync(path, 'utf8')).toContain('schema_version:'); // replaced with the scaffold + }); + + it('rejects an unknown sessionId as a clean exit-2 invocation fault', () => { + expect(() => exportSession({ store, sessionId: 'ghost', cwd, force: false })).toThrow( + /no session found with id ghost/, + ); + }); + + it('remaps a directory target (EISDIR under --force) to a clean exit-2 fault, not a raw crash', () => { + seedOneTurn(); + mkdirSync(join(cwd, 's1.relavium.yaml')); // occupy the default path with a DIRECTORY + let thrown: unknown; + try { + exportSession({ store, sessionId: 's1', cwd, force: true }); + } catch (err) { + thrown = err; + } + expect(isCliError(thrown)).toBe(true); // a typed invocation fault (exit 2), not a raw EISDIR (exit 1) + expect(isCliError(thrown) && thrown.message).toMatch(/not a file/); + }); + + it('remaps a file-as-path-component target (ENOTDIR under --out) to a clean exit-2 fault, not a raw crash', () => { + seedOneTurn(); + writeFileSync(join(cwd, 'conflict'), 'x', 'utf8'); // a regular FILE used as a NON-terminal path component + let thrown: unknown; + try { + // mkdirSync(dirname(path)) on `/conflict/deeper` must traverse THROUGH the file `conflict` ⇒ ENOTDIR + // (a file as the terminal component would be EEXIST instead — the middle component is what forces ENOTDIR). + exportSession({ + store, + sessionId: 's1', + cwd, + outPath: 'conflict/deeper/sub.yaml', + force: true, + }); + } catch (err) { + thrown = err; + } + expect(isCliError(thrown)).toBe(true); // the sibling ENOTDIR arm maps to exit 2, like EISDIR + expect(isCliError(thrown) && thrown.message).toMatch(/not a file/); + }); + + it('exports a session with no completed turns as a minimal input→output scaffold', () => { + store.createSession(record({ id: 's1', title: 'Empty' })); + const result = exportSession({ store, sessionId: 's1', cwd, force: false }); + expect(result.sequenceNumber).toBe(0); // empty transcript ⇒ next seq 0 + const yaml = readFileSync(result.path, 'utf8'); + expect(yaml).toContain('type: input'); + expect(yaml).toContain('type: output'); + expect(yaml).not.toContain('type: agent'); // no completed turn ⇒ no agent node + }); +}); diff --git a/apps/cli/src/chat/export.ts b/apps/cli/src/chat/export.ts new file mode 100644 index 00000000..358af97f --- /dev/null +++ b/apps/cli/src/chat/export.ts @@ -0,0 +1,103 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + +import { serializeWorkflow, sessionToWorkflow } from '@relavium/core'; +import type { SessionStore } from '@relavium/db'; +import type { AgentSessionRecord } from '@relavium/shared'; + +import { CliError } from '../process/errors.js'; + +/** + * Session → workflow export (2.P / [ADR-0026](../../../../docs/decisions/0026-session-export-to-workflow.md)) — + * the shared core driving both the `relavium chat-export ` command and the in-REPL `/export` slash + * command. It loads a persisted session, maps it to a `.relavium.yaml` **scaffold** (a linear chain of `agent` + * nodes + the full transcript under `metadata.relaviumExport`) via the engine's `sessionToWorkflow` (1.Z) + + * deterministic `serializeWorkflow`, and writes the file. It is **pure of side effects beyond the file write**: + * it does NOT mark the session row (that is a caller policy — the command marks it `exported`; the live + * `/export` does not, since a subsequent turn's persist would clobber the marker). No **Relavium-managed** + * secret can appear: API keys live in the OS keychain and never enter a `SessionMessage`, and the frozen + * `agentSnapshot` carries only a `{{secrets.*}}` placeholder, never a resolved key ([ADR-0006](../../../../docs/decisions/0006-os-keychain-for-api-keys.md)/[ADR-0029](../../../../docs/decisions/0029-tool-policy-hardening.md)). + * The user's own conversational content is preserved **verbatim** (as `prompt_template` text + the full + * transcript under `metadata.relaviumExport`) — that is the author's data to review before commit, by design. + */ + +export interface ExportSessionOptions { + readonly store: SessionStore; + readonly sessionId: string; + /** The base dir a relative `outPath` (or the default `.relavium.yaml`) resolves against — the launch cwd. */ + readonly cwd: string; + /** + * `--out ` override (absolute, or relative to {@link cwd}); the default is `.relavium.yaml` + * in cwd — keyed on the UNIQUE session id, not the (possibly shared/absent) title, so two sessions never + * collide on one path (which the in-REPL `/export`'s `force` would otherwise silently clobber). + */ + readonly outPath?: string; + /** Overwrite an existing file at the target path; without it an existing file is a clean exit-2 fault. */ + readonly force: boolean; +} + +export interface ExportResult { + /** The absolute path the scaffold was written to. */ + readonly path: string; + /** The scaffold workflow id (a deterministic kebab slug of the session title). */ + readonly workflowId: string; + /** The next session `sequenceNumber` past the transcript — the `session:exported` event's seq. */ + readonly sequenceNumber: number; + /** The loaded session record, so a caller can mark it `exported` without a second load. */ + readonly record: AgentSessionRecord; +} + +/** + * Load a persisted session and write its `.relavium.yaml` scaffold. An unknown `sessionId` is a clean exit-2 + * invocation fault; an existing target file without `force` is exit 2 (never a silent overwrite). Returns the + * written path + the loaded record (for an optional row-marking by the caller). + */ +export function exportSession(opts: ExportSessionOptions): ExportResult { + const loaded = opts.store.loadFull(opts.sessionId); + if (loaded === undefined) { + throw new CliError('invalid_invocation', `no session found with id ${opts.sessionId}`); + } + + const definition = sessionToWorkflow(loaded.session, loaded.messages); + const yaml = serializeWorkflow(definition); + + // Default the filename to the UNIQUE session id (collision-free), not `workflow.id` (the title-slug, which + // two untitled sessions share) — the in-file `workflow.id` stays the human-renameable title-slug. + const path = + opts.outPath === undefined + ? join(opts.cwd, `${opts.sessionId}.relavium.yaml`) + : resolve(opts.cwd, opts.outPath); + try { + // Create the parent directory (so `--out exports/wf.yaml` just works) before writing the scaffold. + mkdirSync(dirname(path), { recursive: true }); + // Atomic create: `wx` fails with EEXIST if the target already exists — no TOCTOU window between a + // separate existence check and the write; `w` truncates/overwrites under `--force`. + writeFileSync(path, yaml, { encoding: 'utf8', flag: opts.force ? 'w' : 'wx' }); + } catch (err) { + const code = + err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined; + // An existing target without `--force` (EEXIST) is the no-overwrite fault; a structurally-invalid + // `--out` (a directory → EISDIR; a file used as a dir → ENOTDIR) is also an INVOCATION fault (exit 2). + // Other write faults (permissions, disk) propagate. + if (code === 'EEXIST') { + throw new CliError( + 'invalid_invocation', + `${path} already exists — pass --force to overwrite`, + { cause: err }, + ); + } + if (code === 'EISDIR' || code === 'ENOTDIR') { + throw new CliError( + 'invalid_invocation', + `cannot write ${path}: the target path is not a file`, + { cause: err }, + ); + } + throw err; + } + + // The session is append-only; the next event/seq is one past the durable MAX (a fold, not a spread). + const sequenceNumber = + loaded.messages.reduce((max, m) => Math.max(max, m.sequenceNumber), -1) + 1; + return { path, workflowId: definition.workflow.id, sequenceNumber, record: loaded.session }; +} diff --git a/apps/cli/src/chat/fixture.test.ts b/apps/cli/src/chat/fixture.test.ts new file mode 100644 index 00000000..5c5ae2bd --- /dev/null +++ b/apps/cli/src/chat/fixture.test.ts @@ -0,0 +1,80 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { cassetteResolver, loadCassette, type Cassette } from './fixture.js'; + +const VALID: Cassette = { + schema_version: '1.0', + provider: 'anthropic', + model: 'claude-sonnet-4-6', + calls: [ + [ + { type: 'text_delta', text: 'hi' }, + { type: 'stop', stopReason: 'stop', usage: { inputTokens: 10, outputTokens: 5 } }, + ], + ], +}; + +describe('loadCassette + cassetteResolver (2.Q)', () => { + let cwd: string; + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'relavium-cassette-')); + }); + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + function write(name: string, content: string): string { + writeFileSync(join(cwd, name), content, 'utf8'); + return name; + } + + it('loads a valid cassette resolved relative to cwd', () => { + write('c.json', JSON.stringify(VALID)); + const cassette = loadCassette('c.json', cwd); + expect(cassette.provider).toBe('anthropic'); + expect(cassette.calls).toHaveLength(1); + }); + + it('loads a cassette without the optional `model` field (model is undefined)', () => { + write( + 'nm.json', + JSON.stringify({ + schema_version: VALID.schema_version, + provider: VALID.provider, + calls: VALID.calls, + }), + ); + expect(loadCassette('nm.json', cwd).model).toBeUndefined(); + }); + + it('rejects a missing file as a clean exit-2 fault', () => { + expect(() => loadCassette('nope.json', cwd)).toThrow(/cannot read fixture/); + }); + + it('rejects invalid JSON as a clean exit-2 fault', () => { + write('bad.json', '{ not json'); + expect(() => loadCassette('bad.json', cwd)).toThrow(/not valid JSON/); + }); + + it('rejects an unknown schema_version as a clean exit-2 fault', () => { + write('v.json', JSON.stringify({ ...VALID, schema_version: '2.0' })); + expect(() => loadCassette('v.json', cwd)).toThrow(/not a valid cassette/); + }); + + it('rejects a malformed StreamChunk as a clean exit-2 fault (boundary validation)', () => { + write('chunk.json', JSON.stringify({ ...VALID, calls: [[{ type: 'bogus_chunk' }]] })); + expect(() => loadCassette('chunk.json', cwd)).toThrow(/not a valid cassette/); + }); + + it('cassetteResolver answers ONLY the cassette provider and returns a non-secret key', () => { + const resolver = cassetteResolver(VALID); + expect(resolver.resolveProvider('openai')).toBeUndefined(); // not the cassette's provider + expect(resolver.resolveProvider('anthropic')).toBeDefined(); + expect(resolver.keyFor('anthropic')).toBe('fixture-key'); // offline marker, never a real key + // (Replay-order + the unscripted-call throw are exercised end-to-end by the agent-run integration tests.) + }); +}); diff --git a/apps/cli/src/chat/fixture.ts b/apps/cli/src/chat/fixture.ts new file mode 100644 index 00000000..ae7aebe5 --- /dev/null +++ b/apps/cli/src/chat/fixture.ts @@ -0,0 +1,117 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { + CapabilityFlagsSchema, + ProviderIdSchema, + StreamChunkSchema, + type CapabilityFlags, + type LlmProvider, + type StreamChunk, +} from '@relavium/llm'; +import { z } from 'zod'; + +import type { ProviderResolver } from '../engine/providers.js'; +import { CliError } from '../process/errors.js'; + +/** + * `agent run --fixture` cassette replay (2.Q) — the on-disk form of the in-memory `scriptedProvider`, so a + * one-shot `relavium agent run` is deterministic and fully offline (no key, no network). It is the CLI's + * small, dependency-free analogue of the `@relavium/llm` conformance replay; every recorded chunk is a + * Relavium-owned `StreamChunk`, never a vendor SDK shape (the seam holds, ADR-0011). The format is documented + * in [agent-run-fixture.md](../../../../docs/reference/cli/agent-run-fixture.md). + */ + +/** A cassette: the recorded `StreamChunk[]` per `provider.stream()` call, answered as `provider`. */ +export const CassetteSchema = z.object({ + schema_version: z.literal('1.0'), + provider: ProviderIdSchema, + model: z.string().optional(), + /** One entry per `stream()` call in the turn (call N → `calls[N]`); each is the ordered chunk list. */ + calls: z.array(z.array(StreamChunkSchema)), +}); +export type Cassette = z.infer; + +/** The replay provider's reported capabilities — permissive (text + tools + streaming) so the chain never pre-skips it. */ +const REPLAY_CAPABILITIES: CapabilityFlags = CapabilityFlagsSchema.parse({ + tools: true, + streaming: true, + parallelToolCalls: false, + vision: false, + promptCache: false, + reasoning: false, + media: { + input: { image: false, audio: false, video: false, document: false }, + outputCombinations: [['text']], + surface: 'chat', + }, +}); + +/** + * Read + validate a cassette from `fixturePath` (absolute, or relative to `cwd`). Bad JSON, an unknown + * `schema_version`, or a chunk that fails `StreamChunkSchema` is a clean exit-2 invocation fault — never a + * raw crash. The chunk validation is the boundary guarantee that a malformed cassette cannot reach the engine. + */ +export function loadCassette(fixturePath: string, cwd: string): Cassette { + const path = resolve(cwd, fixturePath); + let raw: string; + try { + raw = readFileSync(path, 'utf8'); + } catch (err) { + throw new CliError('invalid_invocation', `cannot read fixture ${path}`, { cause: err }); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new CliError('invalid_invocation', `fixture ${path} is not valid JSON`, { cause: err }); + } + const result = CassetteSchema.safeParse(parsed); + if (!result.success) { + throw new CliError( + 'invalid_invocation', + `fixture ${path} is not a valid cassette: ${result.error.issues[0]?.message ?? 'schema mismatch'}`, + ); + } + return result.data; +} + +async function* streamOf(chunks: readonly StreamChunk[]): AsyncGenerator { + await Promise.resolve(); + for (const chunk of chunks) yield chunk; +} + +/** + * A replay {@link LlmProvider} over a cassette: call N of `stream()` replays `cassette.calls[N]`. An + * unscripted call throws (an extra LLM invocation is a fixture/agent mismatch, never a silent empty turn). + * `generate` is never used (the session path streams); the key is a fixed non-secret marker (offline). + */ +export function cassetteProvider(cassette: Cassette): LlmProvider { + let call = 0; + return { + id: cassette.provider, + supports: REPLAY_CAPABILITIES, + generate: () => { + throw new Error('agent run --fixture replays the streaming path, not generate()'); + }, + stream: () => { + const chunks = cassette.calls[call]; + call += 1; + if (chunks === undefined) { + throw new Error( + `fixture cassette: unexpected stream call #${call} (only ${cassette.calls.length} recorded)`, + ); + } + return streamOf(chunks); + }, + }; +} + +/** A {@link ProviderResolver} that answers the cassette's `provider` with the replay provider; a fixed dummy key. */ +export function cassetteResolver(cassette: Cassette): ProviderResolver { + const provider = cassetteProvider(cassette); + return { + resolveProvider: (id) => (id === cassette.provider ? provider : undefined), + keyFor: () => 'fixture-key', + }; +} diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index 42dc47e9..937af31d 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -1,9 +1,16 @@ import { BudgetExceededError, BudgetPauseError } from '@relavium/core'; import type { SessionStreamHandleEvent } from '@relavium/core'; +import type { AgentSessionRecord, SessionMessage } from '@relavium/shared'; import { describe, expect, it } from 'vitest'; import type { ResolvedChatConfig } from '../config/resolve.js'; -import { buildChatSession, buildGovernorWiring, type ChatBudgetWarning } from './session-host.js'; +import { buildDefaultChatAgent } from './default-agent.js'; +import { + buildChatSession, + buildGovernorWiring, + buildResumedChatSession, + type ChatBudgetWarning, +} from './session-host.js'; import { drainHandle, scriptedResolver, @@ -132,6 +139,150 @@ describe('buildChatSession', () => { }); }); +describe('buildResumedChatSession (2.N)', () => { + const RESUME_AGENT = buildDefaultChatAgent('claude-sonnet-4-6'); + const ISO = '2026-06-25T00:00:00.000Z'; + + const message = (seq: number, role: 'user' | 'assistant', text: string): SessionMessage => ({ + id: `m${seq}`, + sessionId: 'sess-r', + sequenceNumber: seq, + role, + content: [{ type: 'text', text }], + timestamp: ISO, + }); + + const record = (overrides: Partial = {}): AgentSessionRecord => ({ + id: 'sess-r', + agentSlug: RESUME_AGENT.id, + agentSnapshot: RESUME_AGENT, + context: { workingDir: '/workspace', fsScopeTier: 'project' }, + status: 'ended', + totalInputTokens: 10, + totalOutputTokens: 5, + totalCostMicrocents: 1234, + createdAt: ISO, + updatedAt: ISO, + ...overrides, + }); + + function resume(messages: readonly SessionMessage[], rec: AgentSessionRecord = record()) { + return buildResumedChatSession({ + chat: EMPTY_CHAT, + record: rec, + messages, + now: () => Date.parse(ISO), + providers: scriptedResolver([textTurn('continued')]), + }); + } + + it('rebinds the frozen agent + context and reconstructs the carried-over state', () => { + const built = resume([message(0, 'user', 'hi'), message(1, 'assistant', 'hello')]); + expect(built.sessionId).toBe('sess-r'); + expect(built.agent.id).toBe(RESUME_AGENT.id); + expect(built.agent.model).toBe('claude-sonnet-4-6'); + expect(built.context.fsScopeTier).toBe('project'); // the frozen context tier, not the chat default + expect(built.resumeState.turnCount).toBe(1); // one completed exchange + expect(built.resumeState.cumulativeCostMicrocents).toBe(1234); // carried from the record + // The persister continues PAST the persisted MAX(sequence_number) = 1. + expect(built.nextSequenceNumber).toBe(2); + }); + + it('continues a transcript whose last persisted seq is computed from the MAX (order-independent)', () => { + // Rows passed out of order; nextSequenceNumber must be MAX+1, not last-element+1. + const built = resume([message(1, 'assistant', 'hello'), message(0, 'user', 'hi')]); + expect(built.nextSequenceNumber).toBe(2); + }); + + it('computes nextSequenceNumber from the MAX, not the row COUNT (a gapped transcript)', () => { + // A gapped transcript (seq 0,1,5 — length 3 but MAX 5) pins MAX+1 semantics: a count-based bug + // (`messages.length`) would yield 3 and collide; the correct answer is 6. + const built = resume([ + message(0, 'user', 'hi'), + message(1, 'assistant', 'hello'), + message(5, 'user', 'later'), + ]); + expect(built.nextSequenceNumber).toBe(6); + }); + + it('rolls back a trailing unanswered user turn but still continues past its durable seq', () => { + // A session interrupted after the user typed (assistant never replied): the durable transcript ends in a + // dangling user row (seq 2). reconstruct rolls it back (so the model is not re-sent two user turns), but + // the persister must still continue PAST that durable row (seq 3), never overwrite it. + const built = resume([ + message(0, 'user', 'hi'), + message(1, 'assistant', 'hello'), + message(2, 'user', 'dangling'), + ]); + expect(built.resumeState.turnCount).toBe(1); // only the one completed exchange survives projection + expect(built.resumeState.messages.at(-1)?.role).toBe('assistant'); // the dangling user turn is trimmed + expect(built.nextSequenceNumber).toBe(3); // continues past the durable orphan row, not over it + }); + + it('starts a never-messaged session at sequence 0', () => { + const built = resume([]); + expect(built.nextSequenceNumber).toBe(0); + expect(built.resumeState.turnCount).toBe(0); + }); + + it('the resumed session lands at idle and continues WITHOUT re-emitting session:started', async () => { + const built = resume([message(0, 'user', 'hi'), message(1, 'assistant', 'hello')]); + // No start() — AgentSession.resume already landed at idle; sendMessage continues the conversation. + await built.session.sendMessage('again'); + built.session.cancel(); + const events = await drainHandle(built.handle.events); + + const types = events.map((e) => e.type); + expect(types).not.toContain('session:started'); // resume must not double the lifecycle-open event + expect(types).toContain('session:turn_completed'); + const tokens = events.flatMap((e) => (e.type === 'agent:token' ? [e.token] : [])).join(''); + expect(tokens).toContain('continued'); + }); + + it('rebinds the agent from the SNAPSHOT, not the record agentSlug (which may diverge after a rename)', async () => { + // agentSlug diverges from the snapshot id; the resumed session must bind the SNAPSHOT's id, and the + // live events' nodeId (= agentRef = agent.id) must follow the snapshot — not the stale slug. + const built = resume( + [message(0, 'user', 'hi'), message(1, 'assistant', 'hello')], + record({ agentSlug: 'old-slug' }), + ); + expect(built.agent.id).toBe(RESUME_AGENT.id); // 'relavium-chat' from the snapshot, not 'old-slug' + + await built.session.sendMessage('go'); + built.session.cancel(); + const events = await drainHandle(built.handle.events); + const token = events.find((e) => e.type === 'agent:token'); + expect(token?.type === 'agent:token' && token.nodeId).toBe(RESUME_AGENT.id); + }); + + it('seeds the budget governor with the carried cost — the first resumed turn trips the cap pre-egress', async () => { + // A near-exhausted record (totalCostMicrocents far past a 1µ¢ cap): AgentSession.resume seeds the governor + // via updateCost(carried), so the FIRST resumed turn's pre-egress check trips BEFORE any new cost:updated. + // Without that seeding the carried spend would be invisible and the first turn would slip past the cap. + const built = buildResumedChatSession({ + chat: { ...EMPTY_CHAT, maxCostMicrocents: 1, onExceed: 'fail' }, + record: record({ totalCostMicrocents: 999_999 }), + messages: [message(0, 'user', 'hi'), message(1, 'assistant', 'hello')], + now: () => Date.parse(ISO), + providers: scriptedResolver([textTurn('should never stream')]), + }); + await built.session.sendMessage('again'); + built.session.cancel(); + const events = await drainHandle(built.handle.events); + + const errorCodes = events.flatMap((e) => + e.type === 'session:turn_completed' && e.error !== undefined ? [e.error.code] : [], + ); + expect(errorCodes).toContain('budget_exceeded'); // tripped on the carried cost, no provider call + }); + + it('rejects a record with no stored agent snapshot as a clean exit-2 invocation fault', () => { + expect(() => resume([], record({ agentSnapshot: undefined }))).toThrow( + /no stored agent snapshot/, + ); + }); +}); + describe('buildGovernorWiring', () => { // Seed the governor's cumulative directly via updateCost so the pre-egress projection trips the cap // regardless of model pricing — exercising the real fail/pause/warn behavior, not just the wiring shape. diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index bddf3e4a..c993378b 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -6,15 +6,19 @@ import { createSessionEventSink, createSessionHandle, createToolRegistry, + reconstructSessionState, type AgentDefinition, type SessionDeps, + type SessionEventSink, type SessionHandle, + type SessionResumeState, type ToolHost, } from '@relavium/core'; -import type { Budget, SessionContext } from '@relavium/shared'; +import type { AgentSessionRecord, Budget, SessionContext, SessionMessage } from '@relavium/shared'; import type { ResolvedChatConfig } from '../config/resolve.js'; import { createProviderResolver, type ProviderResolver } from '../engine/providers.js'; +import { CliError } from '../process/errors.js'; import { resolveChatAgent } from './agent-source.js'; /** @@ -45,6 +49,11 @@ export interface BuildChatSessionOptions { readonly providers?: ProviderResolver; /** The tool-execution host (injectable for tests); defaults to fail-closed `{}` (capabilities are a follow-up). */ readonly toolHost?: ToolHost; + /** + * Session-scoped `{{ctx.*}}` variables (plaintext, NO secrets — agent-session-spec.md §Tools). `relavium + * agent run --input k=v` (2.Q) populates these; a bare `chat` leaves them unset. + */ + readonly variables?: Record; /** * Sink for an `on_exceed: 'warn'` pre-egress budget warning. A session has no `budget:warning` event in * its namespace, so the surface (the REPL) is the warning channel — the command wires this to surface a @@ -68,29 +77,41 @@ export interface BuiltChatSession { readonly agent: AgentDefinition; /** The frozen session context (working dir + fs-scope tier) the session ran against. */ readonly context: SessionContext; + /** + * Push a SURFACE-originated session event onto the same per-session bus (so it shares the monotonic + * `sequenceNumber` of the live stream). Used by the in-REPL `/export` to emit `session:exported` under + * `--json`; the bus stamps the `sessionId`/`sequenceNumber`/`timestamp`. + */ + readonly emitSessionEvent: SessionEventSink; } /** The safe default filesystem tier when `[chat].fs_scope` is unset (mirrors the workflow default). */ const DEFAULT_FS_SCOPE = 'sandboxed' as const; -export function buildChatSession(opts: BuildChatSessionOptions): BuiltChatSession { - const sessionId = opts.uuid(); - const agent = resolveChatAgent(opts.agentRef, { - cwd: opts.cwd, - projectConfigDir: opts.projectConfigDir, - defaultModel: opts.chat.defaultModel, - }); - const context: SessionContext = { - workingDir: opts.cwd, - fsScopeTier: opts.chat.fsScope ?? DEFAULT_FS_SCOPE, - }; +/** The fields {@link buildSessionRuntime} reads — the platform-capability inputs shared by a fresh + resumed session. */ +type SessionRuntimeOptions = Pick< + BuildChatSessionOptions, + 'chat' | 'now' | 'providers' | 'toolHost' | 'onBudgetWarning' +>; - // A fresh bus per session: the sink attaches the sessionId, the bus stamps the per-session sequenceNumber, - // and the handle scopes its stream to this sessionId (ADR-0036 one-bus-two-namespaces). +/** + * Build the per-session platform-capability runtime — a fresh `RunEventBus` (the sink attaches the sessionId, + * the bus stamps the per-session sequenceNumber, the handle scopes its stream to it; ADR-0036 + * one-bus-two-namespaces) and the {@link SessionDeps} (provider seam, tool registry, the hard turn cap, and — + * when a cost cap is configured — the ADR-0028 pre-egress governor). Shared by {@link buildChatSession} (fresh) + * and {@link buildResumedChatSession} (2.N resume) so the two paths can never wire different capabilities. + */ +function buildSessionRuntime( + opts: SessionRuntimeOptions, + sessionId: string, +): { bus: RunEventBus; deps: SessionDeps; emit: SessionEventSink } { const bus = new RunEventBus({ now: () => new Date(opts.now()).toISOString() }); const providers = opts.providers ?? createProviderResolver(); const registry = createToolRegistry({ tools: BUILTIN_TOOLS, host: opts.toolHost ?? {} }); const governor = buildGovernorWiring(opts.chat, opts.onBudgetWarning); + // The session event sink (1.W): a draft → bus → stamped sequenceNumber/timestamp. Hoisted so a SURFACE + // event (the in-REPL `/export`'s `session:exported`, 2.Q) can ride the same monotonic per-session counter. + const emit = createSessionEventSink(bus, sessionId); const deps: SessionDeps = { resolveProvider: providers.resolveProvider, @@ -101,7 +122,7 @@ export function buildChatSession(opts: BuildChatSessionOptions): BuiltChatSessio now: opts.now, // Node's AbortController satisfies the engine's structural AbortControllerLike (abort() + signal). newAbortController: () => new AbortController(), - emit: createSessionEventSink(bus, sessionId), + emit, // No toolPolicy ⇒ the AgentSession default `{}` applies: gated tools deny-all and `run_command` is // disabled (empty allowedCommands). A standalone chat has no workflow allowedCommands to inherit, so // empty is the secure default (config-spec.md `[chat]` "empty/absent ⇒ run_command disabled"). @@ -110,10 +131,104 @@ export function buildChatSession(opts: BuildChatSessionOptions): BuiltChatSessio ? {} : { preEgress: governor.preEgress, updateCost: governor.updateCost }), }; + return { bus, deps, emit }; +} + +export function buildChatSession(opts: BuildChatSessionOptions): BuiltChatSession { + const sessionId = opts.uuid(); + const agent = resolveChatAgent(opts.agentRef, { + cwd: opts.cwd, + projectConfigDir: opts.projectConfigDir, + defaultModel: opts.chat.defaultModel, + }); + const context: SessionContext = { + workingDir: opts.cwd, + fsScopeTier: opts.chat.fsScope ?? DEFAULT_FS_SCOPE, + ...(opts.variables === undefined ? {} : { variables: opts.variables }), + }; + const { bus, deps, emit } = buildSessionRuntime(opts, sessionId); const session = new AgentSession({ sessionId, agentRef: agent.id, agent, context, deps }); const handle = createSessionHandle(bus, sessionId, () => session.cancel()); - return { session, handle, sessionId, agent, context }; + return { session, handle, sessionId, agent, context, emitSessionEvent: emit }; +} + +/** A resumed session (2.N) plus the two extra facts the REPL needs: the reconstructed state + the next seq. */ +export interface BuiltResumedChatSession extends BuiltChatSession { + /** The reconstructed in-flight state the view seeds from (carried cost + prior completed-turn count). */ + readonly resumeState: SessionResumeState; + /** + * The first `sequenceNumber` the persister assigns to a new message — past the persisted MAX so a continued + * session does not collide on the `(session_id, sequence_number)` UNIQUE index. + */ + readonly nextSequenceNumber: number; +} + +export interface BuildResumedChatSessionOptions { + /** The resolved `[chat]` block (turn cap, cost cap) — applied to the resumed session's deps. */ + readonly chat: ResolvedChatConfig; + /** The loaded session record (its frozen `agentSnapshot` + `context` rebind the session). */ + readonly record: AgentSessionRecord; + /** The session's persisted transcript, in any order ({@link reconstructSessionState} sorts it). */ + readonly messages: readonly SessionMessage[]; + /** + * Wall-clock in ms (injectable for tests) — feeds the bus + the chain clock. It clocks ONLY the continued + * turn(s); the carried-over rows keep their original persisted timestamps, so a post-resume `history.db` + * shows an expected time discontinuity at the resume boundary. + */ + readonly now: () => number; + /** The provider seam (injectable for tests); defaults to the env/keychain resolver. */ + readonly providers?: ProviderResolver; + /** The tool-execution host (injectable for tests); defaults to fail-closed `{}`. */ + readonly toolHost?: ToolHost; + /** Sink for an `on_exceed: 'warn'` pre-egress budget warning (see {@link BuildChatSessionOptions}). */ + readonly onBudgetWarning?: (warning: ChatBudgetWarning) => void; +} + +/** + * Assemble a RESUMED `relavium chat` session (2.N) over `AgentSession.resume`: rebind the session's frozen + * agent + context from the loaded record, reconstruct its in-flight state from the persisted transcript + * ({@link reconstructSessionState} — text-only, with a trailing unanswered turn rolled back), and wire the + * SAME platform-capability runtime a fresh session uses. The resumed session lands directly at idle and does + * NOT re-emit `session:started`; the next `sendMessage` continues the conversation. A session with no stored + * `agentSnapshot` cannot be rebound and is a clean invalid invocation (exit 2). + */ +export function buildResumedChatSession( + opts: BuildResumedChatSessionOptions, +): BuiltResumedChatSession { + const { record, messages } = opts; + const agent = record.agentSnapshot; + if (agent === undefined) { + throw new CliError( + 'invalid_invocation', + `session ${record.id} has no stored agent snapshot and cannot be resumed`, + ); + } + const context = record.context; + const resumeState = reconstructSessionState(record, messages); + + const { bus, deps, emit } = buildSessionRuntime(opts, record.id); + const session = AgentSession.resume( + { sessionId: record.id, agentRef: agent.id, agent, context, deps }, + resumeState, + ); + const handle = createSessionHandle(bus, record.id, () => session.cancel()); + // Seed the persister one past the persisted MAX(sequence_number) — a fold (not `Math.max(...spread)`, which + // would overflow the argument-count limit on a very long transcript) over the durable rows, so it is + // order-independent and starts an empty transcript at 0 (reduce of `[]` from -1, +1 = 0). NOTE: this is a + // single-writer assumption — the next seq is read at load time, so two concurrent resumes of the SAME + // session would collide on the `(session_id, sequence_number)` UNIQUE index (a loud failure, not corruption). + const nextSequenceNumber = messages.reduce((max, m) => Math.max(max, m.sequenceNumber), -1) + 1; + return { + session, + handle, + sessionId: record.id, + agent, + context, + emitSessionEvent: emit, + resumeState, + nextSequenceNumber, + }; } export interface GovernorWiring { diff --git a/apps/cli/src/commands/agent-run.test.ts b/apps/cli/src/commands/agent-run.test.ts new file mode 100644 index 00000000..8d056d01 --- /dev/null +++ b/apps/cli/src/commands/agent-run.test.ts @@ -0,0 +1,230 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { scriptedResolver, textTurn, unresolvedResolver } from '../chat/test-support.js'; +import type { ProviderResolver } from '../engine/providers.js'; +import { isCliError } from '../process/errors.js'; +import { EXIT_CODES } from '../process/exit-codes.js'; +import type { GlobalOptions } from '../process/options.js'; +import { captureIo, parseNdjson } from '../test-support.js'; +import { agentRunCommand, readAllStdin, type AgentRunCommandDeps } from './agent-run.js'; + +const AGENT_YAML = + 'id: coder\nprovider: anthropic\nmodel: claude-sonnet-4-6\nsystem_prompt: You are a coder.\ntools:\n - read_file'; +const CASSETTE = { + schema_version: '1.0', + provider: 'anthropic', + model: 'claude-sonnet-4-6', + calls: [ + [ + { type: 'text_delta', text: 'cassette reply' }, + { type: 'stop', stopReason: 'stop', usage: { inputTokens: 10, outputTokens: 5 } }, + ], + ], +}; +const HOME_ENV_VARS = ['HOME', 'USERPROFILE'] as const; + +function globalOptions(cwd: string, json = false): GlobalOptions { + return { json, color: false, cwd, configPath: undefined, verbosity: 'normal' }; +} + +describe('agentRunCommand (2.Q)', () => { + let cwd: string; + let home: string; + const savedHome = new Map(); + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'relavium-agentrun-cwd-')); + home = mkdtempSync(join(tmpdir(), 'relavium-agentrun-home-')); + for (const v of HOME_ENV_VARS) { + savedHome.set(v, process.env[v]); + process.env[v] = home; + } + writeFileSync(join(cwd, 'coder.agent.yaml'), AGENT_YAML); + }); + afterEach(() => { + for (const v of HOME_ENV_VARS) { + const prev = savedHome.get(v); + if (prev === undefined) delete process.env[v]; + else process.env[v] = prev; + } + rmSync(cwd, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + }); + + function deps( + stdin: string, + opts: { json?: boolean; providers?: ProviderResolver } = {}, + ): { d: AgentRunCommandDeps; out: () => string; err: () => string } { + const { io, out, err } = captureIo(); + return { + d: { + io: { ...io, stdin: Readable.from([stdin]) }, + global: globalOptions(cwd, opts.json ?? false), + now: () => 0, + uuid: () => 'a-0', + ...(opts.providers === undefined ? {} : { providers: opts.providers }), + }, + out, + err, + }; + } + + const agentPath = (): string => join(cwd, 'coder.agent.yaml'); + + it('runs one turn from a stdin prompt and prints the reply (exit 0)', async () => { + const { d, out } = deps('summarize this', { + providers: scriptedResolver([textTurn('the summary')]), + }); + expect(await agentRunCommand({ agent: agentPath(), input: [] }, d)).toBe(EXIT_CODES.success); + expect(out()).toContain('the summary'); + }); + + it('emits a pure NDJSON session stream under --json (no human chrome, no key leak)', async () => { + const { d, out, err } = deps('hi', { + json: true, + providers: scriptedResolver([textTurn('reply')]), + }); + expect(await agentRunCommand({ agent: agentPath(), input: [] }, d)).toBe(EXIT_CODES.success); + // Every stdout line is a valid SessionEvent object (parseNdjson throws on a leaked human line). + const types = parseNdjson<{ type: string }>(out()).map((e) => e.type); + expect(types[0]).toBe('session:started'); // the subscription is wired before start() — first line + expect(types).toContain('session:turn_completed'); + expect(types.at(-1)).toBe('session:cancelled'); // the finally's cancel() flushes the terminal BEFORE unsubscribe + expect(err()).not.toContain('session:'); // no event leaks onto stderr + expect(out()).not.toContain('test-key'); // the dummy provider key never reaches the stream + }); + + it('--json + a tool-calling --fixture cassette replays the tool loop, offline, with no key leak', async () => { + // A 2-call cassette: a tool_use turn (forcing a 2nd stream() call) then the text turn. Drives the full + // replay through the fail-closed tool host; the NDJSON stream carries agent:tool_call and the answer. + const toolCassette = { + schema_version: '1.0', + provider: 'anthropic', + model: 'claude-sonnet-4-6', + calls: [ + [ + { type: 'tool_call_start', id: 'tc-1', name: 'read_file' }, + { type: 'tool_call_end', id: 'tc-1' }, + { type: 'stop', stopReason: 'tool_use', usage: { inputTokens: 4, outputTokens: 2 } }, + ], + [ + { type: 'text_delta', text: 'the answer' }, + { type: 'stop', stopReason: 'stop', usage: { inputTokens: 5, outputTokens: 3 } }, + ], + ], + }; + writeFileSync(join(cwd, 'tool.json'), JSON.stringify(toolCassette)); + const { d, out } = deps('read it', { json: true }); // no providers ⇒ pure offline cassette path + expect(await agentRunCommand({ agent: agentPath(), input: [], fixture: 'tool.json' }, d)).toBe( + EXIT_CODES.success, + ); + const types = parseNdjson<{ type: string }>(out()).map((e) => e.type); + expect(types).toContain('agent:tool_call'); // the recorded tool call is replayed onto the stream + expect(types).toContain('session:turn_completed'); + expect(out()).not.toContain('fixture-key'); // the cassette's offline marker never reaches the stream + }); + + it('--fixture takes precedence over an injected resolver (the cassette wins)', async () => { + writeFileSync(join(cwd, 'c.json'), JSON.stringify(CASSETTE)); + // Pass BOTH a fixture and an injected resolver; the cassette must win (offline determinism beats the seam). + const { d, out } = deps('hi', { providers: scriptedResolver([textTurn('INJECTED')]) }); + expect(await agentRunCommand({ agent: agentPath(), input: [], fixture: 'c.json' }, d)).toBe( + EXIT_CODES.success, + ); + expect(out()).toContain('cassette reply'); + expect(out()).not.toContain('INJECTED'); + }); + + it('maps an under-recorded cassette (an unscripted stream call) to exit 1, never a crash', async () => { + // A cassette with zero recorded calls: the first turn's stream() is unscripted ⇒ the replay throws ⇒ the + // chain classifies it into a turn error ⇒ the command RESOLVES to exit 1 (it must not reject/crash). + writeFileSync(join(cwd, 'empty.json'), JSON.stringify({ ...CASSETTE, calls: [] })); + const { d } = deps('hi', {}); + expect(await agentRunCommand({ agent: agentPath(), input: [], fixture: 'empty.json' }, d)).toBe( + EXIT_CODES.workflowFailed, + ); + }); + + it('replays a --fixture cassette deterministically with NO providers injected (offline)', async () => { + writeFileSync(join(cwd, 'c.json'), JSON.stringify(CASSETTE)); + const { d, out } = deps('anything', {}); // no providers ⇒ the cassette resolver is built from the file + expect(await agentRunCommand({ agent: agentPath(), input: [], fixture: 'c.json' }, d)).toBe( + EXIT_CODES.success, + ); + expect(out()).toContain('cassette reply'); // the recorded chunks were replayed + }); + + it('maps a turn failure to exit 1 (the turn outcome, not the command shell)', async () => { + const { d } = deps('hi', { providers: unresolvedResolver() }); // every turn settles as an internal error + expect(await agentRunCommand({ agent: agentPath(), input: [] }, d)).toBe( + EXIT_CODES.workflowFailed, + ); + }); + + it('flushes session:cancelled as the LAST --json event even on a turn error (terminal before unsubscribe)', async () => { + // A failing turn under --json must still terminate the NDJSON stream: the finally's cancel() runs before + // unsubscribe, so the recorded error event rides the stream and session:cancelled is the final line. + const { d, out, err } = deps('hi', { json: true, providers: unresolvedResolver() }); + expect(await agentRunCommand({ agent: agentPath(), input: [] }, d)).toBe( + EXIT_CODES.workflowFailed, + ); + const types = parseNdjson<{ type: string }>(out()).map((e) => e.type); + expect(types).toContain('session:turn_completed'); // the classified error rides the stream as an event + expect(types.at(-1)).toBe('session:cancelled'); // the terminal is the last line, not dropped + expect(err()).not.toContain('turn failed:'); // under --json the human stderr detail stays suppressed + }); + + it('rejects --input as not-yet-supported (session prompt interpolation is a pending engine change)', async () => { + // --input is RESERVED: a session does not interpolate {{ctx.*}} into the prompt yet, so exposing it as a + // working flag would mislead. It fails loud (exit 2) — before reading stdin — until interpolation lands. + const { d } = deps('hi', { providers: scriptedResolver([textTurn('x')]) }); + await expect( + agentRunCommand({ agent: agentPath(), input: ['file=./x.ts'] }, d), + ).rejects.toThrow(/`--input` is not supported yet/); + }); + + it('rejects an empty stdin prompt as a clean exit-2 fault', async () => { + const { d } = deps(' ', { providers: scriptedResolver([]) }); + await expect(agentRunCommand({ agent: agentPath(), input: [] }, d)).rejects.toThrow( + /no input message/, + ); + }); + + it('rejects an unknown agent as a clean exit-2 (typed invalid_invocation) fault', async () => { + const { d } = deps('hi', { providers: scriptedResolver([textTurn('x')]) }); + let thrown: unknown; + await agentRunCommand({ agent: join(cwd, 'ghost.agent.yaml'), input: [] }, d).catch((e) => { + thrown = e; + }); + expect(isCliError(thrown)).toBe(true); // a typed CliError (exit 2), not a raw provider/parse crash + expect(isCliError(thrown) && thrown.code).toBe('invalid_invocation'); // pins exit 2, not exit 1 + }); + + it('rejects a bad --fixture cassette as a clean exit-2 fault', async () => { + writeFileSync(join(cwd, 'bad.json'), '{ not json'); + const { d } = deps('hi', {}); + await expect( + agentRunCommand({ agent: agentPath(), input: [], fixture: 'bad.json' }, d), + ).rejects.toThrow(/not valid JSON/); + }); +}); + +describe('readAllStdin (2.Q)', () => { + it('decodes a multi-byte UTF-8 character split ACROSS a chunk boundary (StringDecoder buffering)', async () => { + // Split the UTF-8 bytes of a multi-byte string mid-character into two Buffers; a per-chunk decode would + // mangle the boundary char into replacement chars (�), the StringDecoder buffers it across writes. + const bytes = Buffer.from('şağ🚀', 'utf8'); + const mid = Math.floor(bytes.length / 2); + const stream = Readable.from([bytes.subarray(0, mid), bytes.subarray(mid)]); + expect(await readAllStdin(stream)).toBe('şağ🚀'); + }); + + it('reads a string-yielding stream (the test fallback) verbatim', async () => { + expect(await readAllStdin(Readable.from(['hel', 'lo']))).toBe('hello'); + }); +}); diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts new file mode 100644 index 00000000..e5ed72ca --- /dev/null +++ b/apps/cli/src/commands/agent-run.ts @@ -0,0 +1,139 @@ +import { randomUUID } from 'node:crypto'; +import { StringDecoder } from 'node:string_decoder'; + +import type { SessionStreamHandleEvent } from '@relavium/core'; + +import { cassetteResolver, loadCassette } from '../chat/fixture.js'; +import { buildChatSession } from '../chat/session-host.js'; +import { loadResolvedConfig } from '../config/load.js'; +import { createProviderResolver, type ProviderResolver } from '../engine/providers.js'; +import { CliError } from '../process/errors.js'; +import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; +import type { CliIo } from '../process/io.js'; +import type { GlobalOptions } from '../process/options.js'; +import { makePlainPrinter } from './chat.js'; + +/** + * `relavium agent run ` (2.Q) — invoke a single agent **one-shot** (non-interactive) on the same + * `AgentSession` infra: a chat session with one turn, then exit. The user prompt is read from **stdin** (the + * `echo … | relavium agent run` idiom); `--fixture ` replays a recorded cassette so the run is + * deterministic + offline. `--json` emits the NDJSON `session:*` stream; otherwise the assistant reply streams + * in human form. Unlike the REPL it is NOT persisted (a stateless invoke). Exit: the turn's outcome — `0` on + * success, `1` on a turn error; an invocation fault (no prompt / unknown agent / bad cassette / `--input`) is + * `2`. `--input` is **reserved** — rejected until session `{{ctx.*}}` prompt interpolation lands (deferred-tasks.md). + */ + +export interface AgentRunCommandArgs { + /** `` (required) — a `.agent.yaml` path or a `.relavium/` agent id. */ + readonly agent: string; + /** `--input k=v` (repeatable) — RESERVED; currently rejected (session prompt interpolation is a pending engine change). */ + readonly input: readonly string[]; + /** `--fixture ` — replay a recorded LLM cassette (deterministic, offline). */ + readonly fixture?: string; +} + +export interface AgentRunCommandDeps { + readonly io: CliIo; + readonly global: GlobalOptions; + /** Injectable provider seam (tests). Ignored when `--fixture` is given (the cassette resolver always wins). */ + readonly providers?: ProviderResolver; + /** Injectable session builder (tests). Default {@link buildChatSession}. */ + readonly buildSession?: typeof buildChatSession; + readonly now?: () => number; + readonly uuid?: () => string; +} + +export async function agentRunCommand( + args: AgentRunCommandArgs, + deps: AgentRunCommandDeps, +): Promise { + const now = deps.now ?? Date.now; + const uuid = deps.uuid ?? randomUUID; + const { config, projectConfigDir } = loadResolvedConfig({ + cwd: deps.global.cwd, + configPath: deps.global.configPath, + }); + + // `--input k=v` is REJECTED for now: a session does not yet interpolate `{{ctx.*}}` into the agent prompt + // (the engine passes `system_prompt` verbatim; wiring `resolveTemplate` into the session turn core is a + // deferred, security-relevant change — it would also throw on existing prompts' unresolved placeholders). + // Exposing an inert flag is misleading, so fail loud until the interpolation wiring lands. *(deferred-tasks.md)* + if (args.input.length > 0) { + throw new CliError( + 'invalid_invocation', + '`--input` is not supported yet — a session does not interpolate {{ctx.*}} into the agent prompt (a tracked engine follow-up). Omit it for now.', + ); + } + + // The one-shot prompt is the piped stdin; an empty stdin is a clean invocation fault (nothing to run). + const message = (await readAllStdin(deps.io.stdin)).trim(); + if (message.length === 0) { + throw new CliError( + 'invalid_invocation', + 'no input message — pipe the prompt on stdin (e.g. `echo "…" | relavium agent run `)', + ); + } + + // A `--fixture` replays a cassette (offline, no keychain) and takes precedence over any injected/real seam; + // otherwise tests inject `providers`, and production resolves keys via the env/keychain (like `relavium run`). + const providers = + args.fixture === undefined + ? (deps.providers ?? createProviderResolver(deps.io.env)) + : cassetteResolver(loadCassette(args.fixture, deps.global.cwd)); + + // An unknown `` (path or id) throws a typed CliError here (exit 2), before any turn. + const built = (deps.buildSession ?? buildChatSession)({ + chat: config.chat, + agentRef: args.agent, + cwd: deps.global.cwd, + projectConfigDir, + now, + uuid, + providers, + }); + + // Render the live stream (NDJSON under --json, else the plain token/tool printer) and capture the turn + // outcome — a classified turn failure completes with `session:turn_completed.error`, mapping to exit 1. + let turnErrorCode: string | undefined; + const renderer: (event: SessionStreamHandleEvent) => void = deps.global.json + ? (event) => deps.io.writeOut(`${JSON.stringify(event)}\n`) + : makePlainPrinter(deps.io); + const unsubscribe = built.handle.subscribe((event) => { + renderer(event); + if (event.type === 'session:turn_completed' && event.error !== undefined) { + turnErrorCode = event.error.code; + } + }); + + try { + built.session.start(); + await built.session.sendMessage(message); + } catch (err) { + // An UNCLASSIFIED turn error re-raised by the turn core (e.g. an under-recorded `--fixture` cassette whose + // next `stream()` call is unscripted) rejects `sendMessage`. Map it to a clean exit 1 here rather than + // letting a raw rejection surface as an opaque boundary "internal error". The detail goes to stderr (never + // a stack as primary output); under --json the failing `session:turn_completed.error` is already on stdout. + turnErrorCode ??= 'internal'; + if (!deps.global.json) { + deps.io.writeErr(`turn failed: ${err instanceof Error ? err.message : String(err)}\n`); + } + } finally { + built.session.cancel(); // the session's terminal (session:cancelled) — closes the one-shot cleanly + unsubscribe(); + } + return turnErrorCode === undefined ? EXIT_CODES.success : EXIT_CODES.workflowFailed; +} + +/** Read the whole input stream to EOF as UTF-8 text (the one-shot prompt). Exported for a focused unit test. */ +export async function readAllStdin(stream: NodeJS.ReadableStream): Promise { + // Decode binary chunks through a StringDecoder so a multi-byte UTF-8 character split ACROSS a chunk boundary + // is buffered (not mangled into replacement chars); `decoder.end()` flushes any trailing partial sequence. A + // test stream yields strings (a Buffer is itself a Uint8Array subclass), handled by the String fallback. + const decoder = new StringDecoder('utf8'); + let data = ''; + for await (const chunk of stream) { + data += chunk instanceof Uint8Array ? decoder.write(Buffer.from(chunk)) : String(chunk); + } + data += decoder.end(); + return data; +} diff --git a/apps/cli/src/commands/chat-export.test.ts b/apps/cli/src/commands/chat-export.test.ts new file mode 100644 index 00000000..7a259d50 --- /dev/null +++ b/apps/cli/src/commands/chat-export.test.ts @@ -0,0 +1,195 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { createClient, createSessionStore, runMigrations, type DbClient } from '@relavium/db'; +import { AgentSchema, type AgentSessionRecord, type SessionMessage } from '@relavium/shared'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { OpenedSessionStore } from '../history/session-open.js'; +import { EXIT_CODES } from '../process/exit-codes.js'; +import type { GlobalOptions } from '../process/options.js'; +import { captureIo, parseNdjson } from '../test-support.js'; +import { chatExportCommand, type ChatExportCommandDeps } from './chat-export.js'; + +const ISO = '2026-06-25T00:00:00.000Z'; +const AGENT = AgentSchema.parse({ + id: 'coder', + model: 'claude-opus-4-8', + provider: 'anthropic', + system_prompt: 'You are concise.', +}); + +function globalOptions(cwd: string, json = false): GlobalOptions { + return { json, color: false, cwd, configPath: undefined, verbosity: 'normal' }; +} + +const record = (overrides: Partial = {}): AgentSessionRecord => ({ + id: 's1', + agentSlug: AGENT.id, + agentSnapshot: AGENT, + title: 'My Session', + context: { workingDir: '/workspace', fsScopeTier: 'sandboxed' }, + status: 'active', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + createdAt: ISO, + updatedAt: ISO, + ...overrides, +}); + +const message = (seq: number, role: 'user' | 'assistant', text: string): SessionMessage => ({ + id: `m${seq}`, + sessionId: 's1', + sequenceNumber: seq, + role, + content: [{ type: 'text', text }], + timestamp: ISO, +}); + +describe('chatExportCommand (2.P)', () => { + let client: DbClient; + let opened: OpenedSessionStore; + let cwd: string; + + beforeEach(() => { + client = createClient(':memory:'); + runMigrations(client.db); + const store = createSessionStore(client.db); + store.createSession(record()); + store.appendMessage(message(0, 'user', 'hello')); + store.appendMessage(message(1, 'assistant', 'hi there')); + opened = { store, db: client.db, close: () => undefined }; + cwd = mkdtempSync(join(tmpdir(), 'relavium-export-cmd-')); + }); + afterEach(() => { + client.sqlite.close(); + rmSync(cwd, { recursive: true, force: true }); + }); + + function deps(io: ReturnType['io'], json = false): ChatExportCommandDeps { + return { + io, + global: globalOptions(cwd, json), + openSessionStore: () => opened, + now: () => Date.parse(ISO), + }; + } + + it('writes the scaffold, marks the session exported, and prints the path (exit 0)', () => { + const { io, out } = captureIo(); + expect(chatExportCommand({ sessionId: 's1', force: false }, deps(io))).toBe(EXIT_CODES.success); + + const path = join(cwd, 's1.relavium.yaml'); // named from the unique session id + expect(existsSync(path)).toBe(true); + expect(out()).toBe(`Exported session s1 to ${path}\n`); + + // The session row is marked `exported` with the written path (provenance, ADR-0026). + const loaded = opened.store.loadFull('s1'); + expect(loaded?.session.status).toBe('exported'); + expect(loaded?.session.exportedWorkflowPath).toBe(path); + }); + + it('emits a single session:exported event under --json (stdout pure)', () => { + const { io, out } = captureIo(); + expect(chatExportCommand({ sessionId: 's1', force: false }, deps(io, true))).toBe( + EXIT_CODES.success, + ); + const records = parseNdjson(out()); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + type: 'session:exported', + sessionId: 's1', + sequenceNumber: 2, + workflowPath: join(cwd, 's1.relavium.yaml'), + }); + expect(out()).not.toContain('Exported session'); // no human chrome on the --json path + }); + + it('emits sequenceNumber 0 for an empty-session --json export', () => { + // Replace the seeded transcript with a turn-less session so the seq fold's base case (−1+1=0) is pinned + // at the boundary where it is observable (the event), not only in the export-core unit test. + const fresh = createSessionStore(client.db); + fresh.createSession(record({ id: 's-empty', title: 'Empty' })); + opened = { store: fresh, db: client.db, close: () => undefined }; + const { io, out } = captureIo(); + expect(chatExportCommand({ sessionId: 's-empty', force: false }, deps(io, true))).toBe( + EXIT_CODES.success, + ); + expect(parseNdjson(out())[0]).toMatchObject({ sequenceNumber: 0 }); + }); + + it('embeds no Relavium-managed key in the scaffold (the agentSnapshot carries no api_key)', () => { + const { io } = captureIo(); + chatExportCommand({ sessionId: 's1', force: false }, deps(io)); + const yaml = readFileSync(join(cwd, 's1.relavium.yaml'), 'utf8'); + // The frozen agent is emitted into `agents:`, but it references the provider by id — no key field exists. + expect(yaml).not.toMatch(/api_?key/i); + expect(yaml).toContain('agents:'); // the snapshot WAS embedded (so the no-key assertion is meaningful) + }); + + it('rejects an unknown sessionId as exit-2 and closes the store', () => { + let closed = false; + opened = { ...opened, close: () => (closed = true) }; + const { io } = captureIo(); + expect(() => chatExportCommand({ sessionId: 'ghost', force: false }, deps(io))).toThrow( + /no session found with id ghost/, + ); + expect(closed).toBe(true); + }); + + it('honors --out at the command level (the args.out → outPath passthrough)', () => { + const { io } = captureIo(); + expect( + chatExportCommand({ sessionId: 's1', out: 'custom/out.yaml', force: false }, deps(io)), + ).toBe(EXIT_CODES.success); + expect(existsSync(join(cwd, 'custom/out.yaml'))).toBe(true); + }); + + it('degrades a row-mark failure to a stderr warning — the scaffold still lands, exit 0', () => { + // updateSession throws (e.g. a locked db): the file write is the durable contract, so the export still + // succeeds (exit 0) with a stderr note rather than failing an export already on disk. + opened = { + ...opened, + store: { + ...opened.store, + updateSession: () => { + throw new Error('db locked'); + }, + }, + }; + const { io, err } = captureIo(); + expect(chatExportCommand({ sessionId: 's1', force: false }, deps(io))).toBe(EXIT_CODES.success); + expect(err()).toContain('could not mark the session exported'); + expect(existsSync(join(cwd, 's1.relavium.yaml'))).toBe(true); + }); + + it('refuses to overwrite an existing target without --force (exit-2 fault) and closes the store', () => { + const { io } = captureIo(); + chatExportCommand({ sessionId: 's1', force: false }, deps(io)); // first export creates the file + let closed = false; + opened = { ...opened, close: () => (closed = true) }; + expect(() => chatExportCommand({ sessionId: 's1', force: false }, deps(io))).toThrow( + /already exists — pass --force/, + ); + expect(closed).toBe(true); // the store is closed on the file-exists throw path too + }); + + it('warns when exporting a session with no stored agent snapshot (run-time agent_ref hint)', () => { + const fresh = createSessionStore(client.db); + fresh.createSession(record({ id: 's-nosnap', title: 'No Snap', agentSnapshot: undefined })); + opened = { store: fresh, db: client.db, close: () => undefined }; + const { io, err } = captureIo(); + expect(chatExportCommand({ sessionId: 's-nosnap', force: false }, deps(io))).toBe( + EXIT_CODES.success, + ); + expect(err()).toContain('no stored agent'); + }); + + it('overwrites with --force', () => { + const { io } = captureIo(); + chatExportCommand({ sessionId: 's1', force: false }, deps(io)); + expect(chatExportCommand({ sessionId: 's1', force: true }, deps(io))).toBe(EXIT_CODES.success); + }); +}); diff --git a/apps/cli/src/commands/chat-export.ts b/apps/cli/src/commands/chat-export.ts new file mode 100644 index 00000000..17bf4fea --- /dev/null +++ b/apps/cli/src/commands/chat-export.ts @@ -0,0 +1,98 @@ +import { SessionExportedEventSchema } from '@relavium/shared'; + +import { exportSession } from '../chat/export.js'; +import { loadResolvedConfig } from '../config/load.js'; +import { openSessionStore, type OpenedSessionStore } from '../history/session-open.js'; +import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; +import type { CliIo } from '../process/io.js'; +import type { GlobalOptions } from '../process/options.js'; + +/** + * `relavium chat-export ` (2.P) — export a persisted session to a `.relavium.yaml` **scaffold** for + * review before commit ([ADR-0026](../../../docs/decisions/0026-session-export-to-workflow.md)). It writes the + * file (cwd `.relavium.yaml` by default, `--out ` to override, never overwriting without `--force`), + * marks the session row `exported` with the written path (provenance), and prints the path — or, under + * `--json`, emits a single `session:exported` event. Framework-free (no commander/ink). An unknown sessionId or + * an existing target file (without `--force`) is a clean exit-2 invocation fault; success is exit 0. + */ + +export interface ChatExportCommandArgs { + readonly sessionId: string; + /** `--out `: write the scaffold here (absolute, or relative to cwd) instead of `.relavium.yaml`. */ + readonly out?: string; + /** `--force`: overwrite an existing file at the target path. */ + readonly force: boolean; +} + +export interface ChatExportCommandDeps { + readonly io: CliIo; + readonly global: GlobalOptions; + /** Injectable session-store opener — tests pass an in-memory store; production opens `~/.relavium/history.db`. */ + readonly openSessionStore?: (homeDir: string) => OpenedSessionStore; + /** Wall-clock (ms) for the `exported` row timestamp + the event timestamp (injectable for tests). */ + readonly now?: () => number; +} + +export function chatExportCommand( + args: ChatExportCommandArgs, + deps: ChatExportCommandDeps, +): ExitCode { + const now = deps.now ?? Date.now; + const { homeDir } = loadResolvedConfig({ + cwd: deps.global.cwd, + configPath: deps.global.configPath, + }); + const opened = (deps.openSessionStore ?? openSessionStore)(homeDir); + try { + const result = exportSession({ + store: opened.store, + sessionId: args.sessionId, + cwd: deps.global.cwd, + ...(args.out === undefined ? {} : { outPath: args.out }), + force: args.force, + }); + + // A session with no captured `agentSnapshot` exports an `agents:`-less scaffold (the CLI persister always + // captures one, so this only bites a NULL-snapshot row from another surface/migration): warn that its + // `agent_ref` must resolve against the workspace registry before `relavium run` will accept it. + if (result.record.agentSnapshot === undefined) { + deps.io.writeErr( + `note: this session has no stored agent — set agent_ref in ${result.path} to a workspace agent before running it.\n`, + ); + } + + // Mark the session `exported` + record the path (provenance, ADR-0026). Safe here — this is a NON-live + // session (no concurrent persister), unlike the in-REPL `/export` which deliberately does not mark the row. + // The file write is the durable contract, so a provenance-mark fault degrades to a warning (stderr, so + // stdout stays pure under --json) rather than failing an export whose scaffold already landed on disk. + try { + opened.store.updateSession({ + ...result.record, + status: 'exported', + exportedWorkflowPath: result.path, + updatedAt: new Date(now()).toISOString(), + }); + } catch (err) { + deps.io.writeErr( + `note: scaffold written but could not mark the session exported: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + + if (deps.global.json) { + // The machine output is the one documented `session:exported` event (validated at the boundary). + const event = SessionExportedEventSchema.parse({ + type: 'session:exported', + sessionId: args.sessionId, + timestamp: new Date(now()).toISOString(), + sequenceNumber: result.sequenceNumber, + workflowPath: result.path, + }); + deps.io.writeOut(`${JSON.stringify(event)}\n`); + } else { + deps.io.writeOut(`Exported session ${args.sessionId} to ${result.path}\n`); + } + return EXIT_CODES.success; + } finally { + opened.close(); + } +} diff --git a/apps/cli/src/commands/chat-list.test.ts b/apps/cli/src/commands/chat-list.test.ts new file mode 100644 index 00000000..8c508a5b --- /dev/null +++ b/apps/cli/src/commands/chat-list.test.ts @@ -0,0 +1,160 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { createClient, createSessionStore, runMigrations, type DbClient } from '@relavium/db'; +import type { AgentSessionRecord } from '@relavium/shared'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { EXIT_CODES } from '../process/exit-codes.js'; +import type { GlobalOptions } from '../process/options.js'; +import type { OpenedSessionStore } from '../history/session-open.js'; +import { captureIo, parseNdjson } from '../test-support.js'; +import { chatListCommand, type ChatListCommandDeps } from './chat-list.js'; + +function globalOptions(cwd: string, json = false): GlobalOptions { + return { json, color: false, cwd, configPath: undefined, verbosity: 'normal' }; +} + +const CTX = { workingDir: '/workspace', fsScopeTier: 'sandboxed' as const }; + +function makeSession(overrides: Partial = {}): AgentSessionRecord { + return { + id: 'sess-1', + agentSlug: 'chatter', + context: CTX, + status: 'active', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + createdAt: '2026-06-17T08:00:00.000Z', + updatedAt: '2026-06-17T08:00:00.000Z', + ...overrides, + }; +} + +describe('chatListCommand (2.O)', () => { + let client: DbClient; + let opened: OpenedSessionStore; + let cwd: string; + + beforeEach(() => { + client = createClient(':memory:'); + runMigrations(client.db); + opened = { store: createSessionStore(client.db), db: client.db, close: () => {} }; + // A test-local cwd, so loadResolvedConfig() can never walk up into the real repo's `.relavium/` config. + cwd = mkdtempSync(join(tmpdir(), 'relavium-chatlist-cwd-')); + }); + afterEach(() => { + client.sqlite.close(); + rmSync(cwd, { recursive: true, force: true }); + }); + + function deps(io: ReturnType['io'], json = false): ChatListCommandDeps { + return { io, global: globalOptions(cwd, json), openSessionStore: () => opened }; + } + + it('reports an empty history clearly (exit 0)', () => { + const { io, out } = captureIo(); + expect(chatListCommand(deps(io))).toBe(EXIT_CODES.success); + expect(out()).toBe('No agent sessions yet.\n'); + }); + + it('sanitizes a crafted session title (no ANSI/control injection, no row break)', () => { + // A persisted title with an OSC/CSI escape + a newline must not break the one-row layout or inject a + // terminal control sequence; the bytes are stripped and the newline collapsed to a space. + opened.store.createSession(makeSession({ id: 'sess-x', title: '\x1b]0;pwn\x07evil\nrow2' })); + const { io, out } = captureIo(); + expect(chatListCommand(deps(io))).toBe(EXIT_CODES.success); + const text = out(); + expect(text).not.toContain('\x1b'); // the escape introducer is gone + expect(text).not.toContain('\x07'); // and its BEL terminator + expect(text).toContain('"evil row2"'); // the visible text survives, the newline collapsed to a space + expect(text.trimEnd().split('\n')).toHaveLength(2); // heading + ONE session row (no smuggled extra line) + }); + + it('emits a pure-empty stdout for an empty history under --json and closes the store', () => { + let closed = false; + opened = { ...opened, close: () => (closed = true) }; + const { io, out } = captureIo(); + expect(chatListCommand(deps(io, true))).toBe(EXIT_CODES.success); + // The machine contract (ADR-0049): zero sessions ⇒ zero NDJSON lines, never the human "No agent sessions" line. + expect(out()).toBe(''); + // The store closes on the JSON+empty path too (the intersection of the two output axes). + expect(closed).toBe(true); + }); + + it('lists sessions most-recently-updated first with id, agent, status, and title; closes the store', () => { + let closed = false; + opened = { ...opened, close: () => (closed = true) }; + opened.store.createSession( + makeSession({ id: 'sess-old', updatedAt: '2026-06-17T08:00:00.000Z', title: 'First' }), + ); + opened.store.createSession( + makeSession({ id: 'sess-new', updatedAt: '2026-06-17T10:00:00.000Z', agentSlug: 'coder' }), + ); + + const { io, out } = captureIo(); + expect(chatListCommand(deps(io))).toBe(EXIT_CODES.success); + const text = out(); + expect(text).toContain('Agent sessions (2):'); + // Title-ABSENT line asserted exactly (incl. trailing newline) so a stray title suffix would fail; the + // status is bracketed (`[active]`) like `relavium list`'s `[last: …]` column. + expect(text).toContain(' sess-new coder [active] 2026-06-17T10:00:00.000Z\n'); + expect(text).toContain(' sess-old chatter [active] 2026-06-17T08:00:00.000Z "First"\n'); + // A null/undefined title must never leak as a literal into the human line (the absent branch). + expect(text).not.toMatch(/"(null|undefined)"/); + // Most-recent-first ordering: sess-new appears before sess-old. + expect(text.indexOf('sess-new')).toBeLessThan(text.indexOf('sess-old')); + // The store is closed on the POPULATED path too (not only the empty path) — no leaked SQLite handle. + expect(closed).toBe(true); + }); + + it('emits one NDJSON record per session under --json (stdout pure, null-for-absent fields)', () => { + // No modelId set: model_id FK-references model_catalog, which this in-memory store doesn't seed; the + // null-for-absent mapping is what `toJson` exercises (the passthrough is a trivial `?? null`). + opened.store.createSession(makeSession({ id: 'sess-1', title: 'Titled', status: 'ended' })); + opened.store.createSession( + makeSession({ id: 'sess-2', updatedAt: '2026-06-17T09:00:00.000Z' }), + ); + + const { io, out } = captureIo(); + expect(chatListCommand(deps(io, true))).toBe(EXIT_CODES.success); + // Stdout is pure NDJSON: exactly one line per session, no human heading mixed in. + expect(out().trimEnd().split('\n')).toHaveLength(2); + expect(out()).not.toContain('Agent sessions'); + const records = parseNdjson<{ sessionId: string }>(out()); + // Most-recent-first ordering pinned on the MACHINE path independently of the human test. + expect(records.map((r) => r.sessionId)).toEqual(['sess-2', 'sess-1']); + expect(records).toEqual([ + { + sessionId: 'sess-2', + agentSlug: 'chatter', + title: null, + status: 'active', + modelId: null, + createdAt: '2026-06-17T08:00:00.000Z', + updatedAt: '2026-06-17T09:00:00.000Z', + totalCostMicrocents: 0, + }, + { + sessionId: 'sess-1', + agentSlug: 'chatter', + title: 'Titled', + status: 'ended', + modelId: null, + createdAt: '2026-06-17T08:00:00.000Z', + updatedAt: '2026-06-17T08:00:00.000Z', + totalCostMicrocents: 0, + }, + ]); + }); + + it('closes the opened store even on the empty path', () => { + let closed = false; + opened = { ...opened, close: () => (closed = true) }; + const { io } = captureIo(); + chatListCommand(deps(io)); + expect(closed).toBe(true); + }); +}); diff --git a/apps/cli/src/commands/chat-list.ts b/apps/cli/src/commands/chat-list.ts new file mode 100644 index 00000000..414db052 --- /dev/null +++ b/apps/cli/src/commands/chat-list.ts @@ -0,0 +1,84 @@ +import type { AgentSessionRecord } from '@relavium/shared'; + +import { loadResolvedConfig } from '../config/load.js'; +import { openSessionStore, type OpenedSessionStore } from '../history/session-open.js'; +import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; +import type { CliIo } from '../process/io.js'; +import type { GlobalOptions } from '../process/options.js'; +import { sanitizeInline } from '../render/tui/chat-projection.js'; +import { writeRecordLines } from '../render/records.js'; + +export interface ChatListCommandDeps { + readonly io: CliIo; + readonly global: GlobalOptions; + /** Injectable session-store opener — tests pass an in-memory store; production opens `~/.relavium/history.db`. */ + readonly openSessionStore?: (homeDir: string) => OpenedSessionStore; +} + +/** + * The `relavium chat-list` core (**2.O**) — list the past agent sessions (id, agent, title, last activity) + * from durable `history.db`, the session counterpart of `relavium list`. Reads the additive `listSessions` + * seam (non-deleted rows, most-recently-updated first); soft-deleted sessions are excluded. Framework-free + * (no commander/ink). `--json` emits one NDJSON record per session ([ADR-0049](../../../docs/decisions/0049-cli-machine-output-contract.md)); + * an empty history is reported clearly (exit `0` — no sessions is not a fault). + */ +export function chatListCommand(deps: ChatListCommandDeps): ExitCode { + const { homeDir } = loadResolvedConfig({ + cwd: deps.global.cwd, + configPath: deps.global.configPath, + }); + const opened = (deps.openSessionStore ?? openSessionStore)(homeDir); + try { + const sessions = opened.store.listSessions(); + + if (deps.global.json) { + writeRecordLines(deps.io, sessions.map(toJson)); + return EXIT_CODES.success; + } + + if (sessions.length === 0) { + deps.io.writeOut('No agent sessions yet.\n'); + return EXIT_CODES.success; + } + deps.io.writeOut(`Agent sessions (${sessions.length}):\n`); + for (const session of sessions) { + deps.io.writeOut(renderLine(session)); + } + return EXIT_CODES.success; + } finally { + opened.close(); + } +} + +/** + * One session as a terse human line: id, agent slug, bracketed status (the `[…]` convention `relavium list` + * uses for its last-run status, so the column is unambiguous to a scanner), the last-activity timestamp + * (`updatedAt` — the "last activity" chat-session.md promises; raw ISO/UTC, deterministic and unambiguous), + * and the title (if any). + */ +function renderLine(session: AgentSessionRecord): string { + // The title is user/model-supplied persisted text, and `id` is only schema-constrained to a non-empty + // string (the CLI mints a UUID, but `history.db` is shared with other surfaces) — so sanitize BOTH: + // strip ANSI/OSC/control bytes + collapse tab/newline so neither can break the one-row layout or inject a + // terminal escape. The remaining fields are byte-constrained (agentSlug a kebab id, status an enum, + // updatedAt an ISO timestamp). + const title = session.title === undefined ? '' : ` "${sanitizeInline(session.title)}"`; + return ` ${sanitizeInline(session.id)} ${session.agentSlug} [${session.status}] ${session.updatedAt}${title}\n`; +} + +/** + * One session as a machine record — the secret-free identity + lifecycle fields (no transcript, no key). `title` + * / `modelId` are `null` when the row declares none, mirroring `relavium list`'s null-for-absent convention. + */ +function toJson(session: AgentSessionRecord): unknown { + return { + sessionId: session.id, + agentSlug: session.agentSlug, + title: session.title ?? null, + status: session.status, + modelId: session.modelId ?? null, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + totalCostMicrocents: session.totalCostMicrocents, + }; +} diff --git a/apps/cli/src/commands/chat.test.ts b/apps/cli/src/commands/chat.test.ts index cbabe2b9..0f909eb2 100644 --- a/apps/cli/src/commands/chat.test.ts +++ b/apps/cli/src/commands/chat.test.ts @@ -1,7 +1,7 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { PassThrough } from 'node:stream'; +import { PassThrough, Readable } from 'node:stream'; import type { SessionStreamHandleEvent } from '@relavium/core'; import type { StreamChunk } from '@relavium/llm'; @@ -20,14 +20,17 @@ import { EXIT_CODES } from '../process/exit-codes.js'; import type { GlobalOptions } from '../process/options.js'; import { selectChatDriver } from '../render/tui/chat-ink.js'; import { createChatStore } from '../render/tui/chat-store.js'; -import { captureIo } from '../test-support.js'; +import { captureIo, parseNdjson } from '../test-support.js'; import { chatCommand, + chatResumeCommand, + driveJson, drivePlain, makePlainPrinter, type ChatCommandDeps, type ChatDriveContext, type ChatDriver, + type ChatResumeCommandDeps, } from './chat.js'; const EMPTY_CHAT: ResolvedChatConfig = { @@ -220,11 +223,106 @@ describe('chatCommand', () => { expect(err()).toContain('budget warning'); }); - it('strips control bytes from an unknown-slash echo (terminal-injection guard)', async () => { + it('exports the session-so-far to a scaffold on /export, continues, and does NOT mark the live row', async () => { + // The filename is the session id ('id-0'). A custom drive snapshots the row status IMMEDIATELY after + // /export (before the next turn's persist) — that is the only point that distinguishes "never marked" + // from "marked then clobbered": if /export wrongly marked the row, the snapshot would read 'exported'. + const { d, err, store, sessionId } = deps([], [textTurn('hi'), textTurn('more')]); + let statusAfterExport: string | undefined; + const probingDrive: ChatDriver = async (ctx) => { + ctx.startSession(); + await ctx.processLine('hello'); // turn 1 ⇒ persisted, row status 'active' + await ctx.processLine('/export'); // export the session-so-far; must NOT mark the row + statusAfterExport = store.loadFull(sessionId)?.session.status; + await ctx.processLine('again'); // turn 2 still runs (the REPL continued) + await ctx.processLine('/exit'); + }; + await chatCommand({ agent: undefined }, { ...d, drive: probingDrive }); + + const path = join(cwd, 'id-0.relavium.yaml'); + expect(existsSync(path)).toBe(true); + expect(err()).toContain(`exported session to ${path}`); + expect(statusAfterExport).toBe('active'); // /export left the live row untouched (NOT 'exported') + expect(store.loadFull(sessionId)?.messages).toHaveLength(4); // both turns persisted — REPL continued + expect(store.loadFull(sessionId)?.session.status).toBe('ended'); // /exit's terminal + }); + + it('re-exports on a second /export (force overwrites the session OWN scaffold)', async () => { + // The path is keyed on the session id, so a 2nd /export targets the same file — it must overwrite, not + // fail "already exists" (which a force:false regression would produce on the second pass). + const { d, err } = deps([], [textTurn('hi')]); + const twiceDrive: ChatDriver = async (ctx) => { + ctx.startSession(); + await ctx.processLine('hello'); + await ctx.processLine('/export'); // creates id-0.relavium.yaml + await ctx.processLine('/export'); // must overwrite it (force:true), not error + await ctx.processLine('/exit'); + }; + await chatCommand({ agent: undefined }, { ...d, drive: twiceDrive }); + expect(existsSync(join(cwd, 'id-0.relavium.yaml'))).toBe(true); + expect(err()).not.toContain('export failed:'); // force:false would fail the 2nd pass + }); + + it('reports a real /export failure on stderr without crashing the REPL', async () => { + // A DIRECTORY at the target path makes writeFileSync throw EISDIR — a deterministic /export failure that + // exercises the catch arm. The REPL must report it and still drive to /exit (status 'ended'), not crash. + const { d, err, store, sessionId } = deps(['hello', '/export', '/exit'], [textTurn('hi')]); + mkdirSync(join(cwd, 'id-0.relavium.yaml')); // occupy the scaffold path with a dir ⇒ write fails + await chatCommand({ agent: undefined }, d); + expect(err()).toContain('export failed:'); // the catch arm reported the fault + expect(store.loadFull(sessionId)?.session.status).toBe('ended'); // the REPL survived and ended cleanly + }); + + it('chat --json drives the headless stream: stdout pure NDJSON, the unknown-slash diagnostic on stderr', async () => { + const { io, out, err } = captureIo(); + const store = createSessionStore(client.db); + let id = 0; + const d: ChatCommandDeps = { + io: { ...io, stdin: Readable.from(['hello\n/bogus\n']) }, + global: { ...globalOptions(cwd), json: true }, + providers: scriptedResolver([textTurn('hi there')]), + openSessionStore: () => ({ store, db: client.db, close: () => undefined }), + drive: driveJson, + now: () => 0, + uuid: () => `id-${id++}`, // sessionId = id-0; message ids advance, so no PK collision + }; + expect(await chatCommand({ agent: undefined }, d)).toBe(EXIT_CODES.chatEnded); + // parseNdjson throws if a human line leaked onto stdout; the /bogus notice must be on stderr only. + const types = parseNdjson<{ type: string }>(out()).map((e) => e.type); + expect(types).toContain('session:started'); + expect(types).toContain('session:turn_completed'); + expect(types.at(-1)).toBe('session:cancelled'); // the terminal flushed via runReplLoop's finalize wiring + expect(out()).not.toContain('unknown command'); // the diagnostic did NOT leak to stdout + expect(err()).toContain("unknown command '/bogus'"); // it went to stderr + }); + + it('chat --json /export emits a session:exported event on stdout (the machine path)', async () => { + const { io, out } = captureIo(); + const store = createSessionStore(client.db); + let id = 0; + const d: ChatCommandDeps = { + io: { ...io, stdin: Readable.from(['hello\n/export\n']) }, + global: { ...globalOptions(cwd), json: true }, + providers: scriptedResolver([textTurn('hi there')]), + openSessionStore: () => ({ store, db: client.db, close: () => undefined }), + drive: driveJson, + now: () => 0, + uuid: () => `id-${id++}`, // sessionId = id-0; message ids advance, so no PK collision + }; + expect(await chatCommand({ agent: undefined }, d)).toBe(EXIT_CODES.chatEnded); + const exported = parseNdjson<{ type: string; workflowPath?: string }>(out()).find( + (e) => e.type === 'session:exported', + ); + expect(exported).toBeDefined(); // /export emits the event on the --json stream, not just a stderr line + expect(exported?.workflowPath).toBe(join(cwd, 'id-0.relavium.yaml')); + }); + + it('strips control bytes from an unknown-slash echo (terminal-injection guard) and lists /export', async () => { const { d, err } = deps(['/\x1b[2Jboom', '/exit'], [textTurn('x')]); await chatCommand({ agent: undefined }, d); expect(err()).not.toContain('\x1b'); // the raw ESC never reached stderr expect(err()).toContain('?'); // it was replaced + expect(err()).toContain('/export'); // the help line advertises the /export affordance }); it('propagates a driver rejection AND still runs teardown (the anti-hang/propagation contract)', async () => { @@ -266,6 +364,282 @@ describe('chatCommand', () => { }); }); +describe('chatResumeCommand (2.N)', () => { + let cwd: string; + let home: string; + let client: DbClient; + const savedHome = new Map(); + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'relavium-resume-cwd-')); + home = mkdtempSync(join(tmpdir(), 'relavium-resume-home-')); + for (const v of HOME_ENV_VARS) { + savedHome.set(v, process.env[v]); + process.env[v] = home; + } + client = createClient(':memory:'); + runMigrations(client.db); + }); + afterEach(() => { + client.sqlite.close(); + for (const v of HOME_ENV_VARS) { + const prev = savedHome.get(v); + if (prev === undefined) delete process.env[v]; + else process.env[v] = prev; + } + rmSync(cwd, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + }); + + type Store = ReturnType; + + /** Fresh-session deps over a SHARED store, so a later resume reloads the SAME `history.db`. sessionId = `id-0`. */ + function freshDeps( + lines: readonly string[], + scripts: StreamChunk[][], + store: Store, + ): ChatCommandDeps { + const { io } = captureIo(); + let tick = Date.parse('2026-06-25T00:00:00.000Z'); + let id = 0; + return { + io, + global: globalOptions(cwd), + providers: scriptedResolver(scripts), + openSessionStore: () => ({ store, db: client.db, close: () => undefined }), + drive: linesDriver(lines), + now: () => tick++, + uuid: () => `id-${id++}`, + }; + } + + /** + * Resume deps over the SAME store; resume reuses the persisted sessionId (no mint), so ids only feed + * messages. `prefix` makes message ids unique ACROSS resumes (production uses randomUUID; the deterministic + * test uuid would otherwise collide on the message PK when one session is resumed more than once). + */ + function resumeDeps( + lines: readonly string[], + scripts: StreamChunk[][], + store: Store, + prefix = 'r', + ): { d: ChatResumeCommandDeps; err: () => string } { + const { io, err } = captureIo(); + let tick = Date.parse('2026-06-25T01:00:00.000Z'); + let id = 0; + return { + d: { + io, + global: globalOptions(cwd), + providers: scriptedResolver(scripts), + openSessionStore: () => ({ store, db: client.db, close: () => undefined }), + drive: linesDriver(lines), + now: () => tick++, + uuid: () => `${prefix}-${id++}`, + }, + err, + }; + } + + it('reloads a persisted session and continues it, appending sequenced rows past the prior max', async () => { + const store = createSessionStore(client.db); + // Seed one fresh turn (id-0 = session; messages seq 0,1), then resume and add a second turn. + expect( + await chatCommand( + { agent: undefined }, + freshDeps(['hello', '/exit'], [textTurn('hi')], store), + ), + ).toBe(EXIT_CODES.chatEnded); + + const { d } = resumeDeps(['again', '/exit'], [textTurn('more')], store); + expect(await chatResumeCommand({ sessionId: 'id-0' }, d)).toBe(EXIT_CODES.chatEnded); + + const full = store.loadFull('id-0'); + expect(full?.messages.map((m) => m.role)).toEqual(['user', 'assistant', 'user', 'assistant']); + // The continued turn's rows are seq 2,3 — past the persisted MAX (1), no UNIQUE collision. + expect(full?.messages.map((m) => m.sequenceNumber)).toEqual([0, 1, 2, 3]); + expect(full?.messages[2]?.content[0]).toEqual({ type: 'text', text: 'again' }); + expect(full?.messages[3]?.content[0]).toEqual({ type: 'text', text: 'more' }); + // Totals accumulate across the resume (the persister adopts + hydrates the prior row): 2 turns × {10,5}. + expect(full?.session.totalInputTokens).toBe(20); + expect(full?.session.totalOutputTokens).toBe(10); + // Cost also accumulates (not reset to the new turn's delta) — the persister seeds it from the adopted row. + expect(full?.session.totalCostMicrocents).toBeGreaterThan(0); + }); + + it('keeps sequence numbers monotonic across THREE resumes (no off-by-one)', async () => { + const store = createSessionStore(client.db); + await chatCommand({ agent: undefined }, freshDeps(['t1', '/exit'], [textTurn('a')], store)); + await chatResumeCommand( + { sessionId: 'id-0' }, + resumeDeps(['t2', '/exit'], [textTurn('b')], store, 'r2').d, + ); + await chatResumeCommand( + { sessionId: 'id-0' }, + resumeDeps(['t3', '/exit'], [textTurn('c')], store, 'r3').d, + ); + + const full = store.loadFull('id-0'); + // Three turns × {user, assistant} = six rows, contiguously sequenced across the three processes. + expect(full?.messages.map((m) => m.sequenceNumber)).toEqual([0, 1, 2, 3, 4, 5]); + expect(full?.messages.map((m) => m.role)).toEqual([ + 'user', + 'assistant', + 'user', + 'assistant', + 'user', + 'assistant', + ]); + }); + + it('ends a RESUMED session on /cancel with exit 4 (the resumable-cancel contract holds on the resume path)', async () => { + const store = createSessionStore(client.db); + await chatCommand({ agent: undefined }, freshDeps(['hello', '/exit'], [textTurn('hi')], store)); + const { d } = resumeDeps(['/cancel'], [], store); + expect(await chatResumeCommand({ sessionId: 'id-0' }, d)).toBe(EXIT_CODES.chatEnded); + expect(store.loadFull('id-0')?.session.status).toBe('ended'); + }); + + it('warns up front when resuming a session already at/over the [chat].max_turns cap', async () => { + const store = createSessionStore(client.db); + // Seed two turns under the default (uncapped) cwd so the seeding itself is not blocked. + await chatCommand( + { agent: undefined }, + freshDeps(['t1', 't2', '/exit'], [textTurn('a'), textTurn('b')], store), + ); + // A separate project whose [chat].max_turns = 1 is BELOW the session's 2 prior turns. + const capCwd = mkdtempSync(join(tmpdir(), 'relavium-cap-')); + mkdirSync(join(capCwd, '.relavium'), { recursive: true }); + writeFileSync(join(capCwd, '.relavium', 'project.toml'), '[chat]\nmax_turns = 1\n'); + try { + const { d, err } = resumeDeps([], [], store); + await chatResumeCommand( + { sessionId: 'id-0' }, + { ...d, global: { ...globalOptions(capCwd), json: false } }, + ); + expect(err()).toContain('new turns will be refused'); + } finally { + rmSync(capCwd, { recursive: true, force: true }); + } + }); + + it('does NOT warn when resuming a session below the (default) turn cap', async () => { + const store = createSessionStore(client.db); + await chatCommand({ agent: undefined }, freshDeps(['t1', '/exit'], [textTurn('a')], store)); + const { d, err } = resumeDeps([], [], store); + await chatResumeCommand({ sessionId: 'id-0' }, d); + expect(err()).not.toContain('new turns will be refused'); // 1 turn ≪ default cap 50 + }); + + it('seeds the view header (model · cost · prior turns) and the resume intro from the reconstructed state', async () => { + const store = createSessionStore(client.db); + await chatCommand({ agent: undefined }, freshDeps(['hello', '/exit'], [textTurn('hi')], store)); + const seededModel = store.loadFull('id-0')?.session.agentSnapshot?.model; + + let snapshot: ReturnType | undefined; + let intro: string | undefined; + const captureDrive: ChatDriver = (ctx) => { + snapshot = ctx.store.getSnapshot(); + intro = ctx.intro; + return Promise.resolve(); + }; + const { d } = resumeDeps([], [], store); + await chatResumeCommand({ sessionId: 'id-0' }, { ...d, drive: captureDrive }); + + expect(snapshot?.state.turnCount).toBe(1); // one prior completed turn + expect(snapshot?.state.model).toBe(seededModel); // header model seeded (a fresh store would be undefined) + expect(seededModel).toBeDefined(); + expect(snapshot?.state.cumulativeCostMicrocents).toBeGreaterThan(0); // carried-over cost, not zero + expect(intro).toContain('Resuming session id-0'); + expect(intro).toContain('1 prior turn'); // singular, and not "1 prior turns" + expect(intro).not.toContain('1 prior turns'); + }); + + it('pluralizes the resume intro for a multi-turn session ("N prior turns")', async () => { + const store = createSessionStore(client.db); + // Seed TWO completed turns so the reconstructed turn count is 2 (plural branch of the intro). + await chatCommand( + { agent: undefined }, + freshDeps(['hello', 'again', '/exit'], [textTurn('hi'), textTurn('yo')], store), + ); + + let intro: string | undefined; + const captureDrive: ChatDriver = (ctx) => { + intro = ctx.intro; + return Promise.resolve(); + }; + const { d } = resumeDeps([], [], store); + await chatResumeCommand({ sessionId: 'id-0' }, { ...d, drive: captureDrive }); + expect(intro).toContain('2 prior turns'); + }); + + it('sanitizes a crafted session id in the resume intro banner (no terminal escape reaches the TTY)', async () => { + const store = createSessionStore(client.db); + // `history.db` is shared with other surfaces whose ids are only schema-constrained to a non-empty string, + // so a row may carry control bytes. Mint the session under an id bearing an OSC sequence + a newline; a + // fresh run persists a real agentSnapshot under it, then the resume intro must strip the escape (exactly + // as chat-list sanitizes its id column) — else the banner is the one chat output path that could inject. + const craftedId = 'evil\u001b]0;x\u0007\nFAKE-ROW'; + let idc = 0; + let tick = Date.parse('2026-06-25T00:00:00.000Z'); + const seed: ChatCommandDeps = { + io: captureIo().io, + global: globalOptions(cwd), + providers: scriptedResolver([textTurn('hi')]), + openSessionStore: () => ({ store, db: client.db, close: () => undefined }), + drive: linesDriver(['hello', '/exit']), + now: () => tick++, + uuid: () => (idc++ === 0 ? craftedId : `m-${idc}`), // first mint = the session id + }; + expect(await chatCommand({ agent: undefined }, seed)).toBe(EXIT_CODES.chatEnded); + + let intro: string | undefined; + const captureDrive: ChatDriver = (ctx) => { + intro = ctx.intro; + return Promise.resolve(); + }; + const { d } = resumeDeps([], [], store); + await chatResumeCommand({ sessionId: craftedId }, { ...d, drive: captureDrive }); + expect(intro).toBeDefined(); + expect(intro).not.toContain('\u001b'); // no ESC control byte survives into the banner + expect(intro).not.toContain('\u0007'); // no BEL control byte survives into the banner + expect(intro).not.toContain('\n'); // the smuggled newline is collapsed — the row cannot be split + expect(intro).toContain('Resuming session'); // the banner's static text is intact + }); + + it('rejects an unknown sessionId as a clean exit-2 invocation fault and closes the store', async () => { + let closed = false; + const store = createSessionStore(client.db); + const { d } = resumeDeps([], [], store); + await expect( + chatResumeCommand( + { sessionId: 'ghost' }, + { ...d, openSessionStore: () => ({ store, db: client.db, close: () => (closed = true) }) }, + ), + ).rejects.toThrow(/no session found with id ghost/); + expect(closed).toBe(true); // the opened db handle is not stranded on the not-found path + }); + + it('rejects a session with no stored agent snapshot as a clean exit-2 fault', async () => { + const store = createSessionStore(client.db); + store.createSession({ + id: 'no-snap', + agentSlug: 'gone', + context: { workingDir: cwd, fsScopeTier: 'sandboxed' }, + status: 'ended', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + createdAt: '2026-06-25T00:00:00.000Z', + updatedAt: '2026-06-25T00:00:00.000Z', + }); + const { d } = resumeDeps([], [], store); + await expect(chatResumeCommand({ sessionId: 'no-snap' }, d)).rejects.toThrow( + /no stored agent snapshot/, + ); + }); +}); + describe('drivePlain', () => { // A minimal driver context over a REAL session handle (no turns fire — startSession is a no-op) plus a // recording processLine, so we exercise drivePlain's readline loop + teardown over the injected stdin (F1). @@ -436,11 +810,97 @@ describe('selectChatDriver', () => { await expect(selectChatDriver(ctxWith(false, false))).resolves.toBeUndefined(); }); - it('routes --json to the plain driver even on a TTY', async () => { + it('routes --json to the headless json driver even on a TTY (resolves; ink would block)', async () => { await expect(selectChatDriver(ctxWith(true, true))).resolves.toBeUndefined(); }); - it('routes a non-TTY + --json surface to the plain driver', async () => { + it('routes a non-TTY + --json surface to the headless json driver', async () => { await expect(selectChatDriver(ctxWith(false, true))).resolves.toBeUndefined(); }); }); + +describe('driveJson (2.Q)', () => { + // A driver context over a REAL session handle + a recording processLine, so we exercise driveJson's + // readline loop and its NDJSON serialization of the live event stream over the injected stdin. + function jsonCtx(stdin: NodeJS.ReadableStream, turns: StreamChunk[][] = [textTurn('hi there')]) { + const built = buildChatSession({ + chat: EMPTY_CHAT, + agentRef: undefined, + cwd: tmpdir(), + projectConfigDir: undefined, + now: () => 0, + uuid: () => 'sess-j', + providers: scriptedResolver(turns), + }); + const { io: base, out } = captureIo(); + let stop = false; + const ctx: ChatDriveContext = { + startSession: () => built.session.start(), // emits session:started ⇒ the first NDJSON line + processLine: async (line) => { + if (line === '/exit') { + stop = true; + return; + } + await built.session.sendMessage(line); + }, + shouldStop: () => stop, + handle: built.handle, + store: createChatStore(false), + io: { ...base, stdin }, + global: { ...globalOptions(tmpdir()), json: true }, + // Flush the terminal (session:cancelled) before unsubscribing, as runReplLoop wires in production. + finalize: () => built.session.cancel(), + }; + return { ctx, out, built }; + } + + it('emits a pure NDJSON stream (session:started → turn events → session:cancelled terminal) on EOF', async () => { + const stdin = new PassThrough(); + const { ctx, out } = jsonCtx(stdin); + const done = driveJson(ctx); + stdin.write('hello\n'); + stdin.end(); // EOF ends the loop + await done; + + // parseNdjson runtime-rejects any non-object line, so it doubles as a stdout-purity guard. + const events = parseNdjson<{ type: string; sessionId?: string }>(out()); + const types = events.map((e) => e.type); + expect(types[0]).toBe('session:started'); // the first line is the lifecycle-open event + expect(types).toContain('session:turn_started'); + expect(types).toContain('session:turn_completed'); + expect(types.at(-1)).toBe('session:cancelled'); // the sole terminal IS in the stream (the finalize fix) + // Every line carries the sessionId (the disjoint session namespace). + expect(events.every((e) => e.sessionId === 'sess-j')).toBe(true); + expect(out()).not.toContain('test-key'); // the dummy provider key never reaches the stream + }); + + it('streams two turns, each with its own session:turn_completed, before the terminal', async () => { + const stdin = new PassThrough(); + const { ctx, out } = jsonCtx(stdin, [textTurn('one'), textTurn('two')]); + const done = driveJson(ctx); + stdin.write('first\n'); + stdin.write('second\n'); + stdin.end(); + await done; + + const types = parseNdjson<{ type: string }>(out()).map((e) => e.type); + expect(types.filter((t) => t === 'session:turn_completed')).toHaveLength(2); // both turns settled + expect(types.at(-1)).toBe('session:cancelled'); + }); + + it('a SIGINT closes the input so the loop ends and the finally removes the handler (teardown path)', async () => { + // The parallel of the drivePlain SIGINT teardown test — driveJson registers its own SIGINT handler and must + // remove it in the finally. Identify it by SET-DELTA (robust to any other listener the runner registers). + const stdin = new PassThrough(); + const { ctx } = jsonCtx(stdin); + const before = process.listeners('SIGINT').slice(); + const done = driveJson(ctx); + const added = process.listeners('SIGINT').filter((l) => !before.includes(l)); + expect(added).toHaveLength(1); + const handler = added[0]; + if (typeof handler !== 'function') throw new TypeError('expected a registered SIGINT handler'); + handler('SIGINT'); // invoke directly (not process.emit) — it closes the readline, ending the loop + await done; + expect(process.listeners('SIGINT').filter((l) => !before.includes(l))).toHaveLength(0); // finally removed it + }); +}); diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index dcaf61ea..47413dca 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -1,17 +1,31 @@ import { randomUUID } from 'node:crypto'; import { createInterface } from 'node:readline'; -import type { SessionHandle, SessionStreamHandleEvent } from '@relavium/core'; +import { + DEFAULT_SESSION_MAX_TURNS, + type SessionHandle, + type SessionStreamHandleEvent, +} from '@relavium/core'; -import { createSessionPersister } from '../chat/persister.js'; -import { buildChatSession } from '../chat/session-host.js'; +import { exportSession } from '../chat/export.js'; +import { createSessionPersister, type SessionPersister } from '../chat/persister.js'; +import { + buildChatSession, + buildResumedChatSession, + type BuiltChatSession, +} from '../chat/session-host.js'; import { loadResolvedConfig } from '../config/load.js'; import { createProviderResolver, type ProviderResolver } from '../engine/providers.js'; import { openSessionStore, type OpenedSessionStore } from '../history/session-open.js'; +import { CliError } from '../process/errors.js'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; -import { formatToolCall, stripTerminalControls } from '../render/tui/chat-projection.js'; +import { + formatToolCall, + sanitizeInline, + stripTerminalControls, +} from '../render/tui/chat-projection.js'; import { createChatStore, type ChatStoreController } from '../render/tui/chat-store.js'; /** @@ -45,6 +59,19 @@ export interface ChatDriveContext { readonly store: ChatStoreController; readonly io: CliIo; readonly global: GlobalOptions; + /** + * The session banner line (no trailing newline). A fresh session leaves it unset (the plain driver shows a + * default greeting; the ink driver shows nothing); a 2.N resume sets the "Resuming session …" context line, + * which BOTH drivers print — the plain loop as its banner, the ink driver once above the live region — so a + * resumed session is visibly a resume. The seeded footer additionally carries the bound model + prior totals. + */ + readonly intro?: string; + /** + * Flush the session's terminal (`session:cancelled`) — a headless driver MUST call this once its input loop + * ends, while its render subscription is still attached, so the `--json` stream includes its sole terminal + * event (the command's own teardown fires the terminal only AFTER the driver has unsubscribed). Idempotent. + */ + readonly finalize?: () => void; } export type ChatDriver = (ctx: ChatDriveContext) => Promise; @@ -63,6 +90,33 @@ export interface ChatCommandDeps { readonly uuid?: () => string; } +export interface ChatResumeCommandArgs { + /** The persisted session to reload + continue (`relavium chat-resume `). */ + readonly sessionId: string; +} + +export interface ChatResumeCommandDeps { + readonly io: CliIo; + readonly global: GlobalOptions; + readonly providers?: ProviderResolver; + /** Injectable resumed-session builder (tests inject a scripted provider via providers). Default {@link buildResumedChatSession}. */ + readonly buildResumedSession?: typeof buildResumedChatSession; + /** Injectable session-store opener (tests pass an in-memory store). Default {@link openSessionStore}. */ + readonly openSessionStore?: (homeDir: string) => OpenedSessionStore; + /** The interactive driver — defaults to the plain non-TTY line loop; the TTY ink driver + tests override it. */ + readonly drive?: ChatDriver; + /** Wall-clock (ms) + id sources (injectable for tests). */ + readonly now?: () => number; + readonly uuid?: () => string; +} + +/** The subset the shared {@link runReplLoop} needs — satisfied by both {@link ChatCommandDeps} and {@link ChatResumeCommandDeps}. */ +interface ChatReplDeps { + readonly io: CliIo; + readonly global: GlobalOptions; + readonly drive?: ChatDriver; +} + export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): Promise { const now = deps.now ?? Date.now; const uuid = deps.uuid ?? randomUUID; @@ -102,6 +156,124 @@ export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): uuid, }); + return runReplLoop( + { built, opened, store, persister, startSession: () => built.session.start() }, + deps, + ); +} + +/** + * `relavium chat-resume ` (2.N) — reload a persisted session from `history.db` and continue it in + * the SAME REPL. It rebinds the session's frozen agent + context, reconstructs the in-flight transcript + * ({@link buildResumedChatSession} over `AgentSession.resume`), seeds the view header from the carried-over + * state, continues the durable transcript past its last `sequenceNumber`, and drives the shared + * {@link runReplLoop}. An unknown `sessionId` (or a session with no stored agent snapshot) is a clean exit-2 + * invocation fault. Like `chat`, it ends with **exit code 4**. + */ +export async function chatResumeCommand( + args: ChatResumeCommandArgs, + deps: ChatResumeCommandDeps, +): Promise { + const now = deps.now ?? Date.now; + const uuid = deps.uuid ?? randomUUID; + + const { config, homeDir } = loadResolvedConfig({ + cwd: deps.global.cwd, + configPath: deps.global.configPath, + }); + const providers = deps.providers ?? createProviderResolver(deps.io.env); + const opened = (deps.openSessionStore ?? openSessionStore)(homeDir); + + let built: BuiltChatSession; + let store: ChatStoreController; + let persister: SessionPersister; + let intro: string; + try { + // The current `[chat]` config governs the resumed turn/cost caps; the agent, model, and context are the + // frozen originals from the record. An absent session is a clean exit-2 invocation fault. + const loaded = opened.store.loadFull(args.sessionId); + if (loaded === undefined) { + throw new CliError('invalid_invocation', `no session found with id ${args.sessionId}`); + } + const resumed = (deps.buildResumedSession ?? buildResumedChatSession)({ + chat: config.chat, + record: loaded.session, + messages: loaded.messages, + now, + providers, + onBudgetWarning: (warning) => + deps.io.writeErr( + `budget warning: ~${warning.thresholdPct}% of the ${warning.limitMicrocents}µ¢ cap reached\n`, + ), + }); + built = resumed; + // Seed the view header: a resumed session never re-emits `session:started`, so without this the footer + // would show no model and zero cost/turns until the first new turn (the durable record is unaffected). + store = createChatStore(deps.global.color, { + agentRef: resumed.agent.id, + model: resumed.agent.model, + cumulativeCostMicrocents: resumed.resumeState.cumulativeCostMicrocents, + turnCount: resumed.resumeState.turnCount, + }); + persister = createSessionPersister({ + store: opened.store, + handle: resumed.handle, + sessionId: resumed.sessionId, + agent: resumed.agent, + context: resumed.context, + now, + uuid, + // Continue the durable transcript past its last sequence number (start() adopts the row + its totals). + initialSequenceNumber: resumed.nextSequenceNumber, + }); + const turns = resumed.resumeState.turnCount; + // `sessionId` is only schema-constrained to a non-empty string (the CLI mints a UUID, but `history.db` is + // shared with other surfaces) — sanitize it before it reaches the TTY, exactly as `chat-list` does (the + // agent id is kebab-constrained, so safe raw). Without this the resume banner is the one chat output path + // that could carry a terminal escape from a crafted stored id. + intro = `Resuming session ${sanitizeInline(resumed.sessionId)} — ${resumed.agent.id}, ${turns} prior ${turns === 1 ? 'turn' : 'turns'}. Type a message, or /exit to quit.`; + // Pre-flight the hard turn cap: a session resumed under a config whose `[chat].max_turns` is now at/below + // its prior turn count would have EVERY new turn blocked loudly as `turn_limit` (the engine cap counts the + // carried turns) with no way forward but /exit. Warn up front (stderr, non-blocking) so the user isn't + // silently trapped — distinct from the per-turn engine error. The effective cap mirrors the engine's + // `undefined/≤0 ⇒ default` rule (config validation already rejects a non-positive max_turns). + const cap = config.chat.maxTurns ?? DEFAULT_SESSION_MAX_TURNS; + if (turns >= cap) { + deps.io.writeErr( + `note: this session has ${turns} turns, at or over the ${cap}-turn cap — new turns will be refused (turn_limit). Raise [chat].max_turns to continue it.\n`, + ); + } + } catch (err) { + // A pre-loop fault (not-found, no snapshot, build failure) must not strand the open db handle. + opened.close(); + throw err; + } + + // A resumed session already landed at idle inside `AgentSession.resume`; calling start() would throw and + // re-emitting `session:started` would double a terminal-less lifecycle event — so startSession is a no-op. + return runReplLoop({ built, opened, store, persister, startSession: () => {}, intro }, deps); +} + +/** What the shared REPL loop needs: a built (fresh or resumed) session, its store/persister, and how to open it. */ +interface ReplWiring { + readonly built: BuiltChatSession; + readonly opened: OpenedSessionStore; + readonly store: ChatStoreController; + readonly persister: SessionPersister; + /** Open the session: `built.session.start()` for a fresh session, a no-op for a resumed one (already idle). */ + readonly startSession: () => void; + /** The plain-driver banner override (the 2.N resume context line); fresh sessions omit it. */ + readonly intro?: string; +} + +/** + * The shared REPL loop driving both `chat` (fresh) and `chat-resume` (2.N): wire the slash-command/message + * `processLine`, start the persister, hand control to the injected {@link ChatDriver} (ink TTY or plain line + * loop), and on teardown emit the session's sole terminal (`session:cancelled`, idempotent) + close the + * persister and the db. `/exit`, `/cancel`, and an input-stream EOF all end the session with **exit code 4**. + */ +async function runReplLoop(wiring: ReplWiring, deps: ChatReplDeps): Promise { + const { built, opened, store, persister, startSession, intro } = wiring; let stop = false; let cancelled = false; const cancelOnce = (): void => { @@ -125,11 +297,39 @@ export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): stop = true; return; } + if (line === '/export') { + // Export the session-so-far to a `.relavium.yaml` scaffold (2.P, same ADR-0026 contract). It runs + // BETWEEN turns (every completed turn is already persisted), reads the durable transcript, and writes + // the file; it does NOT mark the row `exported` (a later turn's persist would clobber that — the + // standalone `chat-export` command marks it). A failure is reported, never crashing the REPL. + try { + const result = exportSession({ + store: opened.store, + sessionId: built.sessionId, + cwd: deps.global.cwd, + // Re-export overwrites the session's OWN scaffold: the default path is keyed on the unique session + // id, so `force` here can only ever clobber this session's prior export, never another session's. + force: true, + }); + if (deps.global.json) { + // Machine mode (--json, 2.Q): emit `session:exported` THROUGH the session bus, so it rides the + // live stream's monotonic per-session sequenceNumber (a DB-derived seq would jump backward and + // trip a consumer's gap-detection). The bus stamps sessionId/sequenceNumber/timestamp; the + // driveJson serializer (subscribed) writes it to stdout, keeping the stream pure + complete. + built.emitSessionEvent({ type: 'session:exported', workflowPath: result.path }); + } else { + deps.io.writeErr(`exported session to ${result.path}\n`); + } + } catch (err) { + deps.io.writeErr(`export failed: ${err instanceof Error ? err.message : String(err)}\n`); + } + return; + } if (line.startsWith('/')) { // Echo a SANITIZED form — strip non-printable bytes + truncate — so a crafted slash can't smuggle a // terminal control sequence (or a flood) into stderr. const safe = line.replace(/[^\x20-\x7e]/g, '?').slice(0, 64); - deps.io.writeErr(`unknown command '${safe}'. Available: /exit, /cancel.\n`); + deps.io.writeErr(`unknown command '${safe}'. Available: /exit, /cancel, /export.\n`); return; } store.appendUser(line); @@ -137,19 +337,23 @@ export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): await built.session.sendMessage(line); }; - // persister.start() subscribes for the turn events + inserts the session row; it does NOT consume - // session:started, so it is safe before the driver. session.start() (which fires session:started) is - // deferred to startSession() INSIDE the driver, after the driver has subscribed the view store. + // persister.start() subscribes for the turn events + adopts/inserts the session row; it does NOT consume + // session:started, so it is safe before the driver. The session-open action (fresh start() / resume no-op) + // is deferred to startSession() INSIDE the driver, after the driver has subscribed the view store. try { persister.start(); await (deps.drive ?? drivePlain)({ - startSession: () => built.session.start(), + startSession, processLine, shouldStop: () => stop, handle: built.handle, store, io: deps.io, global: deps.global, + // A headless driver flushes the terminal (session:cancelled) before unsubscribing; the command's own + // cancelOnce below is then a no-op (idempotent). Other drivers ignore it — the command fires it. + finalize: cancelOnce, + ...(intro === undefined ? {} : { intro }), }); } finally { cancelOnce(); // emit the terminal even on /exit or EOF (idempotent); flips the row to 'ended' @@ -172,8 +376,36 @@ export async function drivePlain(ctx: ChatDriveContext): Promise { const onSigint = (): void => rl.close(); process.once('SIGINT', onSigint); try { - ctx.io.writeOut('relavium chat — type a message, or /exit to quit.\n'); - ctx.startSession(); // subscription wired above ⇒ session:started is observed, not raced + ctx.io.writeOut(`${ctx.intro ?? 'relavium chat — type a message, or /exit to quit.'}\n`); + ctx.startSession(); // subscription wired above ⇒ session:started is observed (fresh), or a no-op (resume) + for await (const line of rl) { + await ctx.processLine(line); + if (ctx.shouldStop()) break; + } + } finally { + process.removeListener('SIGINT', onSigint); + rl.close(); + unsubscribe(); + } +} + +/** + * The **`--json`** (2.Q) headless driver: the machine analogue of `relavium run --json`. Messages are read + * from stdin one user turn per line; every session-stream event (`session:*` + the per-turn `agent:*` / + * `cost:updated`) is serialized verbatim as one NDJSON line on **stdout**, each carrying the `sessionId` + * ([ADR-0049](../../../docs/decisions/0049-cli-machine-output-contract.md)). Diagnostics (unknown-slash, + * /export) stay on stderr, so stdout is a pure `SessionEvent` stream. An input-stream EOF ends the session + * (exit code 4, like the REPL). No banner — the first line is the `session:started` event. + */ +export async function driveJson(ctx: ChatDriveContext): Promise { + const unsubscribe = ctx.handle.subscribe((event) => + ctx.io.writeOut(`${JSON.stringify(event)}\n`), + ); + const rl = createInterface({ input: ctx.io.stdin, terminal: false }); + const onSigint = (): void => rl.close(); + process.once('SIGINT', onSigint); + try { + ctx.startSession(); // subscription wired above ⇒ the synchronous session:started is the first NDJSON line for await (const line of rl) { await ctx.processLine(line); if (ctx.shouldStop()) break; @@ -181,6 +413,8 @@ export async function drivePlain(ctx: ChatDriveContext): Promise { } finally { process.removeListener('SIGINT', onSigint); rl.close(); + // Flush session:cancelled BEFORE unsubscribing, so the NDJSON stream includes its sole terminal event. + ctx.finalize?.(); unsubscribe(); } } diff --git a/apps/cli/src/commands/specs.test.ts b/apps/cli/src/commands/specs.test.ts index 8a940a15..1fa854cd 100644 --- a/apps/cli/src/commands/specs.test.ts +++ b/apps/cli/src/commands/specs.test.ts @@ -14,7 +14,19 @@ describe('command registration (specs)', () => { it('registers the 2.I read commands and the gate list subcommand', () => { const program = buildProgram(captureIo().io); const names = program.commands.map((command) => command.name()); - expect(names).toEqual(expect.arrayContaining(['list', 'logs', 'status', 'gate', 'chat'])); + expect(names).toEqual( + expect.arrayContaining([ + 'list', + 'logs', + 'status', + 'gate', + 'chat', + 'chat-resume', + 'chat-list', + 'chat-export', + 'agent', + ]), + ); const gate = program.commands.find((command) => command.name() === 'gate'); expect(gate?.commands.map((command) => command.name())).toContain('list'); @@ -26,6 +38,30 @@ describe('command registration (specs)', () => { expect(() => program.parse(['node', 'relavium', 'chat'])).toThrow(/`relavium chat` requires/); }); + it('routes `relavium chat-list` to its command (a clean no-context stub in a help-only program)', () => { + const program = buildProgram(captureIo().io); + program.exitOverride(); + expect(() => program.parse(['node', 'relavium', 'chat-list'])).toThrow( + /`relavium chat-list` requires/, + ); + }); + + it('routes `relavium chat-resume ` to its command (a clean no-context stub in a help-only program)', () => { + const program = buildProgram(captureIo().io); + program.exitOverride(); + expect(() => program.parse(['node', 'relavium', 'chat-resume', 'sess-1'])).toThrow( + /`relavium chat-resume` requires/, + ); + }); + + it('routes `relavium chat-export ` to its command (a clean no-context stub in a help-only program)', () => { + const program = buildProgram(captureIo().io); + program.exitOverride(); + expect(() => program.parse(['node', 'relavium', 'chat-export', 'sess-1'])).toThrow( + /`relavium chat-export` requires/, + ); + }); + it('routes `gate list` to the gate-list subcommand (not the parent gate action)', () => { const program = buildProgram(captureIo().io); program.exitOverride(); @@ -41,18 +77,21 @@ describe('command registration (specs)', () => { ); }); - it('gives the documented "not available yet" message for the unshipped chat-family + budget stubs', () => { + it('routes `relavium agent run ` to its command (a clean no-context stub in a help-only program)', () => { + const program = buildProgram(captureIo().io); + program.exitOverride(); + expect(() => program.parse(['node', 'relavium', 'agent', 'run', 'coder'])).toThrow( + /`relavium agent run` requires/, + ); + }); + + it('gives the documented "not available yet" message for the unshipped budget stub', () => { // commands.md promises a clean "not available yet (lands in …)" message — not commander's "unknown - // command" — for the next chat-family / budget commands. These are registered as stubs (C1). - for (const argv of [ - ['chat-resume', 'sess-1'], - ['chat-list'], - ['chat-export', 'sess-1'], - ['budget', 'resume', 'run-1'], - ]) { - const program = buildProgram(captureIo().io); - program.exitOverride(); - expect(() => program.parse(['node', 'relavium', ...argv])).toThrow(/is not available yet/); - } + // command" — for `budget resume` (a tracked follow-up). It is the last registered stub. + const program = buildProgram(captureIo().io); + program.exitOverride(); + expect(() => program.parse(['node', 'relavium', 'budget', 'resume', 'run-1'])).toThrow( + /is not available yet/, + ); }); }); diff --git a/apps/cli/src/commands/specs.ts b/apps/cli/src/commands/specs.ts index e30236d2..1d56a346 100644 --- a/apps/cli/src/commands/specs.ts +++ b/apps/cli/src/commands/specs.ts @@ -15,7 +15,10 @@ import type { GlobalOptions } from '../process/options.js'; import { selectChatDriver } from '../render/tui/chat-ink.js'; import { createOsKeychainStore } from '../secrets/os-keychain.js'; import { readSecretFromStdin } from '../secrets/read-secret.js'; -import { chatCommand } from './chat.js'; +import { agentRunCommand } from './agent-run.js'; +import { chatCommand, chatResumeCommand } from './chat.js'; +import { chatExportCommand } from './chat-export.js'; +import { chatListCommand } from './chat-list.js'; import { gateCommand } from './gate.js'; import { gateListCommand } from './gate-list.js'; import { listCommand } from './list.js'; @@ -32,11 +35,12 @@ import { statusCommand } from './status.js'; * The documented command surface (canonical home: * [commands.md](../../../../docs/reference/cli/commands.md)). `run` (2.D), `gate` + `gate list` (2.G/2.I), * `provider` (2.C), and the read commands `list` / `logs` / `status` (2.I) are real commands; the remaining - * confirmed pre-chat commands are registered as clean "not-yet-available" stubs until their own workstreams - * (the authoring commands at 2.J). `chat` (2.M) is a real command (`registerChat` below); the rest of the chat - * family (`chat-resume`/`chat-list`/`chat-export`/`agent run`) and `budget resume` are likewise registered as - * clean stubs here (so the documented "not available yet" message — not commander's "unknown command" — is - * what a user sees) until their workstreams land (2.N–2.Q; a tracked follow-up). + * confirmed pre-chat commands are registered as clean "not-yet-available" stubs until their own workstreams. + * The whole chat family is now live — `chat` (2.M), `chat-resume` (2.N), `chat-list` (2.O), `chat-export` + * (2.P), and `agent run` (2.Q) — via their `register*` functions below. The remaining `STUB_COMMANDS` are the + * authoring commands `create` / `import` / `export` (2.J), `init` (a later workstream), and `budget resume` + * (a tracked follow-up) — each shows the documented "not available yet (lands in …)" message (not commander's + * "unknown command") until it lands. */ /** The runtime context the real commands need; the boundary reads `result.exitCode` after parse. */ @@ -68,22 +72,6 @@ const STUB_COMMANDS: readonly StubSpec[] = [ summary: 'Export a workflow/agent to a portable YAML (secrets stripped).', landsIn: 'workstream 2.J', }, - { name: 'agent', summary: 'Manage and run agents.', landsIn: 'workstreams 2.N–2.Q' }, - { - name: 'chat-resume ', - summary: 'Reload a persisted session from history.db and continue the conversation.', - landsIn: 'workstreams 2.N–2.Q', - }, - { - name: 'chat-list', - summary: 'List past agent sessions (id, agent, last activity).', - landsIn: 'workstreams 2.N–2.Q', - }, - { - name: 'chat-export ', - summary: 'Export a session to a .relavium.yaml scaffold (ADR-0026).', - landsIn: 'workstreams 2.N–2.Q', - }, { name: 'budget', summary: 'Budget commands (resume a budget-paused run, etc.) — not yet available.', @@ -99,6 +87,10 @@ const STUB_COMMANDS: readonly StubSpec[] = [ export function registerCommands(program: Command, ctx?: CommandContext): void { registerRun(program, ctx); registerChat(program, ctx); + registerChatResume(program, ctx); + registerChatList(program, ctx); + registerChatExport(program, ctx); + registerAgent(program, ctx); registerGate(program, ctx); registerProvider(program, ctx); registerList(program, ctx); @@ -179,6 +171,143 @@ function registerChat(program: Command, ctx?: CommandContext): void { }); } +/** + * Register `relavium chat-resume ` (2.N) — reload a persisted session from `history.db` and continue + * it in the same REPL. Production wires the keychain-backed key resolver (2.C) + the TTY-aware driver, like + * `chat`. An unknown session id is a clean exit-2 invocation fault; `/exit` ends with exit code 4. + */ +function registerChatResume(program: Command, ctx?: CommandContext): void { + const chatResume = program + .command('chat-resume ') + .description('Reload a persisted session from history.db and continue the conversation.'); + + if (ctx === undefined) { + chatResume.action(() => { + throw new CliError( + 'not_implemented', + '`relavium chat-resume` requires the CLI runtime context.', + ); + }); + return; + } + + chatResume.action(async (sessionId: string) => { + ctx.result.exitCode = await chatResumeCommand( + { sessionId }, + { + io: ctx.io, + global: ctx.global, + providers: createProviderResolver(ctx.io.env, createOsKeychainStore()), + openSessionStore, + drive: selectChatDriver, + }, + ); + }); +} + +/** Register `relavium chat-list` (2.O) — list past agent sessions from durable `history.db` (id, agent, last activity). */ +function registerChatList(program: Command, ctx?: CommandContext): void { + const chatList = program + .command('chat-list') + .description('List past agent sessions (id, agent, title, last activity).'); + if (ctx === undefined) { + chatList.action(() => { + throw new CliError( + 'not_implemented', + '`relavium chat-list` requires the CLI runtime context.', + ); + }); + return; + } + chatList.action(() => { + // Pass the real opener explicitly (consistent with registerChat) so the production wiring is visible at + // the registration site and a future specs-level integration test can inject an in-memory store. + ctx.result.exitCode = chatListCommand({ io: ctx.io, global: ctx.global, openSessionStore }); + }); +} + +/** + * Register `relavium chat-export ` (2.P) — export a persisted session to a `.relavium.yaml` + * scaffold for review ([ADR-0026](../../../../docs/decisions/0026-session-export-to-workflow.md)). Writes + * `.relavium.yaml` in cwd by default; `--out ` overrides, `--force` overwrites an existing file. + */ +function registerChatExport(program: Command, ctx?: CommandContext): void { + const chatExport = program + .command('chat-export ') + .description('Export a session to a .relavium.yaml scaffold for review (ADR-0026).') + .option('--out ', 'write the scaffold here instead of .relavium.yaml') + .option('--force', 'overwrite an existing file at the target path'); + if (ctx === undefined) { + chatExport.action(() => { + throw new CliError( + 'not_implemented', + '`relavium chat-export` requires the CLI runtime context.', + ); + }); + return; + } + chatExport.action((sessionId: string, opts: { out?: string; force?: boolean }) => { + ctx.result.exitCode = chatExportCommand( + { + sessionId, + ...(opts.out === undefined ? {} : { out: opts.out }), + force: opts.force ?? false, + }, + { io: ctx.io, global: ctx.global, openSessionStore }, + ); + }); +} + +/** + * Register `relavium agent run ` (2.Q) — a one-shot, non-interactive agent invocation over the same + * `AgentSession` infra. The prompt is piped on stdin; `--input k=v` adds `{{ctx.*}}` variables; `--fixture` + * replays a recorded cassette (offline). Production resolves keys via the OS keychain (skipped under + * `--fixture`). A bare `relavium agent` (no subcommand) is a clean exit-2 invocation fault. + */ +function registerAgent(program: Command, ctx?: CommandContext): void { + const agent = program.command('agent').description('Manage and run agents.'); + const run = agent + .command('run ') + .description( + 'Run a single agent one-shot (prompt on stdin); --fixture replays a recorded cassette.', + ) + .option('--input ', 'a session {{ctx.*}} variable (repeatable)') + .option('--fixture ', 'replay a recorded LLM cassette (deterministic, offline)'); + + if (ctx === undefined) { + run.action(() => { + throw new CliError( + 'not_implemented', + '`relavium agent run` requires the CLI runtime context.', + ); + }); + agent.action(() => { + throw new CliError('not_implemented', '`relavium agent` requires the CLI runtime context.'); + }); + return; + } + + run.action(async (agentRef: string, opts: { input?: readonly string[]; fixture?: string }) => { + ctx.result.exitCode = await agentRunCommand( + { + agent: agentRef, + input: opts.input ?? [], + ...(opts.fixture === undefined ? {} : { fixture: opts.fixture }), + }, + { + io: ctx.io, + global: ctx.global, + // A non-fixture run resolves keys via the OS keychain → env var (2.C), like `run`/`chat`. + providers: createProviderResolver(ctx.io.env, createOsKeychainStore()), + }, + ); + }); + // A bare `relavium agent` (no `run`) is a clean invocation fault, not a thrown stack. + agent.action(() => { + throw new CliError('invalid_invocation', '`relavium agent` requires a subcommand (run).'); + }); +} + /** * Register `relavium gate [runId]` (2.G — resolve a pending human gate over the durable resume substrate) plus * its `gate list [runId]` subcommand (2.I — list the pending gates so an operator picks a `gateId`). The diff --git a/apps/cli/src/history/session-open.ts b/apps/cli/src/history/session-open.ts index e3b6cf5a..59694a08 100644 --- a/apps/cli/src/history/session-open.ts +++ b/apps/cli/src/history/session-open.ts @@ -1,6 +1,7 @@ import { createSessionStore, type Db, type SessionStore } from '@relavium/db'; import { openLocalDb } from '../db/open.js'; +import { CliError } from '../process/errors.js'; /** An opened session store plus the handle to close its SQLite connection at REPL end. */ export interface OpenedSessionStore { @@ -15,10 +16,23 @@ export interface OpenedSessionStore { * the session counterpart of {@link openHistoryStore} (2.H run history), sharing the **same** db file and * the unencrypted-at-rest, `0600`/`0700`-guarded posture ([ADR-0050](../../../../docs/decisions/0050-cli-history-db-at-rest-posture.md); * there is no separate `sessions.db`, per [config-spec.md](../../../../docs/reference/contracts/config-spec.md) `[chat]`). - * Production (`commands/chat.ts`) wires this; the unit tests drive a `createSessionStore` over an in-memory - * db directly, so they never touch the user's home. + * Production (`commands/chat.ts`, `chat-list`, …) wires this; the unit tests drive a `createSessionStore` + * over an in-memory db directly, so they never touch the user's home. + * + * A db-open fault (cannot create / open / migrate the file) is an INVOCATION fault (exit 2), surfaced before + * any session work — mirroring {@link openHistoryReader} so every session command (`chat`, `chat-list`, and + * the upcoming resume/export) reports an unreadable `history.db` as a clean exit 2, not an opaque exit 1. */ export function openSessionStore(homeDir: string): OpenedSessionStore { - const { db, close } = openLocalDb(homeDir); - return { store: createSessionStore(db), db, close }; + let opened: { db: Db; close: () => void }; + try { + opened = openLocalDb(homeDir); + } catch (err) { + throw new CliError( + 'invalid_invocation', + `could not open the session history database: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ); + } + return { store: createSessionStore(opened.db), db: opened.db, close: opened.close }; } diff --git a/apps/cli/src/process/exit-codes.ts b/apps/cli/src/process/exit-codes.ts index ffbf8f18..ffc74a0f 100644 --- a/apps/cli/src/process/exit-codes.ts +++ b/apps/cli/src/process/exit-codes.ts @@ -13,7 +13,10 @@ export const EXIT_CODES = { invalidInvocation: 2, /** Run paused at a human gate (CI / non-interactive mode) — resume with `relavium gate`. */ gatePaused: 3, - /** A chat session ended via `/exit` (interactive `relavium chat`) — wired at 2.M. */ + /** + * A chat session ended by the user — via `/exit`, `/cancel` (or Ctrl-C in TTY mode), or an input-stream + * EOF — from a `relavium chat` (2.M) or `relavium chat-resume` (2.N) REPL (both drive the same loop). + */ chatEnded: 4, } as const; diff --git a/apps/cli/src/render/tui/chat-ink.tsx b/apps/cli/src/render/tui/chat-ink.tsx index a2818bbe..74ef3e8b 100644 --- a/apps/cli/src/render/tui/chat-ink.tsx +++ b/apps/cli/src/render/tui/chat-ink.tsx @@ -1,7 +1,12 @@ import { Box, Static, Text, render, useInput } from 'ink'; import { createElement, useRef, useState, useSyncExternalStore, type ReactElement } from 'react'; -import { drivePlain, type ChatDriveContext, type ChatDriver } from '../../commands/chat.js'; +import { + driveJson, + drivePlain, + type ChatDriveContext, + type ChatDriver, +} from '../../commands/chat.js'; import { EXIT_CODES } from '../../process/exit-codes.js'; import { colorProps } from './projection.js'; import { spinnerFrame } from './format.js'; @@ -155,6 +160,12 @@ export function ChatApp(props: Readonly): ReactElement { /** The TTY ink driver: mount {@link ChatApp}, run the frame loop, and finalize on exit. */ export function driveInk(ctx: ChatDriveContext): Promise { + // The resume banner (2.N): print it once before mounting ink so it scrolls into the terminal history above + // the live region — the TTY counterpart of the line drivePlain writes, so a resumed session is visibly a + // resume (not just an N-turn footer). A fresh session has no intro and prints nothing here. + if (ctx.intro !== undefined) { + ctx.io.writeOut(`${ctx.intro}\n`); + } // Mirror the live stream into the view store the component projects. const unsubscribe = ctx.handle.subscribe((event) => ctx.store.apply(event)); // Open the session ONLY now — the store is subscribed, so the synchronous session:started (which carries @@ -235,6 +246,11 @@ export function driveInk(ctx: ChatDriveContext): Promise { } } -/** Select the chat driver by surface: a real TTY (and not `--json`, which is 2.Q) ⇒ ink; else the plain loop. */ -export const selectChatDriver: ChatDriver = (ctx) => - ctx.io.stdoutIsTty && !ctx.global.json ? driveInk(ctx) : drivePlain(ctx); +/** + * Select the chat driver by surface (2.Q): `--json` ⇒ the headless NDJSON `SessionEvent` stream (machine + * output wins over the TTY); else a real TTY ⇒ the ink REPL; else the plain non-TTY line loop. + */ +export const selectChatDriver: ChatDriver = (ctx) => { + if (ctx.global.json) return driveJson(ctx); // machine output wins over the TTY + return ctx.io.stdoutIsTty ? driveInk(ctx) : drivePlain(ctx); +}; diff --git a/apps/cli/src/render/tui/chat-projection.ts b/apps/cli/src/render/tui/chat-projection.ts index 2babb3ab..8d976c98 100644 --- a/apps/cli/src/render/tui/chat-projection.ts +++ b/apps/cli/src/render/tui/chat-projection.ts @@ -38,11 +38,12 @@ export function stripTerminalControls(text: string): string { } /** - * Sanitize a single-line dynamic identifier (a tool id, the bound model name) for terminal display: strip the - * ANSI/OSC/control bytes {@link stripTerminalControls} removes, then collapse any surviving tab/newline to a - * single space so the value cannot spoof extra terminal lines or columns inside a one-line annotation/footer. + * Sanitize a single-line dynamic identifier (a tool id, the bound model name, a persisted session title) for + * terminal display: strip the ANSI/OSC/control bytes {@link stripTerminalControls} removes, then collapse any + * surviving tab/newline to a single space so the value cannot spoof extra terminal lines or columns inside a + * one-line annotation/footer/list row. */ -function sanitizeInline(text: string): string { +export function sanitizeInline(text: string): string { return stripTerminalControls(text).replace(/[\t\n]+/g, ' '); } diff --git a/apps/cli/src/render/tui/chat-store.ts b/apps/cli/src/render/tui/chat-store.ts index 6daa66be..6ea09a2a 100644 --- a/apps/cli/src/render/tui/chat-store.ts +++ b/apps/cli/src/render/tui/chat-store.ts @@ -5,6 +5,7 @@ import { appendUserMessage, initialSessionViewState, reduceSessionEvent, + type SessionViewSeed, type SessionViewState, } from './session-view-model.js'; @@ -60,9 +61,9 @@ const HIGH_FREQUENCY_EVENTS: ReadonlySet = new 'cost:updated', ]); -export function createChatStore(color: boolean): ChatStoreController { +export function createChatStore(color: boolean, seed?: SessionViewSeed): ChatStoreController { const listeners = new Set<() => void>(); - let state = initialSessionViewState(); + let state = initialSessionViewState(seed); let tickCount = 0; let dirty = false; let snapshot: ChatStoreSnapshot = { state, tick: tickCount, color }; diff --git a/apps/cli/src/render/tui/session-view-model.test.ts b/apps/cli/src/render/tui/session-view-model.test.ts index a5c6b505..1c1d99d6 100644 --- a/apps/cli/src/render/tui/session-view-model.test.ts +++ b/apps/cli/src/render/tui/session-view-model.test.ts @@ -97,6 +97,29 @@ describe('session-view-model', () => { expect(state.transcript).toEqual([{ role: 'user', text: 'hello' }]); }); + it('a fresh (unseeded) initial state has an empty header + zero totals', () => { + const state = initialSessionViewState(); + expect(state.agentRef).toBeUndefined(); + expect(state.model).toBeUndefined(); + expect(state.cumulativeCostMicrocents).toBe(0); + expect(state.turnCount).toBe(0); + }); + + it('a resume seed (2.N) pre-sets the header model/agent + carried cost/turn count', () => { + const state = initialSessionViewState({ + agentRef: 'coder', + model: 'claude-opus-4-8', + cumulativeCostMicrocents: 4200, + turnCount: 3, + }); + expect(state.agentRef).toBe('coder'); + expect(state.model).toBe('claude-opus-4-8'); + expect(state.cumulativeCostMicrocents).toBe(4200); + expect(state.turnCount).toBe(3); + expect(state.status).toBe('idle'); // a resumed session is idle, ready for the next turn + expect(state.transcript).toEqual([]); // the prior transcript stays durable, not replayed into the view + }); + it('streams a text turn: running while live, then a completed assistant entry with a summary', () => { const e = events(); const mid = reduceAll([e.started(), e.turnStarted(), e.token('hel'), e.token('lo')]); diff --git a/apps/cli/src/render/tui/session-view-model.ts b/apps/cli/src/render/tui/session-view-model.ts index b97feaa6..3cde3436 100644 --- a/apps/cli/src/render/tui/session-view-model.ts +++ b/apps/cli/src/render/tui/session-view-model.ts @@ -79,14 +79,31 @@ export const MAX_LIVE_TOOL_CALLS = 16; /** Recent warnings kept for display. */ export const MAX_WARNINGS = 6; -export function initialSessionViewState(): SessionViewState { +/** + * Optional header seed for a RESUMED session (2.N): a resumed `AgentSession` lands directly at idle and never + * re-emits `session:started`, so the view header (the bound agent/model) and the carried-over running totals + * (prior cost + completed-turn count) would otherwise show empty/zero until the first new turn. The command + * seeds them from the reconstructed state so the footer reflects the continuing session from the first frame. + * A fresh session passes no seed and is identical to before. + */ +export interface SessionViewSeed { + readonly agentRef?: string; + readonly model?: string; + readonly cumulativeCostMicrocents?: number; + readonly turnCount?: number; +} + +export function initialSessionViewState(seed?: SessionViewSeed): SessionViewState { return { + // agentRef/model are required-optional under exactOptionalPropertyTypes: spread the key in only when set. + ...(seed?.agentRef === undefined ? {} : { agentRef: seed.agentRef }), + ...(seed?.model === undefined ? {} : { model: seed.model }), status: 'idle', transcript: [], liveTokens: '', liveToolCalls: [], - cumulativeCostMicrocents: 0, - turnCount: 0, + cumulativeCostMicrocents: seed?.cumulativeCostMicrocents ?? 0, + turnCount: seed?.turnCount ?? 0, turnStartedAtMs: undefined, gapDetected: false, warnings: [], diff --git a/docs/reference/cli/README.md b/docs/reference/cli/README.md index 88ba3e7c..b3628451 100644 --- a/docs/reference/cli/README.md +++ b/docs/reference/cli/README.md @@ -12,3 +12,4 @@ Part of [reference/](../README.md). |------|-----------| | [commands.md](commands.md) | The full `relavium` command surface (run, chat, chat-resume/list/export, agent run, list, create, import, export, logs, gate, init, provider) plus interactive TUI vs CI JSON mode. | | [chat-session.md](chat-session.md) | The `relavium chat` agent-session REPL — entry, agent/model selection, the multi-turn loop, streaming, tool availability, the `--json` session-event stream, and exit code 4. | +| [agent-run-fixture.md](agent-run-fixture.md) | The `relavium agent run --fixture` cassette format — a `StreamChunk[][]` JSON recording of an agent run's LLM stream, for deterministic offline replay. | diff --git a/docs/reference/cli/agent-run-fixture.md b/docs/reference/cli/agent-run-fixture.md new file mode 100644 index 00000000..5bc0e9f2 --- /dev/null +++ b/docs/reference/cli/agent-run-fixture.md @@ -0,0 +1,121 @@ +# `agent run` fixture cassette + +> Last updated: 2026-06-26 + +- **Status**: Reference (the cassette format consumed by `relavium agent run --fixture`, workstream **2.Q**) +- **Surface**: CLI (`relavium agent run`) +- **Scope**: Phase 1 design, local-first. A test/CI artifact only — never part of a live run. +- **Related**: [commands.md](commands.md), [chat-session.md](chat-session.md), [regression-harness.md](regression-harness.md), [../shared-core/llm-provider-seam.md](../shared-core/llm-provider-seam.md), [../contracts/sse-event-schema.md](../contracts/sse-event-schema.md), [../../decisions/0011-internal-llm-abstraction.md](../../decisions/0011-internal-llm-abstraction.md), [../../decisions/0049-cli-machine-output-contract.md](../../decisions/0049-cli-machine-output-contract.md) + +A **fixture cassette** is a committed JSON file that records an LLM provider's streamed output for a single +`relavium agent run` turn, so that run is **deterministic and fully offline** — no key, no network, no live +provider. It is the on-disk form of the in-memory `scriptedProvider` the `AgentSession` unit tests already +use: `relavium agent run --fixture ` loads the cassette, builds a replay provider +over the `@relavium/llm` seam ([ADR-0011](../../decisions/0011-internal-llm-abstraction.md)), and answers +each `provider.stream()` call from the recorded chunk lists in order. + +This is the canonical home for the cassette **format**; it is the CLI's small, dependency-free analogue of +the `@relavium/llm` conformance replay — there is **no new runtime dependency** and no vendor type in the +file (every recorded chunk is a Relavium-owned `StreamChunk`, never a provider SDK shape). + +## When to use + +- A **regression fixture**: an `agent run` cassette committed under a test/harness directory, replayed on + every PR so an agent path is exercised end-to-end without a live provider (the agent-fixture half the + [regression harness](regression-harness.md) deferred until a replay-provider seam existed). +- A **reproducible demo / bug report**: capture a turn's model output once, then re-run it anywhere. + +It is **not** a session-persistence format (that is `history.db`, [ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md)) +and **not** a workflow (that is `.relavium.yaml`). A cassette is throwaway test input. + +## File format + +A cassette is a single JSON object: + +```json +{ + "schema_version": "1.0", + "provider": "anthropic", + "model": "claude-sonnet-4-6", + "calls": [ + [ + { "type": "tool_call_start", "id": "call-1", "name": "read_file" }, + { "type": "tool_call_end", "id": "call-1" }, + { "type": "stop", "stopReason": "tool_use", "usage": { "inputTokens": 12, "outputTokens": 4 } } + ], + [ + { "type": "text_delta", "text": "The file exports a single function." }, + { "type": "stop", "stopReason": "stop", "usage": { "inputTokens": 30, "outputTokens": 8 } } + ] + ] +} +``` + +| Field | Type | Meaning | +|-------|------|---------| +| `schema_version` | `"1.0"` | The cassette format version. An unknown version is a load fault (exit `2`). | +| `provider` | `ProviderId` | The id the replay provider answers as (`anthropic` / `openai` / `gemini` / `deepseek`). The agent's resolved provider must match, or the run fails fast. | +| `model` | `string` (optional) | Informational only — the recorded model id, for provenance. Not used to route. | +| `calls` | `StreamChunk[][]` | One entry per `provider.stream()` invocation during the turn, in order. | + +- **Each `calls[i]` is the ordered `StreamChunk[]` replayed on the i-th `stream()` call.** A one-shot + `agent run` is a single user turn, but that turn may make **several** `stream()` calls when the agent uses + a tool: `calls[0]` is the initial turn (typically ending in a `stop` with `stopReason: "tool_use"`), + `calls[1]` is the continuation after the tool result, and so on — exactly the `scripts: StreamChunk[][]` + shape of the in-memory `scriptedProvider`. A plain (no-tool) turn is a single entry ending in + `stopReason: "stop"`. +- **Every chunk is a `StreamChunk`** ([llm-provider-seam.md](../shared-core/llm-provider-seam.md), the + `StreamChunkSchema` discriminated union): `text_delta`, `tool_call_start` / `tool_call_delta` / + `tool_call_end`, the `reasoning_*` and `media_*` triads, a provider-executed `tool_result`, and the + terminal `stop` (carrying `stopReason` + `usage`) or `error`. A well-formed turn ends in exactly one + `stop` (or `error`). +- **`usage`** on the `stop` chunk carries `inputTokens` / `outputTokens` (and optional `reasoningTokens` ≤ + `outputTokens`); these drive the recorded turn's token/cost accounting deterministically. + +## Validation and loading + +- The cassette is parsed as JSON, then **every chunk is validated against `StreamChunkSchema`** at the load + boundary (the same Zod schema the live adapters produce against). A malformed cassette — bad JSON, an + unknown `schema_version`, a chunk that fails the schema, or a non-array `calls` — is an **invalid + invocation (exit `2`)**, surfaced as a file-attributed error on stderr, never a stack trace as primary + output (consistent with every other CLI load fault). +- The replay provider answers **only** for the cassette's `provider` id and returns a fixed, non-secret + dummy key from `keyFor` — it never reads the OS keychain or an env var, so a fixture run needs no key + configured. **A cassette is NOT inherently secret-free**: its `text_delta` and `tool_result` chunks + capture real model output and tool results, which **may contain sensitive content** — scrub/redact a + cassette before committing it, exactly as any other recorded fixture (the same no-secret rules as every + other surface apply, [keychain-and-secrets.md](../desktop/keychain-and-secrets.md)). +- An **unscripted** `stream()` call (the agent makes more provider calls than the cassette recorded) fails + **loudly** — an extra LLM invocation is a fixture/agent mismatch bug, never a silent empty turn (mirroring + `scriptedProvider`). The run exits non-zero rather than fabricating output. + +## Usage + +The one-shot **prompt is read from stdin** (the `echo … | relavium agent run` idiom); `--fixture` makes the +run deterministic and offline. _(`--input k=v` is **reserved** — currently rejected (exit `2`): a session +does not yet interpolate `{{ctx.*}}` into the agent prompt, a tracked engine follow-up, +[deferred-tasks.md](../../roadmap/deferred-tasks.md).)_ + +```bash +# deterministic, offline single-turn agent run (prompt on stdin) +echo "review this file" | relavium agent run code-reviewer --fixture ./fixtures/review.cassette.json + +# machine-readable: the session:* + agent:* NDJSON stream (ADR-0049), one event per line on stdout +echo "review this file" | relavium agent run code-reviewer --fixture ./fixtures/review.cassette.json --json +``` + +Under `--json` the run emits the same [`SessionEvent`](../contracts/sse-event-schema.md#session-event-namespace) ++ per-turn `agent:*` / `cost:updated` NDJSON stream a live `agent run --json` produces — the cassette +changes only *where the model bytes come from*, never the event contract. `agent run` is a **non-interactive +one-shot** (a single turn, then exit — not persisted), so its exit code is the **turn's outcome**: `0` on +success, `1` on a turn error; an invocation fault (no stdin prompt / unknown agent / bad `--input` / a +malformed cassette) is `2` ([commands.md](commands.md#exit-codes)). This is distinct from the interactive +`relavium chat` REPL, which a user *ends* (exit `4`). + +## Out of scope (v1.0) + +- **Multi-turn cassettes.** A cassette records one `agent run` turn (its one-or-more `stream()` calls). A + multi-user-turn chat replay is a possible later extension (a `turns: Cassette[]` wrapper) and is not in + v1.0 — `relavium chat --json` reads real stdin and uses the live provider. +- **Auto-recording.** v1.0 cassettes are authored/committed by hand or captured by a test helper; a + `--record` capture mode is a future affordance, tracked in [deferred-tasks.md](../../roadmap/deferred-tasks.md). diff --git a/docs/reference/cli/chat-session.md b/docs/reference/cli/chat-session.md index 6bbac4b3..5d0faf1a 100644 --- a/docs/reference/cli/chat-session.md +++ b/docs/reference/cli/chat-session.md @@ -2,7 +2,7 @@ > Last updated: 2026-06-26 -- **Status**: Reference (the interactive REPL + `--agent`, `/exit`/`/cancel`, exit code 4, and durable persistence are implemented in **2.M**; `chat-resume`/`chat-list`/`chat-export`/`--json`/`agent run` are 2.N–2.Q) +- **Status**: Reference — the whole chat family is live: the interactive REPL + `--agent`, `/exit`/`/cancel`, exit code 4, durable persistence (**2.M**); `chat-resume` (**2.N**); `chat-list` (**2.O**); `chat-export` + the in-REPL `/export` (**2.P**); `chat --json` + `agent run` (+ `--fixture`) (**2.Q**) - **Surface**: CLI (`relavium chat`) - **Scope**: Phase 1 design, local-first. Same `@relavium/core` engine as every other surface. - **Related**: [commands.md](commands.md), [../contracts/agent-session-spec.md](../contracts/agent-session-spec.md), [../contracts/sse-event-schema.md](../contracts/sse-event-schema.md), [../contracts/config-spec.md](../contracts/config-spec.md), [../shared-core/built-in-tools.md](../shared-core/built-in-tools.md), [../shared-core/llm-provider-seam.md](../shared-core/llm-provider-seam.md), [../../runbooks/add-a-provider-key.md](../../runbooks/add-a-provider-key.md), [../../decisions/0024-agent-first-entry-point-agentsession.md](../../decisions/0024-agent-first-entry-point-agentsession.md), [../../decisions/0026-session-export-to-workflow.md](../../decisions/0026-session-export-to-workflow.md) @@ -47,7 +47,7 @@ A small set of slash commands drives the REPL itself (not the agent): | --- | --- | | `/exit` | End the session cleanly and quit the REPL (**exit code 4**, below). | | `/cancel` | End the session (aborting any in-flight turn — relevant when entered as **Ctrl-C** mid-turn in TTY mode; a typed `/cancel` runs between turns). In Phase 1 the engine has no per-turn abort that keeps a session alive, so `/cancel` terminates it — but the session is **persisted and resumable** via `relavium chat-resume ` (2.N). Exits with code 4. | -| `/export` | _(lands in **2.P** with `relavium chat-export`)_ Export the session to a `.relavium.yaml` scaffold (same ADR-0026 contract). | +| `/export` | Export the session-so-far to a `.relavium.yaml` scaffold (same ADR-0026 contract as `relavium chat-export`). Writes the file (named `.relavium.yaml`) and reports the path; under `--json` it emits a `session:exported` event on the stream. It does **not** mark the session row `exported` (a later turn's persist would clobber that) — use `relavium chat-export` for the durable provenance mark. **Live (2.P / 2.Q).** | An unrecognized `/…` command prints a one-line, secret-free notice and the prompt returns. In a TTY, **Ctrl-C** is equivalent to `/cancel` (the `ink` REPL runs in raw mode, so the kernel does not raise SIGINT — the REPL handles it). @@ -59,11 +59,15 @@ In interactive mode the REPL renders the assistant turn live: streaming token ou A chat session uses the **same** built-in `ToolRegistry` as a workflow agent ([built-in-tools.md](../shared-core/built-in-tools.md)): the same tools, the same filesystem **scope tiers**, and the same mandatory guardrails (`run_command` only ever runs commands on the `allowedCommands` allowlist — empty/absent ⇒ disabled; `git_commit` behind approval). Per [ADR-0029](../../decisions/0029-tool-policy-hardening.md), a session may only **narrow** the agent's `tools:`, never escalate; a `secret`-typed value is never interpolated into a prompt or tool text; and `http_request` / MCP egress is subject to the same SSRF policy as a workflow. The tool surface, FS tier, and command allowlist for chat all resolve from the `[chat]` block of [config-spec.md](../contracts/config-spec.md), which points back to those canonical homes. -## `--json` session-event stream _(lands in **2.Q**)_ +## `--json` session-event stream -> _Not yet available._ The machine-readable NDJSON stream below is the target spec for workstream **2.Q**, not current behavior. `--json` is already a recognized global flag, but it does not yet emit `SessionEvent` JSON: `selectChatDriver` routes a `--json` invocation (and any non-TTY surface) to the **plain line loop**, so today `relavium chat --json` produces the plain text output, not the NDJSON below. (`session:exported` likewise depends on `/export`, which is **2.P**.) +> **Implementation status (2.Q).** Live: `selectChatDriver` routes a `--json` invocation (`--json` wins +> over a TTY) to the headless `driveJson` driver, which emits the `SessionEvent` NDJSON stream on stdout — +> all diagnostics (the unknown-slash notice, the `/export` confirmation) go to stderr, so stdout is a pure +> `SessionEvent` stream. `/export` under `--json` emits a `session:exported` event on the stream (routed +> through the session bus, so its `sequenceNumber` stays monotonic with the surrounding events). -For scripting and non-interactive use, `--json` will switch the REPL to a machine-readable [`SessionEvent`](../contracts/sse-event-schema.md#session-event-namespace) stream — one JSON object per line (NDJSON), the chat analogue of `relavium run --json`. Messages are read from stdin (one user turn per line) and the `session:*` events (`session:started`, `session:turn_started`, `session:turn_completed`, `session:cancelled`, `session:exported`) plus the per-turn `agent:*` / `cost:updated` events are emitted on stdout, each carrying the `sessionId`: +For scripting and non-interactive use, `--json` switches the REPL to a machine-readable [`SessionEvent`](../contracts/sse-event-schema.md#session-event-namespace) stream — one JSON object per line (NDJSON), the chat analogue of `relavium run --json`. Messages are read from stdin (one user turn per line) and the `session:*` events (`session:started`, `session:turn_started`, `session:turn_completed`, `session:cancelled`, `session:exported`) plus the per-turn `agent:*` / `cost:updated` events are emitted on stdout, each carrying the `sessionId`; an input-stream EOF ends the session with the `session:cancelled` terminal and exit code 4: ```bash echo "summarize ./README.md" | relavium chat --agent code-reviewer --json @@ -73,7 +77,7 @@ The session namespace is **disjoint** from the run namespace (keyed by `sessionI ## Exit code 4 -A `relavium chat` REPL ends with code **`4`** — the canonical **chat-session-ended** code defined in [commands.md](commands.md#exit-codes) — on any of: `/exit`, `/cancel` (or **Ctrl-C** in TTY mode), or an **input-stream EOF** (the user closes stdin in the plain non-TTY mode; the `--json` headless EOF is 2.Q). It is deliberately distinct from a successful workflow run (`0`) and a hard failure (`1`) so a wrapper script can tell "the user ended the chat" apart from either. A crash or an unrecoverable provider error still exits `1`; bad arguments still exit `2`. +A `relavium chat` REPL ends with code **`4`** — the canonical **chat-session-ended** code defined in [commands.md](commands.md#exit-codes) — on any of: `/exit`, `/cancel` (or **Ctrl-C** in TTY mode), or an **input-stream EOF** (the user closes stdin — in plain non-TTY mode, or under `--json`). It is deliberately distinct from a successful workflow run (`0`) and a hard failure (`1`) so a wrapper script can tell "the user ended the chat" apart from either. A crash or an unrecoverable provider error still exits `1`; bad arguments still exit `2`. (The one-shot `relavium agent run` is **not** a REPL — it exits with the turn's outcome, `0`/`1`, never `4`.) ## API keys @@ -81,4 +85,4 @@ Provider keys are read from the **OS keychain** exactly as for `relavium run` ## Export to workflow -`/export` (interactive) and `relavium chat-export ` drive the **one** export contract: the session's assistant turns become a linear chain of `agent` nodes and the full transcript is preserved as YAML metadata, for review before commit ([ADR-0026](../../decisions/0026-session-export-to-workflow.md)). The mapping is owned by [agent-session-spec.md](../contracts/agent-session-spec.md#export-to-workflow); it **produces** the format owned by [../contracts/workflow-yaml-spec.md](../contracts/workflow-yaml-spec.md). Parallel / conditional / loop structure is not auto-inferred — the export is a **scaffold**. +`/export` (interactive) and `relavium chat-export ` drive the **one** export contract: the session's assistant turns become a linear chain of `agent` nodes and the full transcript is preserved as YAML metadata, for review before commit ([ADR-0026](../../decisions/0026-session-export-to-workflow.md)). The two differ only in their provenance side-effect: `relavium chat-export` additionally marks the session row `status: exported` and records the written path (a durable provenance mark surfaced by `chat-list`); the in-REPL `/export` writes the scaffold but does **not** mark the row, since a later turn's persist would clobber the marker. The mapping is owned by [agent-session-spec.md](../contracts/agent-session-spec.md#export-to-workflow); it **produces** the format owned by [../contracts/workflow-yaml-spec.md](../contracts/workflow-yaml-spec.md). Parallel / conditional / loop structure is not auto-inferred — the export is a **scaffold**. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 08b0522a..a94cb4e5 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1,6 +1,6 @@ # CLI Command Reference (`relavium`) -> Last updated: 2026-06-24 +> Last updated: 2026-06-26 - **Status**: Reference (partial — surface defined, exact flags to be finalized as the CLI is built) - **Surface**: CLI (`relavium`) @@ -30,7 +30,7 @@ The CLI auto-detects its environment and switches presentation accordingly: |------|------|----------| | **Interactive TUI** | TTY attached, no `--json` | `ink`-rendered live view: animated per-node status, streaming token output for the active node, final cost/duration summary | | **Plain** | No TTY or `CI=true` (and no `--json`) | The TUI is disabled; a terse line-per-lifecycle-event human renderer writes to stdout | -| **NDJSON** | `--json` (anywhere on the command line) | The machine contract: stdout is a pure [RunEvent](../contracts/sse-event-schema.md) NDJSON stream; all diagnostics go to stderr. See [The `--json` machine-output contract](#the---json-machine-output-contract) | +| **NDJSON** | `--json` (anywhere on the command line) | The machine contract: stdout is a pure NDJSON stream — [RunEvent](../contracts/sse-event-schema.md)s for `run` / `gate`, or [SessionEvent](../contracts/sse-event-schema.md#session-event-namespace)s for `chat` / `agent run` — and all diagnostics go to stderr. See [The `--json` machine-output contract](#the---json-machine-output-contract) | NDJSON is engaged **only** by `--json` (the explicit machine opt-in); a non-TTY or `CI=true` environment disables the interactive TUI but does not by itself switch stdout to NDJSON @@ -93,7 +93,7 @@ before parsing the subcommand). ## Commands -The command set below is the confirmed surface. Commands ship **per workstream**: `run` (2.D), `gate` + `gate list` (2.G/2.I), `provider` (2.C), the read commands `list` / `logs` / `status` (2.I), and **`chat`** (2.M) are **live**; the authoring commands (`create` / `import` / `export`) land at **2.J**, the rest of the chat family (`chat-resume` / `chat-list` / `chat-export` / `agent run`) at **2.N–2.Q**, and `budget resume` is a [tracked follow-up](../../roadmap/deferred-tasks.md). Invoking a not-yet-shipped command exits with a clean "not available yet (lands in …)" message. Subcommands marked _(planned)_ are intended but not yet locked. +The command set below is the confirmed surface. Commands ship **per workstream**: `run` (2.D), `gate` + `gate list` (2.G/2.I), `provider` (2.C), the read commands `list` / `logs` / `status` (2.I), and the whole agent-first chat family — **`chat`** (2.M), **`chat-resume`** (2.N), **`chat-list`** (2.O), **`chat-export`** (2.P), and **`chat --json` + `agent run`** (2.Q) — are all **live**; the authoring commands (`create` / `import` / `export`) land at **2.J**, and `budget resume` is a [tracked follow-up](../../roadmap/deferred-tasks.md). Invoking a not-yet-shipped command exits with a clean "not available yet (lands in …)" message. Subcommands marked _(planned)_ are intended but not yet locked. | Command | Purpose | |---------|---------| @@ -102,7 +102,7 @@ The command set below is the confirmed surface. Commands ship **per workstream** | `relavium chat-resume ` | Reload a persisted session from `history.db` and continue the conversation. | | `relavium chat-list` | List past agent sessions (id, agent, last activity), the way `relavium list` lists workflows. | | `relavium chat-export ` | Export a session to a `.relavium.yaml` scaffold for review ([ADR-0026](../../decisions/0026-session-export-to-workflow.md)). | -| `relavium agent run [--input k=v]` | Run a single agent **one-shot** (non-interactive) on the same AgentSession infra — a chat session with one turn, then exit. | +| `relavium agent run [--fixture ] [--json]` | Run a single agent **one-shot** (non-interactive) on the same AgentSession infra — the prompt is read from stdin, one turn, then exit. See [`relavium agent run`](#relavium-agent-run) and [agent-run-fixture.md](agent-run-fixture.md). | | `relavium list` | List discovered workflows (and, with a flag, agents) in the current project. | | `relavium create` | Scaffold a new workflow or agent YAML via an interactive wizard. | | `relavium import ` | Import an external `.relavium.yaml` / `.agent.yaml` into the project. | @@ -160,7 +160,7 @@ Shows the currently active/paused runs (from `runs` + `step_executions`) and eac ### Read-command `--json` output -The non-streaming read commands (`list` / `status` / `gate list`, and `logs`) keep the CLI to **one machine-output idiom**: `--json` emits **one result record per line** (NDJSON, `jq`-friendly, stdout-pure with diagnostics on stderr) — the same line-oriented shape `relavium run --json` uses for its `RunEvent` stream ([ADR-0049](../../decisions/0049-cli-machine-output-contract.md)). For `logs --json` the records ARE raw `RunEvent`s — the same `RunEvent` data the run streamed (re-serialized from the persisted log, so the field order may differ from the live `run --json` bytes); for the others they are the per-command result records documented above. An unknown `runId` (`logs` / `gate list`) is the structured pre-run fault on stderr with exit `2`, stdout empty — exactly as for `run`. +The non-streaming read commands (`list` / `status` / `gate list` / `chat-list`, and `logs`) keep the CLI to **one machine-output idiom**: `--json` emits **one result record per line** (NDJSON, `jq`-friendly, stdout-pure with diagnostics on stderr) — the same line-oriented shape `relavium run --json` uses for its `RunEvent` stream ([ADR-0049](../../decisions/0049-cli-machine-output-contract.md)). For `logs --json` the records ARE raw `RunEvent`s — the same `RunEvent` data the run streamed (re-serialized from the persisted log, so the field order may differ from the live `run --json` bytes); for the others they are the per-command result records documented above. An unknown `runId` (`logs` / `gate list`) is the structured pre-run fault on stderr with exit `2`, stdout empty — exactly as for `run`. (`chat-export --json` is **not** a read command — it emits a single `session:exported` **event**, not a result record, since the export is a session-lifecycle action.) ### `relavium gate` @@ -195,6 +195,31 @@ relavium gate list # just one run's - It rests on the **same** persisted-event reconstruction the [`gate`](#relavium-gate) resume path uses, so the listing and the resume can never disagree on what is pending. - Human output is one line per gate (` node= ""`); under `--json` each pending gate is one NDJSON record — `{ runId, gateId, nodeId, gateType, message, expiresAt? }` (see [Read-command `--json` output](#read-command---json-output)). +### `relavium chat-list` + +Lists past [agent sessions](../contracts/agent-session-spec.md) from durable `history.db`, most-recently-updated first — the session counterpart of `relavium list`. Human output is one line per session (` [] ""`); an empty history is reported clearly (exit `0`). Under `--json` each session is one NDJSON record — `{ sessionId, agentSlug, title, status, modelId, createdAt, updatedAt, totalCostMicrocents }`, where `title` / `modelId` are `null` when absent (see [Read-command `--json` output](#read-command---json-output)). Soft-deleted sessions are excluded. + +### `relavium chat-export` + +Exports a persisted session to a `.relavium.yaml` **scaffold** for review before commit ([ADR-0026](../../decisions/0026-session-export-to-workflow.md)) — the same contract the in-REPL `/export` drives. Writes `<sessionId>.relavium.yaml` in cwd by default (the file name is keyed on the unique session id, so two sessions never collide); `--out <path>` overrides, `--force` overwrites an existing target. The session row is marked `exported` with the written path. Under `--json` it emits a single `session:exported` event (`{ type, sessionId, timestamp, sequenceNumber, workflowPath }`). An unknown sessionId or an existing target without `--force` exits `2`; success is exit `0`. + +### `relavium agent run` + +Runs a single agent **one-shot** (non-interactive) on the same `AgentSession` infra as `relavium chat` — a session with one turn, then exit. The agent-first headline as a scriptable, CI-friendly primitive. + +```bash +echo "summarize ./README.md" | relavium agent run code-reviewer +echo "review it" | relavium agent run ./agents/coder.agent.yaml --json +echo "review it" | relavium agent run code-reviewer --fixture ./fixtures/review.cassette.json --json +``` + +- The `<agent>` argument is required — a `.agent.yaml` path or a `.relavium/`-discoverable agent id (resolved by the same strict parser `relavium chat --agent` uses). An unknown agent is an invalid invocation (exit `2`). +- **The prompt is read from stdin** (the `echo … | relavium agent run` idiom); an empty stdin is an invalid invocation (exit `2`). +- `--input k=v` is **reserved** — currently **rejected** (exit `2`): a session does not yet interpolate `{{ctx.*}}` into the agent's prompt (the engine passes `system_prompt` verbatim), so the flag is failed loud rather than exposed as an inert no-op. It re-opens when session prompt interpolation lands (a tracked engine follow-up, [deferred-tasks.md](../../roadmap/deferred-tasks.md)). +- `--fixture <path>` replays a recorded LLM **cassette** so the run is deterministic and fully offline (no key, no network, no keychain) — the format is documented in [agent-run-fixture.md](agent-run-fixture.md). A malformed cassette exits `2`. +- `--json` emits the [`SessionEvent`](../contracts/sse-event-schema.md#session-event-namespace) NDJSON stream on stdout (the same shape `chat --json` produces); otherwise the assistant reply streams in human form. +- **Not persisted** — a stateless invoke (no `history.db` row), unlike the REPL. The exit code is the **turn's outcome**: `0` on success, `1` on a turn error; an invocation fault is `2`. It is **never** `4` (that is the interactive REPL's session-ended code). + ### `relavium provider` Registers LLM providers and manages their API keys in the **OS keychain** (workstream 2.C; `@napi-rs/keyring`, diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 2497d626..dc369143 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -2,7 +2,7 @@ > Status: Living -> Last updated: 2026-06-25 +> Last updated: 2026-06-26 - **Related**: [README.md](README.md), [phases/phase-2-cli.md](phases/phase-2-cli.md), [deferred-tasks.md](deferred-tasks.md), [../project-structure.md](../project-structure.md), [../tech-stack.md](../tech-stack.md) @@ -76,7 +76,27 @@ sub-spine: the `model_catalog` reader → `resolveMediaSurface` routing + the D1 `EgressCapability.fetch` egress, the containment-checked `save_to` write port, durable fail-cost on the terminal events, the produced-media render surface, and the best-effort run-end host media GC), ✅ Done (PR #52, 2026-06-25) behind [ADR-0042](../decisions/0042-engine-media-storage-substrate-mediastore-deinline-retention.md)–[ADR-0046](../decisions/0046-inline-media-out-via-generate-streaming-triad-deferred.md) -(no new ADR — `read_media` input access deferred to 2.M). +(no new ADR). +**Also landed — the first user-facing `AgentSession` surface:** **2.M** (`relavium chat` — the agent-first +interactive REPL over `AgentSession`: streaming tokens, tool-call annotations, the FS-scope tier + `allowedCommands` +allowlist honored, `git_commit` denied; `/exit` / `/cancel` / an input-stream EOF / raw-mode Ctrl-C all end the +session with **exit code 4** — over ONE framework-free command core driving both an `ink` TTY app and a plain +non-TTY line loop; a built-in default agent over `[chat].default_model` for a zero-config first run; durable +per-turn persistence to the shared `history.db` that round-trips via `reconstructSessionState`; the ADR-0028 +cost cap wired; model output + pasted input sanitized of terminal control sequences at the display boundary), +✅ Done (PR #54, 2026-06-26) — **no new ADR** (covered by [ADR-0024](../decisions/0024-agent-first-entry-point-agentsession.md), +[ADR-0047](../decisions/0047-cli-framework-commander-ink-clack.md), [ADR-0028](../decisions/0028-workflow-resource-governance.md), +[ADR-0050](../decisions/0050-cli-history-db-at-rest-posture.md), [ADR-0029](../decisions/0029-tool-policy-hardening.md)). +`read_media` **input** access (D12) — which 2.S had pointed at 2.M — was **split into a dedicated, +security-reviewed follow-up** (maintainer-approved); the 2.M REPL shipped without it (tracked in +[deferred-tasks.md](deferred-tasks.md)). +**Also landed — the rest of the agent-first chat family:** **2.N** (`relavium chat-resume` — reload + continue a +persisted session over a shared REPL), **2.O** (`chat-list` — over a new additive `SessionStore.listSessions` +read seam), **2.P** (`chat-export` + the in-REPL `/export` — session → `.relavium.yaml` scaffold, [ADR-0026](../decisions/0026-session-export-to-workflow.md)), +and **2.Q** (`chat --json` — a headless `SessionEvent` NDJSON driver — + the one-shot `relavium agent run` with a +minimal in-house `--fixture` cassette for deterministic offline replay), all ✅ **Done (PR #55, 2026-06-26)** — +**no new ADR** — completing the agent-first CLI lane. (`agent run --input` is reserved/rejected until session +`{{ctx.*}}` prompt interpolation lands — a tracked engine follow-up in [deferred-tasks.md](deferred-tasks.md).) **Next pickup:** **2.R** (the inbound MCP client, [ADR-0034](../decisions/0034-mcp-client-sdk-dependency.md) — off the M3 critical path and the Phase-3 go/no-go, so it completes in-phase without blocking Phase 3); the full status-aware order is the [Remaining build order](phases/phase-2-cli.md#remaining-build-order) queue. See the diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 74196cfe..a78811ca 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -2,7 +2,7 @@ > Status: Living -> Last updated: 2026-06-25 +> Last updated: 2026-06-26 - **Related**: [current.md](current.md), [README.md](README.md), [phases/phase-0-foundations.md](phases/phase-0-foundations.md) @@ -472,6 +472,13 @@ Severity is the review's verified rating. Check an item off in the PR that resol - [ ] **Session `output_schema`.** 1.V ignores `agent.output_schema` (a chat session is free-form text); structured output stays a workflow concern. If a session ever needs it, lower it to `responseFormat` + validate node-side (as the AgentRunner does for an `agent` node). *(low · packages/core/src/engine/agent-session.ts)* +- [ ] **Session `{{ctx.*}}` prompt interpolation (surfaced by 2.Q `agent run --input`).** `AgentSession.#runTurn` + passes the agent's `system_prompt` **verbatim** — it does NOT `resolveTemplate` it against + `#context.variables` the way the workflow `AgentRunner` interpolates an `agent` node's prompt. So + `relavium agent run --input k=v` (2.Q) carries the variables in `SessionContext` (visible on `session:started`) + but a `{{ctx.k}}` placeholder in the agent's prompt is sent to the model **literally**. Wire a `resolveTemplate` + pass over the session prompt against a `RunScope` built from `context.variables` (deliberately deferred — + no surface needed it before 2.Q). *(medium · packages/core/src/engine/agent-session.ts)* ## Phase-2 CLI (2.D) follow-ups diff --git a/docs/roadmap/phases/phase-2-cli.md b/docs/roadmap/phases/phase-2-cli.md index 860699ed..5a46e52f 100644 --- a/docs/roadmap/phases/phase-2-cli.md +++ b/docs/roadmap/phases/phase-2-cli.md @@ -1,6 +1,6 @@ # Phase 2 — CLI -> Status: In progress (Product Phase 1, build phase 2). **2.A** (CLI skeleton + process contract) and **2.B** (config resolution) are ✅ **Done (PR #40, 2026-06-22)**, behind [ADR-0047](../../decisions/0047-cli-framework-commander-ink-clack.md) + [ADR-0048](../../decisions/0048-toml-config-parser.md); **2.D** (`run` → engine, the M3 keystone) is ✅ **Done (PR #41, 2026-06-22)**, and **2.F** (the `--json` CI machine-output contract) is ✅ **Done (PR #42, 2026-06-22)**, behind [ADR-0049](../../decisions/0049-cli-machine-output-contract.md), and **2.K** (the engine regression harness) is ✅ **Done (PR #43, 2026-06-23)** — so **global milestone M3 is reached**; **2.H** (durable run history) is ✅ **Done (PR #44, 2026-06-23)**, behind [ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md); and **2.C** (provider/key commands — OS keychain via `@napi-rs/keyring`) is ✅ **Done (PR #45, 2026-06-23)**, behind [ADR-0019](../../decisions/0019-cli-node-keychain-library.md) + [ADR-0006](../../decisions/0006-os-keychain-for-api-keys.md); and **2.E** (the `ink` streaming TUI) is ✅ **Done (PR #46, 2026-06-24)**, behind [ADR-0047](../../decisions/0047-cli-framework-commander-ink-clack.md); and **2.G** (the interactive human-gate prompt + `relavium gate` cross-process resume) is ✅ **Done (PR #47, 2026-06-24)**, behind [ADR-0047](../../decisions/0047-cli-framework-commander-ink-clack.md) (`@clack/prompts`; no new ADR) — **fully closing 2.K's deferred gate-resume half**; and **2.I** (the read commands `list` / `logs` / `status` / `gate list` over durable history) is ✅ **Done (PR #48, 2026-06-24)** (no new ADR — additive `@relavium/db` read seam + `@relavium/core` `parseAgent`); and **2.L** (packaging, distribution & install verification) is ✅ **Done (PR #49, 2026-06-24)**, behind [ADR-0051](../../decisions/0051-cli-distribution-thin-bundle-private-engine.md) — **closing go/no-go #7, so the Phase-2 spine is complete and all seven Phase-3 exit criteria now hold**; and **2.S** (media host-wiring — the surface half of the multimodal sub-spine) is ✅ **Done (PR #52, 2026-06-25)**, behind [ADR-0042](../../decisions/0042-engine-media-storage-substrate-mediastore-deinline-retention.md)–[ADR-0046](../../decisions/0046-inline-media-out-via-generate-streaming-triad-deferred.md) (no new ADR — `read_media` input access deferred to **2.M**), **the first additive lane done**. The status-aware order for everything still open (next pickup: **2.R**) is the [Remaining build order](#remaining-build-order) queue. +> Status: In progress (Product Phase 1, build phase 2). **2.A** (CLI skeleton + process contract) and **2.B** (config resolution) are ✅ **Done (PR #40, 2026-06-22)**, behind [ADR-0047](../../decisions/0047-cli-framework-commander-ink-clack.md) + [ADR-0048](../../decisions/0048-toml-config-parser.md); **2.D** (`run` → engine, the M3 keystone) is ✅ **Done (PR #41, 2026-06-22)**, and **2.F** (the `--json` CI machine-output contract) is ✅ **Done (PR #42, 2026-06-22)**, behind [ADR-0049](../../decisions/0049-cli-machine-output-contract.md), and **2.K** (the engine regression harness) is ✅ **Done (PR #43, 2026-06-23)** — so **global milestone M3 is reached**; **2.H** (durable run history) is ✅ **Done (PR #44, 2026-06-23)**, behind [ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md); and **2.C** (provider/key commands — OS keychain via `@napi-rs/keyring`) is ✅ **Done (PR #45, 2026-06-23)**, behind [ADR-0019](../../decisions/0019-cli-node-keychain-library.md) + [ADR-0006](../../decisions/0006-os-keychain-for-api-keys.md); and **2.E** (the `ink` streaming TUI) is ✅ **Done (PR #46, 2026-06-24)**, behind [ADR-0047](../../decisions/0047-cli-framework-commander-ink-clack.md); and **2.G** (the interactive human-gate prompt + `relavium gate` cross-process resume) is ✅ **Done (PR #47, 2026-06-24)**, behind [ADR-0047](../../decisions/0047-cli-framework-commander-ink-clack.md) (`@clack/prompts`; no new ADR) — **fully closing 2.K's deferred gate-resume half**; and **2.I** (the read commands `list` / `logs` / `status` / `gate list` over durable history) is ✅ **Done (PR #48, 2026-06-24)** (no new ADR — additive `@relavium/db` read seam + `@relavium/core` `parseAgent`); and **2.L** (packaging, distribution & install verification) is ✅ **Done (PR #49, 2026-06-24)**, behind [ADR-0051](../../decisions/0051-cli-distribution-thin-bundle-private-engine.md) — **closing go/no-go #7, so the Phase-2 spine is complete and all seven Phase-3 exit criteria now hold**; and **2.S** (media host-wiring — the surface half of the multimodal sub-spine) is ✅ **Done (PR #52, 2026-06-25)**, behind [ADR-0042](../../decisions/0042-engine-media-storage-substrate-mediastore-deinline-retention.md)–[ADR-0046](../../decisions/0046-inline-media-out-via-generate-streaming-triad-deferred.md) (no new ADR), **the first additive lane done**; and **2.M** (the `relavium chat` REPL — the first user-facing `AgentSession` surface) is ✅ **Done (PR #54, 2026-06-26)** (no new ADR — covered by [ADR-0024](../../decisions/0024-agent-first-entry-point-agentsession.md)/[ADR-0047](../../decisions/0047-cli-framework-commander-ink-clack.md)/[ADR-0028](../../decisions/0028-workflow-resource-governance.md)/[ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md)/[ADR-0029](../../decisions/0029-tool-policy-hardening.md); `read_media` **input** access split into a dedicated follow-up); and the rest of the agent-first chat family — **2.N** (`chat-resume`), **2.O** (`chat-list`), **2.P** (`chat-export` + in-REPL `/export`), and **2.Q** (`chat --json` + one-shot `agent run` + `--fixture` cassette) — is ✅ **Done (PR #55, 2026-06-26)** (no new ADR), completing the agent-first CLI lane. The status-aware order for everything still open (next pickup: **2.R**) is the [Remaining build order](#remaining-build-order) queue. - **Related**: [../README.md](../README.md), [phase-1-engine-and-llm.md](phase-1-engine-and-llm.md), [phase-3-desktop.md](phase-3-desktop.md), [../../reference/cli/commands.md](../../reference/cli/commands.md), [../../reference/contracts/config-spec.md](../../reference/contracts/config-spec.md), [../../reference/desktop/keychain-and-secrets.md](../../reference/desktop/keychain-and-secrets.md), [../../reference/contracts/sse-event-schema.md](../../reference/contracts/sse-event-schema.md), [../../reference/desktop/database-schema.md](../../reference/desktop/database-schema.md), [../../architecture/execution-model.md](../../architecture/execution-model.md), [../../architecture/shared-core-engine.md](../../architecture/shared-core-engine.md) @@ -479,10 +479,12 @@ The interactive agent entry point on the CLI ([ADR-0024](../../decisions/0024-ag - **2.N — `relavium chat-resume <sessionId>`.** Reload + continue a persisted session from `history.db`. - **2.O — `relavium chat-list`.** List session history (id, agent, title, last activity). - **2.P — `relavium chat-export <sessionId>`.** Export a session to a `.relavium.yaml` scaffold ([ADR-0026](../../decisions/0026-session-export-to-workflow.md)). -- **2.Q — `relavium chat --json` + `relavium agent run`.** A deterministic `--json` `session:*` stream (CI-friendly); and a one-shot `relavium agent run <agent> --input … [--json] [--fixture …]` on the same `AgentSession` infra. _(The `relavium gate list` multi-gate discovery command lands with the read commands in **2.I**, per [commands.md](../../reference/cli/commands.md).)_ +- **2.Q — `relavium chat --json` + `relavium agent run`.** A deterministic `--json` `session:*` stream (CI-friendly); and a one-shot `relavium agent run <agent> [--json] [--fixture …]` on the same `AgentSession` infra (`--input` reserved/rejected — see the status note). _(The `relavium gate list` multi-gate discovery command lands with the read commands in **2.I**, per [commands.md](../../reference/cli/commands.md).)_ **Acceptance:** an interactive `relavium chat` streams a multi-turn conversation with a tool call, persists, and resumes; `chat --json` emits a deterministic `session:*` stream; `agent run` invokes a single agent headlessly; chat `/exit` returns exit code 4. +> **Status.** **2.M** (`relavium chat`) is ✅ **Done (PR #54, 2026-06-26)** — no new ADR (covered by [ADR-0024](../../decisions/0024-agent-first-entry-point-agentsession.md), [ADR-0047](../../decisions/0047-cli-framework-commander-ink-clack.md), [ADR-0028](../../decisions/0028-workflow-resource-governance.md), [ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md), [ADR-0029](../../decisions/0029-tool-policy-hardening.md)). Shipped: the interactive multi-turn REPL over `AgentSession` (ONE framework-free command core driving both an `ink` TTY app and a plain non-TTY line loop), a built-in default agent over `[chat].default_model` for a zero-config first run, durable per-turn `history.db` persistence that round-trips via `reconstructSessionState`, the ADR-0028 cost cap, and display-boundary sanitization of terminal control sequences; `/exit` / `/cancel` / an input EOF / raw-mode Ctrl-C end the session with **exit code 4**. `read_media` **input** access (D12) — which 2.S had pointed here — was **split into a dedicated, security-reviewed follow-up** (maintainer-approved); the REPL shipped without it (see [../deferred-tasks.md](../deferred-tasks.md)). **2.N–2.Q are ✅ Done (PR #55, no new ADR)**: `chat-resume` (2.N — reload + continue a persisted session over a shared REPL), `chat-list` (2.O — over a new additive `SessionStore.listSessions` read seam), `chat-export` + the in-REPL `/export` (2.P — session→`.relavium.yaml` scaffold, ADR-0026), and `chat --json` (a headless `SessionEvent` NDJSON driver) + the one-shot `agent run` (with a minimal in-house `--fixture` cassette for deterministic offline replay) (2.Q) — completing the agent-first CLI lane. `agent run --input` is **reserved/rejected** until session `{{ctx.*}}` prompt interpolation lands (a tracked engine follow-up). + ### 2.R — MCP client integration (inbound) Implement the **inbound** half of [mcp-integration.md](../../reference/shared-core/mcp-integration.md) @@ -519,7 +521,7 @@ explicit opt-in; the import-zone check confirms no SDK type leaks past the integ ### 2.S — Media host-wiring (1.AH / Phase-2) -> **Status:** ✅ **Done (PR #52, 2026-06-25)** — behind [ADR-0042](../../decisions/0042-engine-media-storage-substrate-mediastore-deinline-retention.md)–[ADR-0046](../../decisions/0046-inline-media-out-via-generate-streaming-triad-deferred.md) (no new ADR). Shipped: the `model_catalog` reader → `resolveMediaSurface` routing + the D15 catalog load-check shared by `run`/`gate`, the content-addressed `MediaStore` de-inline, the SSRF-validated `EgressCapability.fetch` egress (with a dedicated security review), the containment-checked `save_to` write port, durable fail-cost on the terminal events (ADR-0045 §5), the produced-media render surface, and the best-effort run-end host media GC (ADR-0042 §4: grace reclaim + CAS-orphan sweep + clean-terminal reclaim-retry). `read_media` **input** access (D12) was deferred to **2.M** with maintainer approval; the deferred D8/D15/D17 mechanism half is discharged for the CLI surface (see [../deferred-tasks.md](../deferred-tasks.md)). The first additive lane — **next pickup: 2.R**. +> **Status:** ✅ **Done (PR #52, 2026-06-25)** — behind [ADR-0042](../../decisions/0042-engine-media-storage-substrate-mediastore-deinline-retention.md)–[ADR-0046](../../decisions/0046-inline-media-out-via-generate-streaming-triad-deferred.md) (no new ADR). Shipped: the `model_catalog` reader → `resolveMediaSurface` routing + the D15 catalog load-check shared by `run`/`gate`, the content-addressed `MediaStore` de-inline, the SSRF-validated `EgressCapability.fetch` egress (with a dedicated security review), the containment-checked `save_to` write port, durable fail-cost on the terminal events (ADR-0045 §5), the produced-media render surface, and the best-effort run-end host media GC (ADR-0042 §4: grace reclaim + CAS-orphan sweep + clean-terminal reclaim-retry). `read_media` **input** access (D12) was deferred to **2.M**, then **split into a dedicated, security-reviewed follow-up** (maintainer-approved) — the 2.M chat REPL shipped without it; the deferred D8/D15/D17 mechanism half is discharged for the CLI surface (see [../deferred-tasks.md](../deferred-tasks.md)). The first additive lane. The surface half of the multimodal sub-spine. The engine-pure media **policy** landed in Phase 1 (1.AF/1.AG: the `MediaStore`/`deInlineMedia` choke point, `read_media` + @@ -642,9 +644,9 @@ streams. Built behind injectable ports so desktop (§3.B) and VS Code (§4.N) re | Authoring lifecycle (`create`/`import`/`export`) | 2.J | — | | CLI adopted as the engine regression harness | 2.D, 2.F, 2.K | **M3** | | Published, installable binary verified on all OSes | 2.L | — | -| **Agent-first CLI** — `relavium chat` + session commands (resume / list / export / `agent run` / `gate list`): the **first user-facing `AgentSession` surface**, a committed build-phase-2 deliverable (off the M3 critical path and the Phase-3 go/no-go, completed in-phase — the agent-first headline is demonstrable here) | 2.M, 2.N, 2.O, 2.P, 2.Q | — | +| **Agent-first CLI** — `relavium chat` + session commands (resume / list / export / `agent run` / `gate list`): the **first user-facing `AgentSession` surface**, a committed build-phase-2 deliverable (off the M3 critical path and the Phase-3 go/no-go, completed in-phase — the agent-first headline is demonstrable here) | 2.M, 2.N, 2.O, 2.P, 2.Q | **2.M ✅ (PR #54)**; **2.N–2.Q ✅ (PR #55, 2026-06-26)** | | **MCP client live** — a fixture agent completes a real stdio MCP tool round-trip behind the `ToolRegistry`, per [ADR-0034](../../decisions/0034-mcp-client-sdk-dependency.md) (off the M3 critical path) | 2.R | — | -| **Media host-wiring** ✅ **(PR #52, 2026-06-25)** — a generative media-output fixture runs end-to-end on the CLI (host `resolveMediaSurface` routing, containment-checked `save_to`, the `EgressCapability.fetch` SSRF mechanism; `read_media` input access deferred to 2.M), the shared ports designed to fit desktop/VS Code, per the media ADRs ([ADR-0042](../../decisions/0042-engine-media-storage-substrate-mediastore-deinline-retention.md)–[ADR-0046](../../decisions/0046-inline-media-out-via-generate-streaming-triad-deferred.md)) (off the M3 critical path) | 2.S | — | +| **Media host-wiring** ✅ **(PR #52, 2026-06-25)** — a generative media-output fixture runs end-to-end on the CLI (host `resolveMediaSurface` routing, containment-checked `save_to`, the `EgressCapability.fetch` SSRF mechanism; `read_media` input access split into a dedicated follow-up past 2.M), the shared ports designed to fit desktop/VS Code, per the media ADRs ([ADR-0042](../../decisions/0042-engine-media-storage-substrate-mediastore-deinline-retention.md)–[ADR-0046](../../decisions/0046-inline-media-out-via-generate-streaming-triad-deferred.md)) (off the M3 critical path) | 2.S | — | ## Sequencing & parallelization @@ -666,22 +668,23 @@ This is the status × plan view; the dependency rationale for every row lives in [Ordered waves](#ordered-waves-each-wave-is-internally-parallel-waves-gate-left-to-right) — this table does not restate them, it only sequences what remains. -> **Status (2026-06-25):** ✅ **2.A · 2.B · 2.D · 2.F · 2.K · 2.H · 2.C · 2.E · 2.G · 2.I · 2.L · 2.S** done — **M3 reached, Phase-2 spine complete, all 7 Phase-3 go/no-go exit criteria hold (Phase 3 may start)**; **2.S** (media host-wiring — the first additive lane) landed PR #52 · next pickup: **2.R**. +> **Status (2026-06-26):** ✅ **2.A · 2.B · 2.D · 2.F · 2.K · 2.H · 2.C · 2.E · 2.G · 2.I · 2.L · 2.S · 2.M · 2.N–2.Q** done — **M3 reached, Phase-2 spine complete, all 7 Phase-3 go/no-go exit criteria hold (Phase 3 may start)**; **2.S** (media host-wiring) landed PR #52 and the agent-first **chat** lane (2.M–2.Q) landed PR #54/#55 · next pickup: **2.R**. > (2.L shipped the published, install-verified binary (PR #49) — so go/no-go #7 holds and **every Phase-2 -> spine/gate PR is done**; **2.S** then cleared the first additive lane (PR #52). The remaining three additive -> lanes (2.R, chat, 2.J) complete in-phase but don't block Phase 3.) +> spine/gate PR is done**; **2.S** then cleared the first additive lane (PR #52) and the **chat** lane (2.M–2.Q) +> landed (PR #54/#55). The remaining two additive lanes (2.R, 2.J) complete in-phase but don't block Phase 3.) | Next | Lane | Why now | Blockers (all met on arrival) | |---|---|---|---| | **1. 2.R** MCP client | additive | inbound MCP tools; first lane after 2.S | 2.B ✓ · 2.C ✓ | -| **2. 2.M → 2.N–2.Q** chat | additive | agent-first chat surface | 2.C ✓ · 2.H ✓ · 2.E ✓ | +| ✅ 2.M → 2.N–2.Q chat | additive | **Done (PR #54, #55)** — the agent-first chat family (chat / resume / list / export / `--json` / agent run) | 2.C ✓ · 2.H ✓ · 2.E ✓ | | **3. 2.J** create / import / export | additive | cheap filler — drop into any low-energy slot | 2.A ✓ | | ✅ 2.S media host-wiring | additive | **Done (PR #52)** — the first additive lane; the lone SSRF security review cleared | 2.D · 2.H ✓ | - **Gate-closing backbone — complete (`2.L` landed, PR #49):** with the published, install-verified binary shipped, every exit-criteria/spine PR is done (2.K + 2.H + 2.C + 2.E + 2.G + 2.I + 2.L), so **all seven - Phase-3 go/no-go criteria hold and Phase 3 may start**. **2.S** then cleared the first additive lane (PR #52); - the remaining three (**2.R, chat, 2.J**) complete in-phase but do **not** block starting Phase 3. + Phase-3 go/no-go criteria hold and Phase 3 may start**. **2.S** then cleared the first additive lane (PR #52) + and the **chat** lane (2.M–2.Q) landed (PR #54/#55); the remaining two (**2.R, 2.J**) complete in-phase but + do **not** block starting Phase 3. - **2.K is fully closed (via 2.G).** Its deferred gate-resume scenario was exercised once the gate pause/resume surface shipped, which unblocked **2.L** — now landed (PR #49). - **The one judgement call — 2.S timing — was honored.** It landed as the *first* additive lane (PR #52, diff --git a/packages/db/src/session-store.test.ts b/packages/db/src/session-store.test.ts index 489f7b98..1345ff5d 100644 --- a/packages/db/src/session-store.test.ts +++ b/packages/db/src/session-store.test.ts @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createClient, runMigrations, type DbClient } from './client.js'; import { agentSessions, llmProviders, modelCatalog, sessionMessages } from './schema.js'; -import { createSessionStore, type SessionStore } from './session-store.js'; +import { createSessionStore, fromAgentSessionRow, type SessionStore } from './session-store.js'; /** Seed a provider + model_catalog row so a session/message `model_id` FK resolves (catalog UUID). */ function seedModelCatalog(client: DbClient): void { @@ -103,6 +103,65 @@ describe('SessionStore (1.X) — persist + resume', () => { expect(store.loadFull('nope')).toBeUndefined(); }); + it('loadSession/loadFull exclude a soft-deleted session (no resurrect on resume)', () => { + store.createSession(makeSession({ id: 'sess-del', deletedAt: '2026-06-17T09:00:00.000Z' })); + expect(store.loadSession('sess-del')).toBeUndefined(); + expect(store.loadFull('sess-del')).toBeUndefined(); + }); + + it('listSessions returns the non-deleted sessions, most-recently-updated first (2.O)', () => { + // Three sessions at distinct updatedAt; insert out of order to prove the sort is by updated_at, not insert. + store.createSession(makeSession({ id: 'sess-a', updatedAt: '2026-06-17T08:00:00.000Z' })); + store.createSession(makeSession({ id: 'sess-c', updatedAt: '2026-06-17T10:00:00.000Z' })); + store.createSession(makeSession({ id: 'sess-b', updatedAt: '2026-06-17T09:00:00.000Z' })); + + expect(store.listSessions().map((s) => s.id)).toEqual(['sess-c', 'sess-b', 'sess-a']); + }); + + it('listSessions excludes a soft-deleted session even when it is the most-recently-updated', () => { + store.createSession( + makeSession({ id: 'sess-live', title: 'Live', updatedAt: '2026-06-17T08:00:00.000Z' }), + ); + // sess-gone sorts FIRST by updated_at — so its absence proves the WHERE runs, not just the ORDER BY. + store.createSession( + makeSession({ + id: 'sess-gone', + updatedAt: '2026-06-17T10:00:00.000Z', + deletedAt: '2026-06-17T11:00:00.000Z', + }), + ); + + const listed = store.listSessions(); + expect(listed.map((s) => s.id)).toEqual(['sess-live']); + expect(listed[0]).toEqual( + makeSession({ id: 'sess-live', title: 'Live', updatedAt: '2026-06-17T08:00:00.000Z' }), + ); + }); + + it('listSessions is empty for a fresh store', () => { + expect(store.listSessions()).toEqual([]); + }); + + it('listSessions breaks an updated_at tie deterministically by id descending (not insert order)', () => { + const tie = '2026-06-17T08:00:00.000Z'; + // Insert in an order (b, a, c) that neither id-asc nor id-desc matches, so passing proves the sort is by + // id (descending), not the rows' insertion/rowid order: id-desc ⇒ [c, b, a]; insertion ⇒ [b, a, c]. + store.createSession(makeSession({ id: 'sess-b', updatedAt: tie })); + store.createSession(makeSession({ id: 'sess-a', updatedAt: tie })); + store.createSession(makeSession({ id: 'sess-c', updatedAt: tie })); + + expect(store.listSessions().map((s) => s.id)).toEqual(['sess-c', 'sess-b', 'sess-a']); + }); + + it('listSessions returns a row whose modelId references the model_catalog (FK-resolved passthrough)', () => { + // modelId is the catalog UUID (FK → model_catalog.id), not a raw model string — mirrors the existing + // FK round-trip test; this pins that listSessions' projection does not drop the column. + seedModelCatalog(client); + store.createSession(makeSession({ id: 'sess-m', modelId: 'model-1' })); + + expect(store.listSessions()[0]?.modelId).toBe('model-1'); + }); + it('updateSession overwrites mutable fields by id', () => { store.createSession(makeSession()); store.updateSession( @@ -196,7 +255,7 @@ describe('SessionStore (1.X) — persist + resume', () => { }).toThrow(); }); - it('round-trips the optional fields — agentSnapshot, exportedWorkflowPath, deletedAt', () => { + it('round-trips the optional fields — agentSnapshot + exportedWorkflowPath (loadSession), deletedAt (mapper)', () => { const agentSnapshot = AgentSchema.parse({ id: 'chatter', model: 'claude-opus-4-8', @@ -209,13 +268,21 @@ describe('SessionStore (1.X) — persist + resume', () => { status: 'exported', agentSnapshot, exportedWorkflowPath: 'flows/chat.relavium.yaml', - deletedAt: TS_ISO, }), ); const loaded = store.loadSession('sess-1'); expect(loaded?.agentSnapshot).toEqual(agentSnapshot); // the JSON snapshot column round-trips expect(loaded?.exportedWorkflowPath).toBe('flows/chat.relavium.yaml'); - expect(loaded?.deletedAt).toBe(TS_ISO); // the soft-delete tombstone survives the epoch-ms edge + + // A soft-deleted session is HIDDEN from loadSession (the exclusion test covers that), so the deletedAt + // tombstone's epoch-ms→ISO round-trip is verified by reading the raw row through the mapper directly. + store.createSession(makeSession({ id: 'sess-del', deletedAt: TS_ISO })); + const rawRow = client.db + .select() + .from(agentSessions) + .where(eq(agentSessions.id, 'sess-del')) + .get(); + expect(rawRow && fromAgentSessionRow(rawRow).deletedAt).toBe(TS_ISO); }); it('updateSession preserves the immutable created_at while advancing updated_at', () => { diff --git a/packages/db/src/session-store.ts b/packages/db/src/session-store.ts index 524353df..139b89c5 100644 --- a/packages/db/src/session-store.ts +++ b/packages/db/src/session-store.ts @@ -4,7 +4,7 @@ import { type AgentSessionRecord, type SessionMessage, } from '@relavium/shared'; -import { asc, eq } from 'drizzle-orm'; +import { and, asc, desc, eq, isNull } from 'drizzle-orm'; import type { Db } from './client.js'; import { @@ -172,6 +172,13 @@ export interface SessionStore { updateSession: (record: AgentSessionRecord) => void; /** Load a session record by id, or `undefined` if absent. */ loadSession: (sessionId: string) => AgentSessionRecord | undefined; + /** + * List the non-deleted sessions, most-recently-updated first (the `relavium chat-list` read seam, 2.O) — + * the session counterpart of the run-history `listRuns`. Soft-deleted rows (`deleted_at` set) are excluded, + * matching `loadSession`; `id` is the stable secondary sort key so the order is deterministic when two rows + * share an `updated_at`. + */ + listSessions: () => AgentSessionRecord[]; /** Append a transcript message (the caller assigns the next monotonic `sequenceNumber`). */ appendMessage: (message: SessionMessage, meta?: SessionMessageMeta) => void; /** Load a session's full transcript in `sequenceNumber` order. */ @@ -185,10 +192,25 @@ export interface SessionStore { /** Wire a {@link SessionStore} over a `@relavium/db` connection. */ export function createSessionStore(db: Db): SessionStore { const loadSession = (sessionId: string): AgentSessionRecord | undefined => { - const row = db.select().from(agentSessions).where(eq(agentSessions.id, sessionId)).get(); + // Exclude soft-deleted rows (matching `listSessions`): a tombstoned session must not reload or resume — + // `chat-resume`'s `loadFull` would otherwise resurrect it and the persister would re-write the row. + const row = db + .select() + .from(agentSessions) + .where(and(eq(agentSessions.id, sessionId), isNull(agentSessions.deletedAt))) + .get(); return row === undefined ? undefined : fromAgentSessionRow(row); }; + const listSessions = (): AgentSessionRecord[] => + db + .select() + .from(agentSessions) + .where(isNull(agentSessions.deletedAt)) + .orderBy(desc(agentSessions.updatedAt), desc(agentSessions.id)) + .all() + .map(fromAgentSessionRow); + const loadMessages = (sessionId: string): SessionMessage[] => db .select() @@ -212,6 +234,7 @@ export function createSessionStore(db: Db): SessionStore { db.update(agentSessions).set(mutable).where(eq(agentSessions.id, record.id)).run(); }, loadSession, + listSessions, appendMessage: (message, meta) => { db.insert(sessionMessages).values(toSessionMessageRow(message, meta)).run(); },