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,339 @@
|
||||
// Plan-first gate: the agent must emit its implementation plan and get approval
|
||||
// before touching files. State machine per session:
|
||||
// pending → approved | rejected | timed_out
|
||||
//
|
||||
// When planGate is active on a session, the fork injects a system-reminder into
|
||||
// the initial prompt instructing the agent to output `## Plan` and stop. The
|
||||
// runtime detects the plan in the first assistant message, emits plan-ready,
|
||||
// and holds until approve/reject/timeout.
|
||||
|
||||
import { GOAL_OBJECTIVE_CHAR_LIMIT } from '../session-goal/objectives.js';
|
||||
|
||||
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 FETCH_TIMEOUT_MS = 15_000;
|
||||
const LANDED_TIMEOUT_MS = 5_000;
|
||||
const LANDED_POLL_MS = 150;
|
||||
const DEFAULT_PLAN_GATE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
const PLAN_MARKER = '## Plan';
|
||||
|
||||
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 extractAssistantText = (info) => {
|
||||
const parts = asList(info?.parts);
|
||||
return parts
|
||||
.map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : ''))
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.slice(0, GOAL_OBJECTIVE_CHAR_LIMIT);
|
||||
};
|
||||
|
||||
const extractPlanFromText = (text) => {
|
||||
const idx = text.indexOf(PLAN_MARKER);
|
||||
if (idx < 0) return null;
|
||||
return text.slice(idx).trim();
|
||||
};
|
||||
|
||||
const extractCardID = (info) => {
|
||||
const metadata = asRecord(info?.metadata);
|
||||
const namespace = asRecord(metadata?.openchamber);
|
||||
return asNonEmptyString(namespace?.cardID) || '';
|
||||
};
|
||||
|
||||
export function createPlanGateRuntime({
|
||||
globalEventHub,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
broadcastGlobalUiEvent,
|
||||
fetchImpl = fetch,
|
||||
planGateTimeoutMs = DEFAULT_PLAN_GATE_TIMEOUT_MS,
|
||||
}) {
|
||||
/** sessionId → { state, plan, directory, cardID, timeout } */
|
||||
const sessions = new Map();
|
||||
let stopped = false;
|
||||
|
||||
const openCodeFetch = async (fetchPath, { directory, method = 'GET', body, query } = {}) => {
|
||||
const base = buildOpenCodeUrl(fetchPath, '');
|
||||
const params = new URLSearchParams(query || {});
|
||||
if (directory) params.set('directory', directory);
|
||||
const search = params.toString();
|
||||
const url = search ? `${base}?${search}` : base;
|
||||
const response = await fetchImpl(url, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...getOpenCodeAuthHeaders(),
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenCode ${method} ${fetchPath} failed with ${response.status}`);
|
||||
}
|
||||
return response.json().catch(() => null);
|
||||
};
|
||||
|
||||
const latestUserMessageID = async (sessionId, directory) => {
|
||||
const messages = asList(await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/message`, {
|
||||
directory,
|
||||
query: { limit: '5' },
|
||||
}).catch(() => null));
|
||||
let latest = null;
|
||||
for (const msg of messages) {
|
||||
const info = asRecord(msg?.info);
|
||||
if (info?.role !== 'user') continue;
|
||||
if (!latest || (info.time?.created || 0) >= (latest.time?.created || 0)) latest = info;
|
||||
}
|
||||
return asNonEmptyString(latest?.id) || null;
|
||||
};
|
||||
|
||||
const waitForPromptLanded = async (sessionId, directory, baselineUserMessageID) => {
|
||||
const deadline = Date.now() + LANDED_TIMEOUT_MS;
|
||||
for (;;) {
|
||||
const latest = await latestUserMessageID(sessionId, directory);
|
||||
if (!latest) return true;
|
||||
if (latest !== baselineUserMessageID) return true;
|
||||
if (Date.now() >= deadline) return false;
|
||||
await new Promise((resolve) => setTimeout(resolve, LANDED_POLL_MS));
|
||||
}
|
||||
};
|
||||
|
||||
const clearSessionTimeout = (sessionId) => {
|
||||
const session = sessions.get(sessionId);
|
||||
if (session?.timeout) {
|
||||
clearTimeout(session.timeout);
|
||||
session.timeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
const armTimeout = (sessionId) => {
|
||||
clearSessionTimeout(sessionId);
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.state !== 'pending') return;
|
||||
const timer = setTimeout(() => {
|
||||
if (stopped) return;
|
||||
const current = sessions.get(sessionId);
|
||||
if (!current || current.state !== 'pending') return;
|
||||
current.state = 'timed_out';
|
||||
clearSessionTimeout(sessionId);
|
||||
broadcastGlobalUiEvent?.({
|
||||
type: 'openchamber:plan-timed-out',
|
||||
properties: {
|
||||
sessionID: sessionId,
|
||||
cardID: current.cardID || '',
|
||||
ts: Date.now(),
|
||||
},
|
||||
});
|
||||
// Auto-approve on timeout
|
||||
approvePlan(sessionId);
|
||||
}, planGateTimeoutMs);
|
||||
if (typeof timer?.unref === 'function') timer.unref();
|
||||
session.timeout = timer;
|
||||
};
|
||||
|
||||
const approvePlan = async (sessionId, feedback = '') => {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.state !== 'pending') return null;
|
||||
clearSessionTimeout(sessionId);
|
||||
session.state = 'approved';
|
||||
const directory = session.directory;
|
||||
const cardID = session.cardID;
|
||||
|
||||
broadcastGlobalUiEvent?.({
|
||||
type: 'openchamber:plan-approved',
|
||||
properties: { sessionID: sessionId, cardID, ts: Date.now() },
|
||||
});
|
||||
|
||||
try {
|
||||
const promptText = feedback
|
||||
? `Plan approved. Additional context: ${feedback}\n\nProceed with implementation.`
|
||||
: 'Plan approved. Proceed with implementation.';
|
||||
const baseline = await latestUserMessageID(sessionId, directory);
|
||||
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/prompt_async`, {
|
||||
directory,
|
||||
method: 'POST',
|
||||
body: {
|
||||
parts: [{ type: 'text', text: promptText, synthetic: true }],
|
||||
},
|
||||
});
|
||||
// Fire-and-forget: the prompt was accepted by OpenCode. Verification
|
||||
// via waitForPromptLanded is optional — if it fails the user sees no
|
||||
// response and can re-approve.
|
||||
waitForPromptLanded(sessionId, directory, baseline).catch(() => {});
|
||||
} catch (error) {
|
||||
console.warn('[plan-gate] approve delivery failed:', error?.message || error);
|
||||
}
|
||||
|
||||
sessions.delete(sessionId);
|
||||
return { state: 'approved' };
|
||||
};
|
||||
|
||||
const rejectPlan = async (sessionId, feedback) => {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.state !== 'pending') return null;
|
||||
clearSessionTimeout(sessionId);
|
||||
session.state = 'rejected';
|
||||
const directory = session.directory;
|
||||
const cardID = session.cardID;
|
||||
|
||||
broadcastGlobalUiEvent?.({
|
||||
type: 'openchamber:plan-rejected',
|
||||
properties: { sessionID: sessionId, cardID, feedback, ts: Date.now() },
|
||||
});
|
||||
|
||||
try {
|
||||
const promptText = `Plan rejected. Revise: ${feedback}\n\nOutput a revised plan as a markdown block starting with ## Plan, then STOP and wait for approval.`;
|
||||
const baseline = await latestUserMessageID(sessionId, directory);
|
||||
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/prompt_async`, {
|
||||
directory,
|
||||
method: 'POST',
|
||||
body: {
|
||||
parts: [{ type: 'text', text: promptText, synthetic: true }],
|
||||
},
|
||||
});
|
||||
waitForPromptLanded(sessionId, directory, baseline).catch(() => {});
|
||||
// Reset to pending so the revised plan can be detected
|
||||
session.state = 'pending';
|
||||
session.plan = null;
|
||||
armTimeout(sessionId);
|
||||
} catch (error) {
|
||||
console.warn('[plan-gate] reject delivery failed:', error?.message || error);
|
||||
}
|
||||
|
||||
return { state: 'rejected' };
|
||||
};
|
||||
|
||||
const getStatus = (sessionId) => {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return { state: 'none', plan: null, ts: null };
|
||||
return {
|
||||
state: session.state,
|
||||
plan: session.plan,
|
||||
cardID: session.cardID || '',
|
||||
ts: session.ts,
|
||||
};
|
||||
};
|
||||
|
||||
const activate = (sessionId, directory, cardID = '') => {
|
||||
if (sessions.has(sessionId)) {
|
||||
clearSessionTimeout(sessionId);
|
||||
}
|
||||
sessions.set(sessionId, {
|
||||
state: 'pending',
|
||||
plan: null,
|
||||
directory,
|
||||
cardID,
|
||||
ts: Date.now(),
|
||||
timeout: null,
|
||||
});
|
||||
armTimeout(sessionId);
|
||||
};
|
||||
|
||||
const processPayload = (payload) => {
|
||||
if (stopped) return;
|
||||
const messageUpdate = extractMessageUpdate(payload);
|
||||
if (!messageUpdate) return;
|
||||
|
||||
const { sessionId, info } = messageUpdate;
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.state !== 'pending') return;
|
||||
if (info.role !== 'assistant') return;
|
||||
// Only process the first assistant message (plan detection)
|
||||
if (session.plan !== null) return;
|
||||
|
||||
const text = extractAssistantText(info);
|
||||
const plan = extractPlanFromText(text);
|
||||
if (!plan) return;
|
||||
|
||||
session.plan = plan;
|
||||
clearSessionTimeout(sessionId);
|
||||
|
||||
const cardID = session.cardID || extractCardID(info);
|
||||
if (cardID && !session.cardID) session.cardID = cardID;
|
||||
|
||||
broadcastGlobalUiEvent?.({
|
||||
type: 'openchamber:plan-ready',
|
||||
properties: {
|
||||
sessionID: sessionId,
|
||||
cardID,
|
||||
plan,
|
||||
ts: Date.now(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
for (const session of sessions.values()) {
|
||||
clearSessionTimeout(session.id);
|
||||
}
|
||||
sessions.clear();
|
||||
};
|
||||
|
||||
return { approvePlan, rejectPlan, getStatus, activate, processPayload, start, stop };
|
||||
}
|
||||
|
||||
export function registerPlanGateRoutes(app, runtime) {
|
||||
const respondError = (res, error, fallback) => {
|
||||
const status = error instanceof TypeError ? 400 : (Number.isFinite(error?.status) ? error.status : 500);
|
||||
res.status(status).json({ error: error?.message ?? fallback });
|
||||
};
|
||||
|
||||
app.post('/api/openchamber/session/:sessionID/plan/approve', async (req, res) => {
|
||||
try {
|
||||
const sessionId = asNonEmptyString(req.params?.sessionID);
|
||||
if (!sessionId) return res.status(400).json({ error: 'sessionID is required' });
|
||||
const feedback = asNonEmptyString(req.body?.feedback) || '';
|
||||
const result = await runtime.approvePlan(sessionId, feedback);
|
||||
if (!result) return res.status(404).json({ error: 'No pending plan for this session' });
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return respondError(res, error, 'Failed to approve plan');
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/openchamber/session/:sessionID/plan/reject', async (req, res) => {
|
||||
try {
|
||||
const sessionId = asNonEmptyString(req.params?.sessionID);
|
||||
const feedback = asNonEmptyString(req.body?.feedback);
|
||||
if (!sessionId) return res.status(400).json({ error: 'sessionID is required' });
|
||||
if (!feedback) return res.status(400).json({ error: 'feedback is required for rejection' });
|
||||
const result = await runtime.rejectPlan(sessionId, feedback);
|
||||
if (!result) return res.status(404).json({ error: 'No pending plan for this session' });
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return respondError(res, error, 'Failed to reject plan');
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/openchamber/session/:sessionID/plan/status', (req, res) => {
|
||||
try {
|
||||
const sessionId = asNonEmptyString(req.params?.sessionID);
|
||||
if (!sessionId) return res.status(400).json({ error: 'sessionID is required' });
|
||||
return res.json(runtime.getStatus(sessionId));
|
||||
} catch (error) {
|
||||
return respondError(res, error, 'Failed to get plan status');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user