diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 0d091609..49844ab9 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1383,6 +1383,7 @@ const openChamberSessionService = createOpenChamberSessionService({ waitForOpenCodeReady, emitSessionCreatedEvent, sessionKnowledgeRuntime, + planGateRuntime, }); // Browser actions are published to whichever OpenChamber clients are connected; // the one owning the browser panel answers. `emitRequest` returns the number of diff --git a/packages/web/server/lib/openchamber-sessions/routes.js b/packages/web/server/lib/openchamber-sessions/routes.js index 736cf223..87107e68 100644 --- a/packages/web/server/lib/openchamber-sessions/routes.js +++ b/packages/web/server/lib/openchamber-sessions/routes.js @@ -416,6 +416,7 @@ export const createOpenChamberSessionService = (dependencies) => { emitSessionCreatedEvent, createSessionGoal: createSessionGoalOverride, sessionKnowledgeRuntime = null, + planGateRuntime = null, } = dependencies; // Last user message of an existing session, as a selection to reuse. Returns @@ -758,6 +759,10 @@ export const createOpenChamberSessionService = (dependencies) => { ...(cardID ? { cardID } : {}), }); + if (planGate && planGateRuntime && typeof planGateRuntime.activate === 'function') { + planGateRuntime.activate(sessionID, sessionDirectory, cardID || ''); + } + let dispatch = { model, agent, variant, promptDispatched: false, dispatchedAsCommand: false }; if (prompt) { dispatch = await dispatchPrompt({ diff --git a/packages/web/server/lib/openchamber-sessions/routes.test.js b/packages/web/server/lib/openchamber-sessions/routes.test.js index 6a60ce73..96d098ab 100644 --- a/packages/web/server/lib/openchamber-sessions/routes.test.js +++ b/packages/web/server/lib/openchamber-sessions/routes.test.js @@ -916,4 +916,72 @@ describe('openchamber session routes', () => { globalThis.fetch = originalFetch; } }); + + it('activates planGateRuntime when planGate is true', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_pg_1' }) })); + const activateMock = vi.fn(); + try { + const { app } = createApp({ planGateRuntime: { activate: activateMock } }); + const response = await request(app) + .post('/api/openchamber/sessions') + .send({ directory: '/repo/app', planGate: true }) + .expect(200); + + expect(activateMock).toHaveBeenCalledTimes(1); + expect(activateMock).toHaveBeenCalledWith('ses_pg_1', '/repo/app', ''); + expect(response.body.planGate).toBe(true); + expect(response.body.sessionId).toBe('ses_pg_1'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('does not activate planGateRuntime when planGate is absent', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_npg_1' }) })); + const activateMock = vi.fn(); + try { + const { app } = createApp({ planGateRuntime: { activate: activateMock } }); + await request(app) + .post('/api/openchamber/sessions') + .send({ directory: '/repo/app' }) + .expect(200); + + expect(activateMock).not.toHaveBeenCalled(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('activates planGateRuntime with cardID when both planGate and cardID are provided', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_pg_2' }) })); + const activateMock = vi.fn(); + try { + const { app } = createApp({ planGateRuntime: { activate: activateMock } }); + await request(app) + .post('/api/openchamber/sessions') + .send({ directory: '/repo/app', planGate: true, cardID: 'card_xyz' }) + .expect(200); + + expect(activateMock).toHaveBeenCalledWith('ses_pg_2', '/repo/app', 'card_xyz'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('gracefully skips planGateRuntime when not provided', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_no_rt' }) })); + try { + const { app } = createApp(); + await request(app) + .post('/api/openchamber/sessions') + .send({ directory: '/repo/app', planGate: true }) + .expect(200); + } finally { + globalThis.fetch = originalFetch; + } + }); }); diff --git a/packages/web/server/lib/plan-gate/runtime.js b/packages/web/server/lib/plan-gate/runtime.js index 1faea46f..034c50bc 100644 --- a/packages/web/server/lib/plan-gate/runtime.js +++ b/packages/web/server/lib/plan-gate/runtime.js @@ -7,6 +7,7 @@ // runtime detects the plan in the first assistant message, emits plan-ready, // and holds until approve/reject/timeout. +import express from 'express'; import { GOAL_OBJECTIVE_CHAR_LIMIT } from '../session-goal/objectives.js'; const asRecord = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : null); @@ -300,7 +301,7 @@ export function registerPlanGateRoutes(app, runtime) { res.status(status).json({ error: error?.message ?? fallback }); }; - app.post('/api/openchamber/session/:sessionID/plan/approve', async (req, res) => { + app.post('/api/openchamber/session/:sessionID/plan/approve', express.json({ limit: '1mb' }), async (req, res) => { try { const sessionId = asNonEmptyString(req.params?.sessionID); if (!sessionId) return res.status(400).json({ error: 'sessionID is required' }); @@ -313,7 +314,7 @@ export function registerPlanGateRoutes(app, runtime) { } }); - app.post('/api/openchamber/session/:sessionID/plan/reject', async (req, res) => { + app.post('/api/openchamber/session/:sessionID/plan/reject', express.json({ limit: '1mb' }), async (req, res) => { try { const sessionId = asNonEmptyString(req.params?.sessionID); const feedback = asNonEmptyString(req.body?.feedback); diff --git a/packages/web/server/lib/plan-gate/runtime.test.js b/packages/web/server/lib/plan-gate/runtime.test.js index 9e7e4408..cd0edf5a 100644 --- a/packages/web/server/lib/plan-gate/runtime.test.js +++ b/packages/web/server/lib/plan-gate/runtime.test.js @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createPlanGateRuntime } from './runtime.js'; +import express from 'express'; +import request from 'supertest'; +import { createPlanGateRuntime, registerPlanGateRoutes } from './runtime.js'; const SESSION = 'ses_plan_test_1'; const DIRECTORY = '/repo'; @@ -229,3 +231,43 @@ describe('plan-gate runtime', () => { expect(broadcasts.filter((e) => e.type === 'openchamber:plan-ready').length).toBe(0); }); }); + +describe('plan-gate route middleware', () => { + it('parses JSON body without global middleware on approve route', async () => { + const runtime = createPlanGateRuntime({ + globalEventHub: { subscribeEvent() { return () => {}; } }, + buildOpenCodeUrl: (fetchPath) => `http://opencode.test${fetchPath}`, + getOpenCodeAuthHeaders: () => ({}), + broadcastGlobalUiEvent: () => {}, + fetchImpl: vi.fn(async () => new Response(null, { status: 204 })), + planGateTimeoutMs: 60_000, + }); + runtime.activate(SESSION, DIRECTORY); + const app = express(); + registerPlanGateRoutes(app, runtime); + const res = await request(app) + .post(`/api/openchamber/session/${SESSION}/plan/approve`) + .send({ feedback: 'looks good' }) + .expect(200); + expect(res.body.state).toBe('approved'); + }); + + it('parses JSON body without global middleware on reject route', async () => { + const runtime = createPlanGateRuntime({ + globalEventHub: { subscribeEvent() { return () => {}; } }, + buildOpenCodeUrl: (fetchPath) => `http://opencode.test${fetchPath}`, + getOpenCodeAuthHeaders: () => ({}), + broadcastGlobalUiEvent: () => {}, + fetchImpl: vi.fn(async () => new Response(null, { status: 204 })), + planGateTimeoutMs: 60_000, + }); + runtime.activate(SESSION, DIRECTORY); + const app = express(); + registerPlanGateRoutes(app, runtime); + const res = await request(app) + .post(`/api/openchamber/session/${SESSION}/plan/reject`) + .send({ feedback: 'too vague' }) + .expect(200); + expect(res.body.state).toBe('rejected'); + }); +}); diff --git a/packages/web/server/lib/session-steer/runtime.js b/packages/web/server/lib/session-steer/runtime.js index 9d7dd20c..7741089f 100644 --- a/packages/web/server/lib/session-steer/runtime.js +++ b/packages/web/server/lib/session-steer/runtime.js @@ -6,6 +6,8 @@ // Follows the message-queue precedent: hub subscription, idle detection, // prompt_async with synthetic parts, waitForPromptLanded verification. +import express from 'express'; + 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 : []); @@ -215,7 +217,7 @@ export function registerSessionSteerRoutes(app, runtime) { res.status(status).json({ error: error?.message ?? fallback }); }; - app.post('/api/openchamber/session/:sessionID/steer', async (req, res) => { + app.post('/api/openchamber/session/:sessionID/steer', express.json({ limit: '1mb' }), async (req, res) => { try { const sessionId = asNonEmptyString(req.params?.sessionID); const directive = asNonEmptyString(req.body?.directive); diff --git a/packages/web/server/lib/session-steer/runtime.test.js b/packages/web/server/lib/session-steer/runtime.test.js index 2ebd22c3..5aa25f50 100644 --- a/packages/web/server/lib/session-steer/runtime.test.js +++ b/packages/web/server/lib/session-steer/runtime.test.js @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createSessionSteerRuntime } from './runtime.js'; +import express from 'express'; +import request from 'supertest'; +import { createSessionSteerRuntime, registerSessionSteerRoutes } from './runtime.js'; const SESSION = 'ses_steer_test_1'; const DIRECTORY = '/repo'; @@ -151,3 +153,41 @@ describe('session-steer runtime', () => { expect(openCode.state.sent.length).toBe(0); }); }); + +describe('session-steer route middleware', () => { + it('parses JSON body without global middleware on steer route', async () => { + const { state, fetchImpl } = createOpenCode(); + state.statuses[SESSION] = { type: 'idle' }; + state.tail = [{ info: { role: 'user', id: 'msg_1', time: { created: 100 } } }]; + const runtime = createSessionSteerRuntime({ + globalEventHub: { subscribeEvent() { return () => {}; } }, + buildOpenCodeUrl: (fetchPath) => `http://opencode.test${fetchPath}`, + getOpenCodeAuthHeaders: () => ({}), + broadcastGlobalUiEvent: () => {}, + fetchImpl, + }); + const app = express(); + registerSessionSteerRoutes(app, runtime); + const res = await request(app) + .post(`/api/openchamber/session/${SESSION}/steer`) + .send({ directive: 'Focus on tests', mode: 'interrupt', directory: DIRECTORY }) + .expect(202); + expect(res.body.accepted).toBe(true); + expect(state.sent.length).toBe(1); + }); + + it('returns 400 directive is required when body is missing', async () => { + const runtime = createSessionSteerRuntime({ + globalEventHub: { subscribeEvent() { return () => {}; } }, + buildOpenCodeUrl: (fetchPath) => `http://opencode.test${fetchPath}`, + getOpenCodeAuthHeaders: () => ({}), + broadcastGlobalUiEvent: () => {}, + }); + const app = express(); + registerSessionSteerRoutes(app, runtime); + await request(app) + .post(`/api/openchamber/session/${SESSION}/steer`) + .send({ mode: 'interrupt', directory: DIRECTORY }) + .expect(400, { error: 'directive is required' }); + }); +});