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.
239 lines
7.1 KiB
JavaScript
239 lines
7.1 KiB
JavaScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { createAgentActivityRuntime } from './runtime.js';
|
|
|
|
const SESSION = 'ses_activity_test_1';
|
|
|
|
const makeEvent = (type, properties = {}) => ({
|
|
payload: { type, properties },
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
const createRuntime = ({ coalesceWindowMs = 0, now = Date.now } = {}) => {
|
|
const broadcasts = [];
|
|
const options = {
|
|
globalEventHub: {
|
|
subscribeEvent() { return () => {}; },
|
|
},
|
|
broadcastGlobalUiEvent: (event) => broadcasts.push(event),
|
|
coalesceWindowMs,
|
|
now,
|
|
};
|
|
const runtime = createAgentActivityRuntime(options);
|
|
return {
|
|
runtime,
|
|
broadcasts,
|
|
};
|
|
};
|
|
|
|
describe('agent-activity runtime', () => {
|
|
it('emits agent-activity for tool-call parts', () => {
|
|
const { runtime, broadcasts } = createRuntime();
|
|
|
|
runtime.processPayload({
|
|
type: 'message.updated',
|
|
properties: {
|
|
info: {
|
|
sessionID: SESSION,
|
|
role: 'assistant',
|
|
parts: [
|
|
{ type: 'tool-invocation', tool: 'read', input: { filePath: 'src/foo.ts' } },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
const activityEvents = broadcasts.filter((e) => e.type === 'openchamber:agent-activity');
|
|
expect(activityEvents.length).toBe(1);
|
|
expect(activityEvents[0].properties.kind).toBe('tool-call');
|
|
expect(activityEvents[0].properties.tool).toBe('read');
|
|
expect(activityEvents[0].properties.file).toBe('src/foo.ts');
|
|
expect(activityEvents[0].properties.sessionID).toBe(SESSION);
|
|
});
|
|
|
|
it('emits file-edit kind for edit/write tools', () => {
|
|
const { runtime, broadcasts } = createRuntime();
|
|
|
|
runtime.processPayload({
|
|
type: 'message.updated',
|
|
properties: {
|
|
info: {
|
|
sessionID: SESSION,
|
|
role: 'assistant',
|
|
parts: [
|
|
{ type: 'tool-invocation', tool: 'edit', input: { filePath: 'src/bar.ts' } },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
const activityEvents = broadcasts.filter((e) => e.type === 'openchamber:agent-activity');
|
|
expect(activityEvents.length).toBe(1);
|
|
expect(activityEvents[0].properties.kind).toBe('file-edit');
|
|
expect(activityEvents[0].properties.tool).toBe('edit');
|
|
});
|
|
|
|
it('emits text-part for text parts', () => {
|
|
const { runtime, broadcasts } = createRuntime();
|
|
|
|
runtime.processPayload({
|
|
type: 'message.updated',
|
|
properties: {
|
|
info: {
|
|
sessionID: SESSION,
|
|
role: 'assistant',
|
|
parts: [
|
|
{ type: 'text', text: 'I am working on the task.' },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
const activityEvents = broadcasts.filter((e) => e.type === 'openchamber:agent-activity');
|
|
expect(activityEvents.length).toBe(1);
|
|
expect(activityEvents[0].properties.kind).toBe('text-part');
|
|
expect(activityEvents[0].properties.detail).toBe('I am working on the task.');
|
|
});
|
|
|
|
it('coalesces tool-call events within window', () => {
|
|
let time = 1000;
|
|
const { runtime, broadcasts } = createRuntime({
|
|
coalesceWindowMs: 2000,
|
|
now: () => time,
|
|
});
|
|
|
|
// First tool-call at t=1000 — should emit
|
|
runtime.processPayload({
|
|
type: 'message.updated',
|
|
properties: {
|
|
info: { sessionID: SESSION, role: 'assistant', parts: [{ type: 'tool-invocation', tool: 'read', input: { filePath: 'a.ts' } }] },
|
|
},
|
|
});
|
|
|
|
// Second tool-call at t=1500 (within 2s window) — should be coalesced
|
|
time = 1500;
|
|
runtime.processPayload({
|
|
type: 'message.updated',
|
|
properties: {
|
|
info: { sessionID: SESSION, role: 'assistant', parts: [{ type: 'tool-invocation', tool: 'grep', input: { pattern: 'foo' } }] },
|
|
},
|
|
});
|
|
|
|
const activityEvents = broadcasts.filter((e) => e.type === 'openchamber:agent-activity');
|
|
expect(activityEvents.length).toBe(1);
|
|
|
|
// Third tool-call at t=3500 (after window) — should emit
|
|
time = 3500;
|
|
runtime.processPayload({
|
|
type: 'message.updated',
|
|
properties: {
|
|
info: { sessionID: SESSION, role: 'assistant', parts: [{ type: 'tool-invocation', tool: 'read', input: { filePath: 'b.ts' } }] },
|
|
},
|
|
});
|
|
|
|
const allEvents = broadcasts.filter((e) => e.type === 'openchamber:agent-activity');
|
|
expect(allEvents.length).toBe(2);
|
|
});
|
|
|
|
it('always emits file-edit events without coalescing', () => {
|
|
let time = 1000;
|
|
const { runtime, broadcasts } = createRuntime({
|
|
coalesceWindowMs: 2000,
|
|
now: () => time,
|
|
});
|
|
|
|
time = 1000;
|
|
runtime.processPayload({
|
|
type: 'message.updated',
|
|
properties: {
|
|
info: { sessionID: SESSION, role: 'assistant', parts: [{ type: 'tool-invocation', tool: 'edit', input: { filePath: 'a.ts' } }] },
|
|
},
|
|
});
|
|
|
|
time = 1500;
|
|
runtime.processPayload({
|
|
type: 'message.updated',
|
|
properties: {
|
|
info: { sessionID: SESSION, role: 'assistant', parts: [{ type: 'tool-invocation', tool: 'write', input: { filePath: 'b.ts' } }] },
|
|
},
|
|
});
|
|
|
|
const fileEdits = broadcasts.filter((e) => e.type === 'openchamber:agent-activity' && e.properties.kind === 'file-edit');
|
|
expect(fileEdits.length).toBe(2);
|
|
});
|
|
|
|
it('emits session-completed on idle', () => {
|
|
const { runtime, broadcasts } = createRuntime();
|
|
|
|
runtime.processPayload({
|
|
type: 'session.status',
|
|
properties: {
|
|
status: { type: 'idle' },
|
|
sessionID: SESSION,
|
|
},
|
|
});
|
|
|
|
const completed = broadcasts.filter((e) => e.type === 'openchamber:session-completed');
|
|
expect(completed.length).toBe(1);
|
|
expect(completed[0].properties.sessionID).toBe(SESSION);
|
|
});
|
|
|
|
it('does not emit when stopped', () => {
|
|
const { runtime, broadcasts } = createRuntime();
|
|
runtime.stop();
|
|
|
|
runtime.processPayload({
|
|
type: 'message.updated',
|
|
properties: {
|
|
info: {
|
|
sessionID: SESSION,
|
|
role: 'assistant',
|
|
parts: [{ type: 'tool-invocation', tool: 'read', input: { filePath: 'x.ts' } }],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(broadcasts.length).toBe(0);
|
|
});
|
|
|
|
it('includes cardID from metadata when present', () => {
|
|
const { runtime, broadcasts } = createRuntime();
|
|
|
|
runtime.processPayload({
|
|
type: 'message.updated',
|
|
properties: {
|
|
info: {
|
|
sessionID: SESSION,
|
|
role: 'assistant',
|
|
metadata: { openchamber: { cardID: 't_abc123' } },
|
|
parts: [{ type: 'tool-invocation', tool: 'edit', input: { filePath: 'x.ts' } }],
|
|
},
|
|
},
|
|
});
|
|
|
|
const events = broadcasts.filter((e) => e.type === 'openchamber:agent-activity');
|
|
expect(events[0].properties.cardID).toBe('t_abc123');
|
|
});
|
|
|
|
it('includes token counts when present', () => {
|
|
const { runtime, broadcasts } = createRuntime();
|
|
|
|
runtime.processPayload({
|
|
type: 'message.updated',
|
|
properties: {
|
|
info: {
|
|
sessionID: SESSION,
|
|
role: 'assistant',
|
|
tokens: { input: 5000, output: 200, cache: { read: 1000 } },
|
|
parts: [{ type: 'text', text: 'done' }],
|
|
},
|
|
},
|
|
});
|
|
|
|
const events = broadcasts.filter((e) => e.type === 'openchamber:agent-activity');
|
|
expect(events[0].properties.tokens).toEqual({ input: 5000, output: 200, cachedRead: 1000 });
|
|
});
|
|
});
|