Merge pull request 'fix: wire plan-gate activation and add express.json to steer/plan-gate routes' (#13) from feat/agent-integrations into custom
This commit is contained in:
@@ -1383,6 +1383,7 @@ const openChamberSessionService = createOpenChamberSessionService({
|
|||||||
waitForOpenCodeReady,
|
waitForOpenCodeReady,
|
||||||
emitSessionCreatedEvent,
|
emitSessionCreatedEvent,
|
||||||
sessionKnowledgeRuntime,
|
sessionKnowledgeRuntime,
|
||||||
|
planGateRuntime,
|
||||||
});
|
});
|
||||||
// Browser actions are published to whichever OpenChamber clients are connected;
|
// Browser actions are published to whichever OpenChamber clients are connected;
|
||||||
// the one owning the browser panel answers. `emitRequest` returns the number of
|
// the one owning the browser panel answers. `emitRequest` returns the number of
|
||||||
|
|||||||
@@ -416,6 +416,7 @@ export const createOpenChamberSessionService = (dependencies) => {
|
|||||||
emitSessionCreatedEvent,
|
emitSessionCreatedEvent,
|
||||||
createSessionGoal: createSessionGoalOverride,
|
createSessionGoal: createSessionGoalOverride,
|
||||||
sessionKnowledgeRuntime = null,
|
sessionKnowledgeRuntime = null,
|
||||||
|
planGateRuntime = null,
|
||||||
} = dependencies;
|
} = dependencies;
|
||||||
|
|
||||||
// Last user message of an existing session, as a selection to reuse. Returns
|
// Last user message of an existing session, as a selection to reuse. Returns
|
||||||
@@ -758,6 +759,10 @@ export const createOpenChamberSessionService = (dependencies) => {
|
|||||||
...(cardID ? { cardID } : {}),
|
...(cardID ? { cardID } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (planGate && planGateRuntime && typeof planGateRuntime.activate === 'function') {
|
||||||
|
planGateRuntime.activate(sessionID, sessionDirectory, cardID || '');
|
||||||
|
}
|
||||||
|
|
||||||
let dispatch = { model, agent, variant, promptDispatched: false, dispatchedAsCommand: false };
|
let dispatch = { model, agent, variant, promptDispatched: false, dispatchedAsCommand: false };
|
||||||
if (prompt) {
|
if (prompt) {
|
||||||
dispatch = await dispatchPrompt({
|
dispatch = await dispatchPrompt({
|
||||||
|
|||||||
@@ -916,4 +916,72 @@ describe('openchamber session routes', () => {
|
|||||||
globalThis.fetch = originalFetch;
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
// runtime detects the plan in the first assistant message, emits plan-ready,
|
// runtime detects the plan in the first assistant message, emits plan-ready,
|
||||||
// and holds until approve/reject/timeout.
|
// and holds until approve/reject/timeout.
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
import { GOAL_OBJECTIVE_CHAR_LIMIT } from '../session-goal/objectives.js';
|
import { GOAL_OBJECTIVE_CHAR_LIMIT } from '../session-goal/objectives.js';
|
||||||
|
|
||||||
const asRecord = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : null);
|
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 });
|
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 {
|
try {
|
||||||
const sessionId = asNonEmptyString(req.params?.sessionID);
|
const sessionId = asNonEmptyString(req.params?.sessionID);
|
||||||
if (!sessionId) return res.status(400).json({ error: 'sessionID is required' });
|
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 {
|
try {
|
||||||
const sessionId = asNonEmptyString(req.params?.sessionID);
|
const sessionId = asNonEmptyString(req.params?.sessionID);
|
||||||
const feedback = asNonEmptyString(req.body?.feedback);
|
const feedback = asNonEmptyString(req.body?.feedback);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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 SESSION = 'ses_plan_test_1';
|
||||||
const DIRECTORY = '/repo';
|
const DIRECTORY = '/repo';
|
||||||
@@ -229,3 +231,43 @@ describe('plan-gate runtime', () => {
|
|||||||
expect(broadcasts.filter((e) => e.type === 'openchamber:plan-ready').length).toBe(0);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
// Follows the message-queue precedent: hub subscription, idle detection,
|
// Follows the message-queue precedent: hub subscription, idle detection,
|
||||||
// prompt_async with synthetic parts, waitForPromptLanded verification.
|
// prompt_async with synthetic parts, waitForPromptLanded verification.
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
|
||||||
const asRecord = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : null);
|
const asRecord = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : null);
|
||||||
const asNonEmptyString = (value) => (typeof value === 'string' && value.trim() ? value.trim() : '');
|
const asNonEmptyString = (value) => (typeof value === 'string' && value.trim() ? value.trim() : '');
|
||||||
const asList = (value) => (Array.isArray(value) ? value : []);
|
const asList = (value) => (Array.isArray(value) ? value : []);
|
||||||
@@ -215,7 +217,7 @@ export function registerSessionSteerRoutes(app, runtime) {
|
|||||||
res.status(status).json({ error: error?.message ?? fallback });
|
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 {
|
try {
|
||||||
const sessionId = asNonEmptyString(req.params?.sessionID);
|
const sessionId = asNonEmptyString(req.params?.sessionID);
|
||||||
const directive = asNonEmptyString(req.body?.directive);
|
const directive = asNonEmptyString(req.body?.directive);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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 SESSION = 'ses_steer_test_1';
|
||||||
const DIRECTORY = '/repo';
|
const DIRECTORY = '/repo';
|
||||||
@@ -151,3 +153,41 @@ describe('session-steer runtime', () => {
|
|||||||
expect(openCode.state.sent.length).toBe(0);
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user