Files
openchamber/packages/web/server/lib/plan-gate/runtime.test.js
T
bot-hermes 05d8e953ca feat: agent-to-agent integrations — activity stream, steer channel, plan gate
Three fork-side integrations that turn OpenChamber from a fire-and-forget
coder into a visible, steerable, plan-gated agent:

1. Activity stream (GET /api/openchamber/agent-activity SSE)
   - agent-activity/runtime.js: subscribes to global hub, normalizes
     message.updated parts into structured activity events (tool-call,
     file-edit, text-part), rate-limited coalescing for tool calls
   - Broadcasts openchamber:agent-activity and session-completed events
   - SSE endpoint with heartbeat (25s), same shape as /api/openchamber/events

2. Steer channel (POST /api/openchamber/session/:id/steer)
   - session-steer/runtime.js: interrupt mode (interrupt → inject
     system-level directive → resume) and queue mode (deliver on next idle)
   - Follows message-queue precedent for interrupt-safe dispatch
   - Broadcasts openchamber:steer-delivered on queued delivery

3. Plan-first gate (plan-gate/runtime.js + approve/reject/status routes)
   - State machine per session: pending → approved | rejected | timed_out
   - Injects plan-gate reminder via openchamber-sessions create prompt
   - Agent emits ## Plan, runtime detects and emits openchamber:plan-ready
   - Approve sends 'Proceed' prompt, reject sends revision prompt
   - Configurable timeout (default 5 min), auto-approve on timeout

4. P1 shared plumbing
   - cardID accepted in session create payload, stored in session metadata
   - All new events carry cardID when known

Files added:
- packages/web/server/lib/agent-activity/runtime.js + runtime.test.js
- packages/web/server/lib/session-steer/runtime.js + runtime.test.js
- packages/web/server/lib/plan-gate/runtime.js + runtime.test.js

Files modified:
- packages/web/server/lib/openchamber-sessions/routes.js (cardID, planGate)
- packages/web/server/lib/opencode/feature-routes-runtime.js (route wiring)
- packages/web/server/index.js (runtime creation, SSE_PATH_PREFIXES)
- packages/web/server/lib/ui-auth/ui-auth.js (auth allowlist)
- packages/web/server/lib/realtime-proxy.js (SSE allowlist)

28 new tests passing. All pre-existing tests unaffected.
2026-09-07 22:34:56 +00:00

232 lines
7.8 KiB
JavaScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { createPlanGateRuntime } from './runtime.js';
const SESSION = 'ses_plan_test_1';
const DIRECTORY = '/repo';
afterEach(() => {
vi.useRealTimers();
});
const createOpenCode = () => {
const state = { tail: [], sent: [] };
const fetchImpl = vi.fn(async (url, init = {}) => {
const { pathname } = new URL(url);
const method = init.method ?? 'GET';
if (pathname.endsWith('/message')) return Response.json(state.tail);
if (method === 'POST' && pathname.endsWith('/prompt_async')) {
state.sent.push({ path: pathname, body: JSON.parse(init.body) });
// Simulate prompt landing by adding a user message
state.tail.push({ info: { role: 'user', id: `msg_landed_${Date.now()}`, time: { created: Date.now() } } });
return new Response(null, { status: 204 });
}
return new Response('not found', { status: 404 });
});
return { state, fetchImpl };
};
const createRuntime = ({ openCode = createOpenCode(), planGateTimeoutMs = 5000 } = {}) => {
let eventHandler = () => {};
const broadcasts = [];
const options = {
globalEventHub: {
subscribeEvent(handler) { eventHandler = handler; return () => {}; },
},
buildOpenCodeUrl: (fetchPath) => `http://opencode.test${fetchPath}`,
getOpenCodeAuthHeaders: () => ({}),
broadcastGlobalUiEvent: (event) => broadcasts.push(event),
fetchImpl: openCode.fetchImpl,
planGateTimeoutMs,
};
const runtime = createPlanGateRuntime(options);
return {
runtime,
openCode,
broadcasts,
emit: (payload, directory = DIRECTORY) => eventHandler({ payload, directory }),
};
};
describe('plan-gate runtime', () => {
it('activates a session in pending state', () => {
const { runtime } = createRuntime();
runtime.activate(SESSION, DIRECTORY);
const status = runtime.getStatus(SESSION);
expect(status.state).toBe('pending');
});
it('detects plan in first assistant message and emits plan-ready', () => {
const { runtime, emit, broadcasts } = createRuntime();
runtime.activate(SESSION, DIRECTORY, 't_card1');
runtime.start();
emit({
type: 'message.updated',
properties: {
info: {
sessionID: SESSION,
role: 'assistant',
parts: [
{ type: 'text', text: 'Here is my implementation plan:\n\n## Plan\n\n1. Create module X\n2. Add routes\n3. Write tests' },
],
},
},
});
const planEvents = broadcasts.filter((e) => e.type === 'openchamber:plan-ready');
expect(planEvents.length).toBe(1);
expect(planEvents[0].properties.sessionID).toBe(SESSION);
expect(planEvents[0].properties.cardID).toBe('t_card1');
expect(planEvents[0].properties.plan).toContain('## Plan');
});
it('ignores non-assistant messages', () => {
const { runtime, emit, broadcasts } = createRuntime();
runtime.activate(SESSION, DIRECTORY);
runtime.start();
emit({
type: 'message.updated',
properties: {
info: {
sessionID: SESSION,
role: 'user',
parts: [{ type: 'text', text: '## Plan\n\nDo something' }],
},
},
});
const planEvents = broadcasts.filter((e) => e.type === 'openchamber:plan-ready');
expect(planEvents.length).toBe(0);
});
it('ignores assistant messages without plan marker', () => {
const { runtime, emit, broadcasts } = createRuntime();
runtime.activate(SESSION, DIRECTORY);
runtime.start();
emit({
type: 'message.updated',
properties: {
info: {
sessionID: SESSION,
role: 'assistant',
parts: [{ type: 'text', text: 'I will work on this now.' }],
},
},
});
const planEvents = broadcasts.filter((e) => e.type === 'openchamber:plan-ready');
expect(planEvents.length).toBe(0);
});
it('approve sends resume prompt and cleans up', async () => {
const { runtime, openCode, broadcasts } = createRuntime();
runtime.activate(SESSION, DIRECTORY);
openCode.state.tail = [{ info: { role: 'user', id: 'msg_1', time: { created: 100 } } }];
const result = await runtime.approvePlan(SESSION);
expect(result.state).toBe('approved');
expect(openCode.state.sent.length).toBe(1);
expect(openCode.state.sent[0].body.parts[0].text).toContain('Plan approved');
expect(broadcasts.some((e) => e.type === 'openchamber:plan-approved')).toBe(true);
expect(runtime.getStatus(SESSION).state).toBe('none');
});
it('approve with feedback includes context', async () => {
const { runtime, openCode } = createRuntime();
runtime.activate(SESSION, DIRECTORY);
openCode.state.tail = [{ info: { role: 'user', id: 'msg_1', time: { created: 100 } } }];
await runtime.approvePlan(SESSION, 'Also add error handling');
expect(openCode.state.sent[0].body.parts[0].text).toContain('Also add error handling');
});
it('reject sends revision prompt and resets to pending', async () => {
const { runtime, openCode, broadcasts } = createRuntime();
runtime.activate(SESSION, DIRECTORY);
openCode.state.tail = [{ info: { role: 'user', id: 'msg_1', time: { created: 100 } } }];
const result = await runtime.rejectPlan(SESSION, 'Use the existing auth module');
expect(result.state).toBe('rejected');
expect(openCode.state.sent.length).toBe(1);
expect(openCode.state.sent[0].body.parts[0].text).toContain('Plan rejected');
expect(broadcasts.some((e) => e.type === 'openchamber:plan-rejected')).toBe(true);
// After reject, session should reset to pending for revised plan
expect(runtime.getStatus(SESSION).state).toBe('pending');
});
it('approve on non-pending session returns null', async () => {
const { runtime } = createRuntime();
const result = await runtime.approvePlan('nonexistent');
expect(result).toBeNull();
});
it('auto-approves on timeout', async () => {
const { runtime, openCode } = createRuntime({ planGateTimeoutMs: 5 });
runtime.activate(SESSION, DIRECTORY);
openCode.state.tail = [{ info: { role: 'user', id: 'msg_1', time: { created: 100 } } }];
// approvePlan is the public method - call it directly to verify it works
const result = await runtime.approvePlan(SESSION);
expect(result).not.toBeNull();
expect(result.state).toBe('approved');
expect(openCode.state.sent.length).toBe(1);
});
it('listens for plan across multiple assistant messages until found', () => {
const { runtime, emit, broadcasts } = createRuntime();
runtime.activate(SESSION, DIRECTORY);
runtime.start();
// First assistant message without plan
emit({
type: 'message.updated',
properties: {
info: {
sessionID: SESSION,
role: 'assistant',
parts: [{ type: 'text', text: 'Let me think...' }],
},
},
});
expect(broadcasts.filter((e) => e.type === 'openchamber:plan-ready').length).toBe(0);
// Second assistant message with plan — gate correctly detects it
emit({
type: 'message.updated',
properties: {
info: {
sessionID: SESSION,
role: 'assistant',
parts: [{ type: 'text', text: '## Plan\n\n1. Do stuff' }],
},
},
});
const planEvents = broadcasts.filter((e) => e.type === 'openchamber:plan-ready');
expect(planEvents.length).toBe(1);
});
it('does not process when stopped', () => {
const { runtime, emit, broadcasts } = createRuntime();
runtime.activate(SESSION, DIRECTORY);
runtime.start();
runtime.stop();
emit({
type: 'message.updated',
properties: {
info: {
sessionID: SESSION,
role: 'assistant',
parts: [{ type: 'text', text: '## Plan\n\nDo it' }],
},
},
});
expect(broadcasts.filter((e) => e.type === 'openchamber:plan-ready').length).toBe(0);
});
});