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.
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
// Live activity stream: normalizes upstream SSE message.updated events into
|
||||
// structured activity events the wrapper can consume in real time. Subscribes
|
||||
// to the global hub (same pattern as message-queue/runtime.js) and broadcasts
|
||||
// via broadcastGlobalUiEvent.
|
||||
//
|
||||
// Rate-limiting: tool-call events are coalesced to ~1 per 2s per session.
|
||||
// File-edit and plan-step events always emit immediately.
|
||||
|
||||
const COALESCE_WINDOW_MS = 2_000;
|
||||
const TEXT_PART_CHAR_LIMIT = 200;
|
||||
|
||||
const asRecord = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : null);
|
||||
const asNonEmptyString = (value) => (typeof value === 'string' && value.trim() ? value.trim() : '');
|
||||
const asList = (value) => (Array.isArray(value) ? value : []);
|
||||
const asCount = (value) => (Number.isFinite(value) && value >= 0 ? Math.floor(value) : null);
|
||||
|
||||
const extractSessionStatus = (payload) => {
|
||||
if (payload.type !== 'session.status') return null;
|
||||
const properties = asRecord(payload.properties) ?? {};
|
||||
const status = asRecord(properties.status) ?? {};
|
||||
const info = asRecord(properties.info) ?? {};
|
||||
const sessionId = asNonEmptyString(properties.sessionID);
|
||||
const type = asNonEmptyString(status.type) || asNonEmptyString(info.type);
|
||||
if (!sessionId || !type) return null;
|
||||
return { sessionId, type };
|
||||
};
|
||||
|
||||
const extractMessageUpdate = (payload) => {
|
||||
if (payload.type !== 'message.updated') return null;
|
||||
const properties = asRecord(payload.properties) ?? {};
|
||||
const info = asRecord(properties.info);
|
||||
if (!info) return null;
|
||||
const sessionId = asNonEmptyString(info.sessionID);
|
||||
if (!sessionId) return null;
|
||||
return { sessionId, info };
|
||||
};
|
||||
|
||||
const isReadLikeTool = (toolName) => {
|
||||
const name = String(toolName ?? '').toLowerCase();
|
||||
return name === 'read' || name === 'grep' || name === 'search' || name === 'glob'
|
||||
|| name === 'list' || name === 'ls';
|
||||
};
|
||||
|
||||
const isFileEditTool = (toolName) => {
|
||||
const name = String(toolName ?? '').toLowerCase();
|
||||
return name === 'edit' || name === 'write' || name === 'bash';
|
||||
};
|
||||
|
||||
const normalizeToolPart = (part) => {
|
||||
const toolName = asNonEmptyString(part?.tool) || asNonEmptyString(part?.name) || '';
|
||||
if (!toolName) return null;
|
||||
const input = asRecord(part?.input) ?? asRecord(part?.parameters) ?? {};
|
||||
const filePath = asNonEmptyString(input?.filePath) || asNonEmptyString(input?.path) || '';
|
||||
const command = asNonEmptyString(input?.command) || '';
|
||||
const detail = filePath
|
||||
? `${toolName} ${filePath}`
|
||||
: (command ? `${toolName} ${command.slice(0, 80)}` : toolName);
|
||||
return { tool: toolName, file: filePath, detail };
|
||||
};
|
||||
|
||||
const normalizeTextPart = (part) => {
|
||||
const text = typeof part?.text === 'string' ? part.text.trim() : '';
|
||||
if (!text) return null;
|
||||
return { detail: text.slice(0, TEXT_PART_CHAR_LIMIT) };
|
||||
};
|
||||
|
||||
const normalizeParts = (parts) => {
|
||||
const activities = [];
|
||||
for (const part of asList(parts)) {
|
||||
if (!part || typeof part !== 'object') continue;
|
||||
if (part.type === 'tool-invocation' || part.type === 'tool') {
|
||||
const normalized = normalizeToolPart(part);
|
||||
if (normalized) {
|
||||
const kind = isFileEditTool(normalized.tool) ? 'file-edit'
|
||||
: isReadLikeTool(normalized.tool) ? 'tool-call'
|
||||
: 'tool-call';
|
||||
activities.push({ kind, ...normalized });
|
||||
}
|
||||
} else if (part.type === 'text') {
|
||||
const normalized = normalizeTextPart(part);
|
||||
if (normalized) {
|
||||
activities.push({ kind: 'text-part', tool: '', file: '', ...normalized });
|
||||
}
|
||||
}
|
||||
}
|
||||
return activities;
|
||||
};
|
||||
|
||||
const extractCardID = (info) => {
|
||||
const metadata = asRecord(info?.metadata);
|
||||
const namespace = asRecord(metadata?.openchamber);
|
||||
return asNonEmptyString(namespace?.cardID) || '';
|
||||
};
|
||||
|
||||
const extractTokens = (info) => {
|
||||
const tokens = asRecord(info?.tokens);
|
||||
if (!tokens) return null;
|
||||
const input = asCount(tokens.input) ?? 0;
|
||||
const output = asCount(tokens.output) ?? 0;
|
||||
const cachedRead = asCount(tokens.cache?.read) ?? 0;
|
||||
return { input, output, cachedRead };
|
||||
};
|
||||
|
||||
export function createAgentActivityRuntime({
|
||||
globalEventHub,
|
||||
broadcastGlobalUiEvent,
|
||||
coalesceWindowMs = COALESCE_WINDOW_MS,
|
||||
now = Date.now,
|
||||
}) {
|
||||
let stopped = false;
|
||||
/** sessionId → timestamp of last emitted tool-call event */
|
||||
const lastToolEmit = new Map();
|
||||
|
||||
const processPayload = (payload) => {
|
||||
if (stopped || !payload || typeof payload !== 'object') return;
|
||||
|
||||
const messageUpdate = extractMessageUpdate(payload);
|
||||
if (messageUpdate) {
|
||||
processMessageUpdate(messageUpdate);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = extractSessionStatus(payload);
|
||||
if (status && status.type === 'idle') {
|
||||
const cardID = ''; // cardID resolved from metadata if available
|
||||
broadcastGlobalUiEvent?.({
|
||||
type: 'openchamber:session-completed',
|
||||
properties: {
|
||||
sessionID: status.sessionId,
|
||||
cardID,
|
||||
ts: now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const processMessageUpdate = ({ sessionId, info }) => {
|
||||
const parts = asList(info?.parts);
|
||||
const activities = normalizeParts(parts);
|
||||
if (activities.length === 0) return;
|
||||
|
||||
const cardID = extractCardID(info);
|
||||
const tokens = extractTokens(info);
|
||||
|
||||
for (const activity of activities) {
|
||||
const isToolCall = activity.kind === 'tool-call';
|
||||
const lastEmit = lastToolEmit.get(sessionId);
|
||||
if (isToolCall && lastEmit !== undefined && (now() - lastEmit) < coalesceWindowMs) {
|
||||
continue;
|
||||
}
|
||||
if (isToolCall) {
|
||||
lastToolEmit.set(sessionId, now());
|
||||
}
|
||||
|
||||
broadcastGlobalUiEvent?.({
|
||||
type: 'openchamber:agent-activity',
|
||||
properties: {
|
||||
sessionID: sessionId,
|
||||
cardID,
|
||||
ts: now(),
|
||||
kind: activity.kind,
|
||||
tool: activity.tool,
|
||||
file: activity.file,
|
||||
detail: activity.detail,
|
||||
...(tokens ? { tokens } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
const unsubscribe = globalEventHub.subscribeEvent((event) => {
|
||||
const raw = event?.payload;
|
||||
const payload = raw?.payload && typeof raw.payload === 'object' ? raw.payload : raw;
|
||||
processPayload(payload);
|
||||
});
|
||||
return () => { unsubscribe(); };
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
lastToolEmit.clear();
|
||||
};
|
||||
|
||||
return { processPayload, start, stop };
|
||||
}
|
||||
|
||||
export function registerAgentActivityRoutes(app, { getOpenChamberEventClients, writeSseEvent }) {
|
||||
app.get('/api/openchamber/agent-activity', (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders?.();
|
||||
|
||||
const clients = getOpenChamberEventClients();
|
||||
clients.add(res);
|
||||
|
||||
try {
|
||||
writeSseEvent(res, {
|
||||
type: 'openchamber:agent-activity-stream-ready',
|
||||
properties: { connectedAt: Date.now() },
|
||||
});
|
||||
} catch {}
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
writeSseEvent(res, {
|
||||
type: 'openchamber:heartbeat',
|
||||
properties: { timestamp: Date.now() },
|
||||
});
|
||||
} catch {
|
||||
clearInterval(heartbeat);
|
||||
clients.delete(res);
|
||||
}
|
||||
}, 25_000);
|
||||
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
clients.delete(res);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user