Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d578cb9
feat(cli): 2.R Step 3a — wire inbound MCP into the chat surface (stdio)
cemililik Jun 27, 2026
d350f69
fix(cli): 2.R Step 3a Opus-review — sanitize MCP skip notes, self-cle…
cemililik Jun 27, 2026
99a118c
test(cli,docs): 2.R Step 3a Sonnet-review — resume + agent-run MCP co…
cemililik Jun 27, 2026
cfad5cd
feat(cli,mcp): 2.R Step 3b — wire inbound MCP into relavium run (stdi…
cemililik Jun 27, 2026
3ac6d0b
fix(cli): 2.R Step 3b Opus-review — close cross-agent allowlist escal…
cemililik Jun 27, 2026
53c9c34
test(cli): 2.R Step 3b Sonnet-review — run-path fail-loud + buildEngi…
cemililik Jun 27, 2026
758aeb0
feat(cli): 2.R Step 4a — named MCP secrets via the isolated mcp-secre…
cemililik Jun 27, 2026
bd786e3
fix(cli): 2.R Step 4a Opus-review — pre-substitution {{ scan + no-lea…
cemililik Jun 27, 2026
86ca02d
test(cli): 2.R Step 4a Sonnet-review — tie the secret custody chain t…
cemililik Jun 27, 2026
210789f
feat(shared,cli): 2.R Step 4b — by-name `ref` MCP servers + transport…
cemililik Jun 27, 2026
bca8c68
fix(cli,docs): 2.R Step 4b Opus-review — sanitize the ref registratio…
cemililik Jun 27, 2026
d1e492e
fix(shared,docs): 2.R Step 4b Sonnet-review — finish the vocab reconc…
cemililik Jun 27, 2026
70599a6
feat(shared,cli,mcp): 2.R Step 4c — network MCP transports (http/sse/…
cemililik Jun 27, 2026
6d95fb8
fix(mcp,cli,shared): 2.R Step 4c Opus review — close SSRF identity + …
cemililik Jun 27, 2026
e5aa365
fix(shared,mcp,cli): 2.R Step 4c Sonnet review — close registration/i…
cemililik Jun 27, 2026
9c5d2f5
test(cli,mcp): 2.R Step 5 — real-spawn stdio MCP e2e (chat + run host…
cemililik Jun 27, 2026
1ad473b
docs(mcp): 2.R Step 5 — reconcile the inbound-MCP reference with the …
cemililik Jun 27, 2026
0f73971
fix(shared,cli,mcp,docs): 2.R final holistic Opus review — close env-…
cemililik Jun 27, 2026
da734bc
fix(cli,shared,docs): 2.R final holistic Sonnet review — defense-in-d…
cemililik Jun 27, 2026
f2855cb
fix(cli,shared,docs): PR #57 review — teardown robustness, Sonar comp…
cemililik Jun 27, 2026
4901e3b
fix(shared,mcp,cli,docs): 2.R end-to-end review — close SSRF IPv6 byp…
cemililik Jun 27, 2026
86f60b5
fix(cli,shared,mcp,docs): PR #57 review round 3 — fixture true-offlin…
cemililik Jun 27, 2026
4b2250c
refactor(cli): agentRunCommand cognitive complexity 18 → under 15 (So…
cemililik Jun 27, 2026
2b1f99b
fix(cli): chat-ink forced-exit — keep the bounded teardown timer refe…
cemililik Jun 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"@clack/prompts": "catalog:",
"@google/genai": "catalog:",
"@jitl/quickjs-singlefile-mjs-release-sync": "catalog:",
"@modelcontextprotocol/sdk": "catalog:",
"@napi-rs/keyring": "catalog:",
"better-sqlite3": "catalog:",
"commander": "catalog:",
Expand All @@ -55,6 +56,7 @@
"@relavium/core": "workspace:*",
"@relavium/db": "workspace:*",
"@relavium/llm": "workspace:*",
"@relavium/mcp": "workspace:*",
"@relavium/shared": "workspace:*",
"@types/node": "catalog:",
"@types/react": "catalog:",
Expand Down
44 changes: 23 additions & 21 deletions apps/cli/src/chat/persister.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@ describe('createSessionPersister', () => {
client.sqlite.close();
});

function setup(providers: ProviderResolver, initialSequenceNumber?: number) {
async function setup(providers: ProviderResolver, initialSequenceNumber?: number) {
let tick = Date.parse('2026-06-25T00:00:00.000Z');
const now = () => tick++;
let msgId = 0;
const built = buildChatSession({
const built = await buildChatSession({
chat: EMPTY_CHAT,
agentRef: undefined,
cwd: '/workspace',
Expand All @@ -66,8 +66,8 @@ describe('createSessionPersister', () => {
return { built, persister };
}

it('persists the session row eagerly on start (auto-persisted from the moment it starts)', () => {
const { built, persister } = setup(scriptedResolver([textTurn('hi')]));
it('persists the session row eagerly on start (auto-persisted from the moment it starts)', async () => {
const { built, persister } = await setup(scriptedResolver([textTurn('hi')]));
persister.start();
const full = store.loadFull('sess-1');
expect(full).toBeDefined();
Expand All @@ -78,8 +78,8 @@ describe('createSessionPersister', () => {
expect(full?.messages).toHaveLength(0); // no turn yet
});

it('adopts an existing row on start instead of re-INSERTing (resume does not hit the UNIQUE pk)', () => {
const { built, persister } = setup(scriptedResolver([textTurn('hi')]), 5);
it('adopts an existing row on start instead of re-INSERTing (resume does not hit the UNIQUE pk)', async () => {
const { built, persister } = await setup(scriptedResolver([textTurn('hi')]), 5);
// Simulate the prior process: the session row already exists in history.db before this resume starts.
store.createSession({
id: built.sessionId,
Expand All @@ -103,7 +103,7 @@ describe('createSessionPersister', () => {

it('resumed persister folds new-turn tokens ON TOP of the adopted row totals, not from zero', async () => {
// Simulate 2.N resume: a row with prior totals already exists; start() adopts it and hydrates the totals.
const { built, persister } = setup(scriptedResolver([textTurn('go')]), 5);
const { built, persister } = await setup(scriptedResolver([textTurn('go')]), 5);
store.createSession({
id: built.sessionId,
agentSlug: built.agent.id,
Expand Down Expand Up @@ -131,7 +131,7 @@ describe('createSessionPersister', () => {
// A turn with no provider egress emits NO cost:updated, so the cost flush relies entirely on the hydrated
// total. unresolvedResolver fails the turn `internal` before any egress; the prior row cost (1300) must
// survive — without start()'s hydration the unconditional flush would reset it to 0.
const { built, persister } = setup(unresolvedResolver(), 5);
const { built, persister } = await setup(unresolvedResolver(), 5);
store.createSession({
id: built.sessionId,
agentSlug: built.agent.id,
Expand All @@ -154,7 +154,7 @@ describe('createSessionPersister', () => {
});

it('persists a completed turn as a user + text-only assistant pair, and folds the token totals', async () => {
const { built, persister } = setup(scriptedResolver([textTurn('hi there')]));
const { built, persister } = await setup(scriptedResolver([textTurn('hi there')]));
persister.start();
built.session.start();
persister.beginUserTurn('hello');
Expand All @@ -176,7 +176,7 @@ describe('createSessionPersister', () => {
it('persists only the user row when a successful turn produces no assistant text', async () => {
// A turn that emits only a stop chunk — zero text_delta, so result.text is empty; the assistantText.length
// guard must skip the empty assistant row (mirroring the engine), leaving just the user row.
const { built, persister } = setup(scriptedResolver([[stop('stop')]]));
const { built, persister } = await setup(scriptedResolver([[stop('stop')]]));
persister.start();
built.session.start();
persister.beginUserTurn('hello');
Expand All @@ -198,7 +198,9 @@ describe('createSessionPersister', () => {
{ type: 'tool_call_end', id: 'c1' },
stop('tool_use'),
];
const { built, persister } = setup(scriptedResolver([preToolTurn, textTurn('the answer')]));
const { built, persister } = await setup(
scriptedResolver([preToolTurn, textTurn('the answer')]),
);
persister.start();
built.session.start();
persister.beginUserTurn('go');
Expand All @@ -210,7 +212,7 @@ describe('createSessionPersister', () => {
});

it('persists nothing for a failed turn (the engine rolls its user message back)', async () => {
const { built, persister } = setup(unresolvedResolver()); // every turn fails `internal`
const { built, persister } = await setup(unresolvedResolver()); // every turn fails `internal`
persister.start();
built.session.start();
persister.beginUserTurn('hello');
Expand All @@ -221,16 +223,16 @@ describe('createSessionPersister', () => {
expect(full?.session.status).toBe('active');
});

it('marks the session ended on cancel (its sole terminal), leaving it resumable', () => {
const { built, persister } = setup(scriptedResolver([textTurn('hi')]));
it('marks the session ended on cancel (its sole terminal), leaving it resumable', async () => {
const { built, persister } = await setup(scriptedResolver([textTurn('hi')]));
persister.start();
built.session.start();
built.session.cancel();
expect(store.loadFull('sess-1')?.session.status).toBe('ended');
});

it('produces a transcript reconstructSessionState round-trips into a resumable state', async () => {
const { built, persister } = setup(scriptedResolver([textTurn('hi there')]));
const { built, persister } = await setup(scriptedResolver([textTurn('hi there')]));
persister.start();
built.session.start();
persister.beginUserTurn('hello');
Expand All @@ -250,7 +252,7 @@ describe('createSessionPersister', () => {
});

it('accumulates sequenceNumber and token totals across consecutive turns', async () => {
const { built, persister } = setup(
const { built, persister } = await setup(
scriptedResolver([textTurn('reply 1'), textTurn('reply 2')]),
);
persister.start();
Expand All @@ -272,7 +274,7 @@ describe('createSessionPersister', () => {
});

it('seeds the first sequenceNumber from initialSequenceNumber (the 2.N resume injection point)', async () => {
const { built, persister } = setup(scriptedResolver([textTurn('hi')]), 5);
const { built, persister } = await setup(scriptedResolver([textTurn('hi')]), 5);
persister.start();
built.session.start();
persister.beginUserTurn('go');
Expand All @@ -282,7 +284,7 @@ describe('createSessionPersister', () => {
});

it('start() is idempotent — a second call neither duplicates the row nor double-subscribes', async () => {
const { built, persister } = setup(scriptedResolver([textTurn('hi')]));
const { built, persister } = await setup(scriptedResolver([textTurn('hi')]));
persister.start();
persister.start(); // a duplicate createSession would PK-violate; a double-subscribe would double-write
built.session.start();
Expand All @@ -292,7 +294,7 @@ describe('createSessionPersister', () => {
});

it('close() unsubscribes — turns after close are not persisted (the session stays in the db)', async () => {
const { built, persister } = setup(scriptedResolver([textTurn('one'), textTurn('two')]));
const { built, persister } = await setup(scriptedResolver([textTurn('one'), textTurn('two')]));
persister.start();
built.session.start();
persister.beginUserTurn('first');
Expand All @@ -308,7 +310,7 @@ describe('createSessionPersister', () => {
it('flushes the running cost on a failed turn so a resumed budget governor sees the true spend', async () => {
// Turn 1 succeeds and incurs a real (priced) cost; turn 2 is unscripted, so the provider throws and the
// turn settles as an error. The d6b975b unconditional flush must keep the turn-1 cost durable (not 0).
const { built, persister } = setup(scriptedResolver([textTurn('hi')]));
const { built, persister } = await setup(scriptedResolver([textTurn('hi')]));
persister.start();
built.session.start();
persister.beginUserTurn('first');
Expand All @@ -325,7 +327,7 @@ describe('createSessionPersister', () => {
});

it('on cancel after a successful turn: status ended, totals retained, messages untouched', async () => {
const { built, persister } = setup(scriptedResolver([textTurn('hi there')]));
const { built, persister } = await setup(scriptedResolver([textTurn('hi there')]));
persister.start();
built.session.start();
persister.beginUserTurn('hello');
Expand Down
Loading
Loading