Files
openchamber/packages/web/server/lib/openchamber-sessions/routes.test.js
T
bot-hermes 9a0c67081e fix: wire plan-gate activation and add express.json to steer/plan-gate routes
- Wire planGateRuntime.activate() into session creation path when planGate is true
  (Bug 1: sessions map stayed empty, plan/status always returned none)
- Add express.json({ limit: '1mb' }) to session-steer and plan-gate POST routes
  (Bug 2: req.body was undefined, POST with JSON body returned 400)
- Pass planGateRuntime dependency to createOpenChamberSessionService
- Add route-level and integration tests for both fixes
2026-09-07 23:41:36 +00:00

988 lines
37 KiB
JavaScript

import express from 'express';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const createWorktreeMock = vi.fn(async () => ({
head: 'abc123',
name: 'side-task',
branch: 'openchamber/side-task',
path: '/repo/worktrees/side-task',
}));
const getWorktreeBootstrapStatusMock = vi.fn(async () => ({
status: 'ready',
phase: 'setup-ready',
error: null,
updatedAt: Date.now(),
}));
const sessionCreateMock = vi.fn(async () => ({ data: { id: 'ses_123' } }));
const sessionForkMock = vi.fn(async () => ({ data: { id: 'ses_fork', title: 'Forked session' } }));
const sessionMessagesMock = vi.fn(async () => ({ data: [] }));
const sessionUpdateMock = vi.fn(async ({ sessionID }) => ({ data: { id: sessionID, time: { archived: 1 } } }));
let existingSessionMessages = [];
let dispatchedUserMessageSeq = 0;
// The service confirms a prompt landed by watching for a new user message, so
// the default mock behaves like OpenCode recording each dispatched prompt.
const setSessionMessages = (messages) => {
existingSessionMessages = messages;
};
const recordedSessionMessages = async () => {
dispatchedUserMessageSeq += 1;
return {
data: [
...existingSessionMessages,
{
info: {
id: `msg_dispatched_${dispatchedUserMessageSeq}`,
role: 'user',
time: { created: 1000 + dispatchedUserMessageSeq },
},
},
],
};
};
// Selection inputs are fetched whenever a request names a model, agent, or
// variant, so every prompt-dispatching fetch mock must answer them.
const selectionInputResponse = (url) => {
const text = String(url);
if (text.includes('/config/providers')) {
return {
ok: true,
json: async () => ({
providers: [
{ id: 'openai', models: [{ id: 'gpt-5.5', variants: { high: {} } }] },
{ id: 'anthropic', models: [{ id: 'claude-sonnet-5', variants: { high: {} } }] },
],
}),
};
}
if (text.includes('/agent')) {
return { ok: true, json: async () => [{ name: 'build', mode: 'primary' }, { name: 'plan', mode: 'primary' }] };
}
if (text.includes('/config')) return { ok: true, json: async () => ({}) };
return null;
};
const sessionCommandMock = vi.fn(async () => ({ data: {} }));
const commandListMock = vi.fn(async () => ({ data: [] }));
globalThis.__openchamberCreateWorktreeMock = createWorktreeMock;
globalThis.__openchamberGetWorktreeBootstrapStatusMock = getWorktreeBootstrapStatusMock;
let registerOpenChamberSessionRoutes;
vi.mock('@opencode-ai/sdk/v2', () => ({
createOpencodeClient: () => ({
session: {
create: sessionCreateMock,
fork: sessionForkMock,
messages: sessionMessagesMock,
command: sessionCommandMock,
update: sessionUpdateMock,
},
command: {
list: commandListMock,
},
}),
}));
vi.mock('../git/index.js', () => ({
createWorktree: (...args) => globalThis.__openchamberCreateWorktreeMock(...args),
getWorktreeBootstrapStatus: (...args) => globalThis.__openchamberGetWorktreeBootstrapStatusMock(...args),
}));
const createApp = (overrides = {}, options = {}) => {
const app = express();
if (options.globalJson !== false) {
app.use(express.json());
}
const calls = [];
registerOpenChamberSessionRoutes(app, {
readSettingsFromDiskMigrated: async () => ({ projects: [{ id: 'proj_1', path: '/repo/app' }] }),
sanitizeProjects: (projects) => projects,
validateDirectoryPath: async (directory) => ({ ok: true, directory }),
buildOpenCodeUrl: (route) => `http://opencode.test${route}`,
getOpenCodeAuthHeaders: () => ({ Authorization: 'Bearer test' }),
waitForOpenCodeReady: vi.fn(async () => undefined),
...overrides,
});
return { app, calls };
};
describe('openchamber session routes', () => {
beforeAll(async () => {
({ registerOpenChamberSessionRoutes } = await import('./routes.js'));
});
beforeEach(() => {
createWorktreeMock.mockClear();
getWorktreeBootstrapStatusMock.mockClear();
getWorktreeBootstrapStatusMock.mockImplementation(async () => ({
status: 'ready',
phase: 'setup-ready',
error: null,
updatedAt: Date.now(),
}));
sessionCreateMock.mockClear();
sessionForkMock.mockClear();
existingSessionMessages = [];
dispatchedUserMessageSeq = 0;
sessionMessagesMock.mockReset();
sessionMessagesMock.mockImplementation(recordedSessionMessages);
sessionCommandMock.mockReset();
sessionCommandMock.mockResolvedValue({ data: {} });
commandListMock.mockReset();
commandListMock.mockResolvedValue({ data: [] });
sessionUpdateMock.mockReset();
sessionUpdateMock.mockImplementation(async ({ sessionID }) => ({ data: { id: sessionID, time: { archived: 1 } } }));
});
describe('archiving a batch of sessions', () => {
it('archives every id against the resolved directory and returns the archived sessions', async () => {
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions/archive')
.send({ directory: '/repo/app', ids: ['ses_a', 'ses_b'], archivedAt: 1700 })
.expect(200);
expect(response.body.directory).toBe('/repo/app');
expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a', 'ses_b']);
expect(response.body.failedIds).toEqual([]);
expect(sessionUpdateMock).toHaveBeenCalledTimes(2);
expect(sessionUpdateMock).toHaveBeenCalledWith({
sessionID: 'ses_a',
directory: '/repo/app',
time: { archived: 1700 },
});
});
it('keeps archiving after a failed session and reports it as failed', async () => {
sessionUpdateMock.mockImplementation(async ({ sessionID }) => {
if (sessionID === 'ses_b') throw new Error('session.update failed');
return { data: { id: sessionID } };
});
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions/archive')
.send({ directory: '/repo/app', ids: ['ses_a', 'ses_b', 'ses_c'] })
.expect(200);
expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a', 'ses_c']);
expect(response.body.failedIds).toEqual(['ses_b']);
});
it('reports a session the server did not confirm as failed instead of archived', async () => {
sessionUpdateMock.mockImplementation(async ({ sessionID }) => (
sessionID === 'ses_b' ? { data: null } : { data: { id: sessionID } }
));
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions/archive')
.send({ directory: '/repo/app', ids: ['ses_a', 'ses_b'] })
.expect(200);
expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a']);
expect(response.body.failedIds).toEqual(['ses_b']);
});
it('rejects an empty batch, an oversized batch, and non-string ids', async () => {
const { app } = createApp();
await request(app).post('/api/openchamber/sessions/archive').send({ directory: '/repo/app', ids: [] }).expect(400);
await request(app)
.post('/api/openchamber/sessions/archive')
.send({ directory: '/repo/app', ids: Array.from({ length: 501 }, (_, index) => `ses_${index}`) })
.expect(400);
await request(app)
.post('/api/openchamber/sessions/archive')
.send({ directory: '/repo/app', ids: ['ses_a', ''] })
.expect(400);
await request(app)
.post('/api/openchamber/sessions/archive')
.send({ directory: '/repo/app', ids: ['ses_a'], archivedAt: -1 })
.expect(400);
expect(sessionUpdateMock).not.toHaveBeenCalled();
});
it('rejects a directory the runtime does not accept', async () => {
const { app } = createApp({
validateDirectoryPath: async () => ({ ok: false, error: 'Invalid directory' }),
});
await request(app)
.post('/api/openchamber/sessions/archive')
.send({ directory: '/elsewhere', ids: ['ses_a'] })
.expect(400);
expect(sessionUpdateMock).not.toHaveBeenCalled();
});
});
it('creates a session for a directory', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));
try {
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', title: 'Side task' })
.expect(200);
expect(response.body.sessionId).toBeTruthy();
expect(response.body.sessionId).toBe('ses_123');
expect(response.body.directory).toBe('/repo/app');
expect(response.body.promptDispatched).toBe(false);
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://opencode.test/session?directory=%2Frepo%2Fapp',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ directory: '/repo/app', title: 'Side task' }),
}),
);
expect(sessionCreateMock).not.toHaveBeenCalled();
} finally {
globalThis.fetch = originalFetch;
}
});
it('percent-encodes the directory header for non-ASCII checkout paths', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));
try {
const { app } = createApp();
await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/home/user/Masaüstü/projeler', title: 'Side task' })
.expect(200);
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
'x-opencode-directory': encodeURIComponent('/home/user/Masaüstü/projeler'),
}),
}),
);
} finally {
globalThis.fetch = originalFetch;
}
});
it('parses JSON body without global middleware', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));
try {
const { app } = createApp({}, { globalJson: false });
const response = await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app' })
.expect(200);
expect(response.body.sessionId).toBe('ses_123');
expect(response.body.directory).toBe('/repo/app');
} finally {
globalThis.fetch = originalFetch;
}
});
it('emits a session-created event after creating a session', async () => {
const originalFetch = globalThis.fetch;
const emitSessionCreatedEvent = vi.fn();
globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));
try {
const { app } = createApp({ emitSessionCreatedEvent });
await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', title: 'Side task' })
.expect(200);
expect(emitSessionCreatedEvent).toHaveBeenCalledWith(expect.objectContaining({
sessionID: 'ses_123',
directory: '/repo/app',
title: 'Side task',
promptDispatched: false,
dispatchedAsCommand: false,
}));
} finally {
globalThis.fetch = originalFetch;
}
});
it('resolves default model and agent when prompt omits them', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => {
const text = String(url);
if (text.includes('/prompt_async')) {
return { ok: true, text: async () => '' };
}
if (text.includes('/config/providers')) {
return { ok: true, json: async () => ({ providers: [{ id: 'openai', models: { 'gpt-5.5': { id: 'gpt-5.5' } } }] }) };
}
if (text.includes('/agent')) {
return { ok: true, json: async () => [{ name: 'build', mode: 'primary' }] };
}
if (text.includes('/config')) {
return { ok: true, json: async () => ({}) };
}
return { ok: true, json: async () => ({ id: 'ses_123' }) };
});
globalThis.fetch = fetchMock;
const { app } = createApp({
readSettingsFromDiskMigrated: async () => ({
defaultModel: 'openai/gpt-5.5',
defaultAgent: 'build',
projects: [{ id: 'proj_1', path: '/repo/app' }],
}),
});
try {
const response = await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', prompt: 'Run this' })
.expect(200);
expect(response.body.model).toEqual({ providerID: 'openai', modelID: 'gpt-5.5' });
expect(response.body.agent).toBe('build');
expect(fetchMock).toHaveBeenCalledWith(
'http://opencode.test/config/providers?directory=%2Frepo%2Fapp',
expect.any(Object),
);
const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
expect(JSON.parse(promptCall?.[1]?.body)).toMatchObject({
model: { providerID: 'openai', modelID: 'gpt-5.5' },
agent: 'build',
});
} finally {
globalThis.fetch = originalFetch;
}
});
it('dispatches an initial prompt when model is provided', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => {
if (String(url).includes('/prompt_async')) {
return { ok: true, text: async () => '' };
}
return { ok: true, json: async () => ({ id: 'ses_123' }) };
});
globalThis.fetch = fetchMock;
try {
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5' })
.expect(200);
expect(response.body.sessionId).toBe('ses_123');
expect(response.body.promptDispatched).toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
'http://opencode.test/session/ses_123/prompt_async?directory=%2Frepo%2Fapp',
expect.objectContaining({ method: 'POST' }),
);
} finally {
globalThis.fetch = originalFetch;
}
});
it('creates goal metadata before dispatching the initial goal prompt', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => {
if (String(url).includes('/prompt_async')) return { ok: true, text: async () => '' };
return { ok: true, json: async () => ({ id: 'ses_123' }) };
});
const createSessionGoal = vi.fn(async () => undefined);
globalThis.fetch = fetchMock;
try {
const { app } = createApp({ createSessionGoal });
const response = await request(app)
.post('/api/openchamber/sessions')
.send({
directory: '/repo/app',
prompt: 'Finish and verify the migration',
model: 'openai/gpt-5.5',
goal: true,
goalTokenBudget: 200000,
})
.expect(200);
const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
const promptPayload = JSON.parse(promptCall[1].body);
expect(createSessionGoal).toHaveBeenCalledWith(expect.objectContaining({
sessionID: 'ses_123',
directory: '/repo/app',
objective: 'Finish and verify the migration',
tokenBudget: 200000,
providerID: 'openai',
modelID: 'gpt-5.5',
}));
expect(createSessionGoal.mock.invocationCallOrder[0]).toBeLessThan(fetchMock.mock.invocationCallOrder.at(-1));
expect(promptPayload.parts).toEqual([
{ type: 'text', text: 'Finish and verify the migration' },
expect.objectContaining({ type: 'text', synthetic: true }),
]);
expect(response.body).toMatchObject({ goalEnabled: true, goalTokenBudget: 200000, promptDispatched: true });
} finally {
globalThis.fetch = originalFetch;
}
});
it('rejects invalid goal requests before creating a session', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn();
globalThis.fetch = fetchMock;
try {
const { app } = createApp();
await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', goal: true })
.expect(400, { error: 'prompt is required when goal is enabled' });
await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', prompt: 'Run', goalTokenBudget: 200000 })
.expect(400, { error: 'goalTokenBudget requires goal' });
await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', prompt: 'Run', goal: true, goalTokenBudget: 999 })
.expect(400, { error: 'goalTokenBudget must be an integer from 1000 to 100000000' });
expect(fetchMock).not.toHaveBeenCalled();
} finally {
globalThis.fetch = originalFetch;
}
});
it('creates a worktree before creating a session', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async (url) => {
if (String(url).includes('/prompt_async')) {
return { ok: true, text: async () => '' };
}
return { ok: true, json: async () => ({ id: 'ses_123' }) };
});
try {
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions')
.send({
directory: '/repo/app',
worktree: { name: 'side-task', branchName: 'openchamber/side-task', startRef: 'main' },
setUpstream: false,
prompt: 'Run this',
model: 'openai/gpt-5.5',
})
.expect(200);
expect(createWorktreeMock).toHaveBeenCalledWith('/repo/app', {
mode: 'new',
name: 'side-task',
branchName: 'openchamber/side-task',
startRef: 'main',
setUpstream: false,
});
expect(response.body.directory).toBe('/repo/worktrees/side-task');
expect(response.body.worktree.path).toBe('/repo/worktrees/side-task');
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://opencode.test/session/ses_123/prompt_async?directory=%2Frepo%2Fworktrees%2Fside-task',
expect.objectContaining({ method: 'POST' }),
);
} finally {
globalThis.fetch = originalFetch;
}
});
it('waits for the worktree bootstrap to complete before creating the session', async () => {
const statuses = [
{ status: 'pending', phase: 'directory-created', error: null, updatedAt: 1 },
{ status: 'pending', phase: 'git-ready', error: null, updatedAt: 2 },
{ status: 'ready', phase: 'setup-ready', error: null, updatedAt: 3 },
];
getWorktreeBootstrapStatusMock.mockImplementation(async () => statuses.shift() || statuses[statuses.length - 1]);
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async (url) => {
if (String(url).includes('/prompt_async')) {
return { ok: true, text: async () => '' };
}
return { ok: true, json: async () => ({ id: 'ses_123' }) };
});
try {
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions')
.send({
directory: '/repo/app',
worktree: { name: 'side-task' },
prompt: 'Run this',
model: 'openai/gpt-5.5',
})
.expect(200);
expect(response.body.promptDispatched).toBe(true);
const sessionCreateCalls = globalThis.fetch.mock.calls.filter(([url]) => String(url).includes('/session?directory'));
const promptCalls = globalThis.fetch.mock.calls.filter(([url]) => String(url).includes('/prompt_async'));
expect(sessionCreateCalls.length).toBeGreaterThanOrEqual(1);
expect(promptCalls.length).toBeGreaterThanOrEqual(1);
const createIndex = globalThis.fetch.mock.calls.indexOf(sessionCreateCalls[0]);
const promptIndex = globalThis.fetch.mock.calls.indexOf(promptCalls[0]);
expect(getWorktreeBootstrapStatusMock).toHaveBeenCalled();
expect(createIndex).toBeGreaterThan(-1);
expect(promptIndex).toBeGreaterThan(createIndex);
} finally {
globalThis.fetch = originalFetch;
}
});
it('fails the create when the worktree bootstrap failed', async () => {
getWorktreeBootstrapStatusMock.mockImplementation(async () => ({
status: 'failed',
phase: 'directory-created',
error: 'branch already exists',
updatedAt: Date.now(),
}));
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async (url) => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));
try {
const { app } = createApp();
await request(app)
.post('/api/openchamber/sessions')
.send({
directory: '/repo/app',
worktree: { name: 'side-task' },
prompt: 'Run this',
model: 'openai/gpt-5.5',
})
.expect(500, { error: 'Worktree bootstrap failed: branch already exists' });
const promptCalls = globalThis.fetch.mock.calls.filter(([url]) => String(url).includes('/prompt_async'));
expect(promptCalls.length).toBe(0);
} finally {
globalThis.fetch = originalFetch;
}
});
it('sends a goal prompt to an existing session after creating goal metadata', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' });
const createSessionGoal = vi.fn(async () => undefined);
globalThis.fetch = fetchMock;
try {
setSessionMessages([{ info: { id: 'msg_before', role: 'assistant', time: { created: 10, completed: 20 } } }]);
const { app } = createApp({ createSessionGoal });
const response = await request(app)
.post('/api/openchamber/sessions/ses_source/send')
.send({
directory: '/repo/app',
prompt: 'Apply and verify the review feedback',
model: 'openai/gpt-5.5',
agent: 'build',
variant: 'high',
goal: true,
goalTokenBudget: 200000,
})
.expect(200);
expect(response.body).toMatchObject({
action: 'send',
sessionId: 'ses_source',
directory: '/repo/app',
promptDispatched: true,
goalEnabled: true,
baselineAssistantMessageId: 'msg_before',
});
expect(createSessionGoal).toHaveBeenCalledWith(expect.objectContaining({
sessionID: 'ses_source',
directory: '/repo/app',
objective: 'Apply and verify the review feedback',
}));
const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
expect(promptCall?.[0]).toBe('http://opencode.test/session/ses_source/prompt_async?directory=%2Frepo%2Fapp');
expect(createSessionGoal.mock.invocationCallOrder[0]).toBeLessThan(fetchMock.mock.invocationCallOrder.at(-1));
} finally {
globalThis.fetch = originalFetch;
}
});
it('uses the expanded slash-command template as the goal objective before command dispatch', async () => {
const originalFetch = globalThis.fetch;
const createSessionGoal = vi.fn(async () => undefined);
commandListMock.mockResolvedValue({
data: [{
name: 'issue--to-pr',
template: 'Take $ARGUMENTS from issue through a verified pull request. Confirm the PR covers $ARGUMENTS.',
}],
});
globalThis.fetch = vi.fn(async (url) => selectionInputResponse(url));
try {
const { app } = createApp({ createSessionGoal });
const response = await request(app)
.post('/api/openchamber/sessions/ses_source/send')
.send({
directory: '/repo/app',
prompt: '/issue--to-pr LIN-123',
model: 'openai/gpt-5.5',
agent: 'build',
goal: true,
})
.expect(200);
expect(createSessionGoal).toHaveBeenCalledWith(expect.objectContaining({
objective: 'Take LIN-123 from issue through a verified pull request. Confirm the PR covers LIN-123.',
}));
expect(sessionCommandMock).toHaveBeenCalledWith(expect.objectContaining({
command: 'issue--to-pr',
arguments: 'LIN-123',
}));
expect(createSessionGoal.mock.invocationCallOrder[0]).toBeLessThan(sessionCommandMock.mock.invocationCallOrder[0]);
expect(response.body).toMatchObject({ goalEnabled: true, dispatchedAsCommand: true });
expect(globalThis.fetch.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
});
it('reuses the previous session selection when send omits model, agent, and variant', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' });
globalThis.fetch = fetchMock;
try {
setSessionMessages([
{
info: {
id: 'msg_user',
role: 'user',
agent: 'plan',
model: { providerID: 'anthropic', modelID: 'claude-sonnet-5', variant: 'high' },
time: { created: 5 },
},
},
{ info: { id: 'msg_before', role: 'assistant', time: { created: 10, completed: 20 } } },
]);
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions/ses_source/send')
.send({ directory: '/repo/app', prompt: 'Continue where you left off' })
.expect(200);
expect(response.body).toMatchObject({
action: 'send',
sessionId: 'ses_source',
model: { providerID: 'anthropic', modelID: 'claude-sonnet-5' },
agent: 'plan',
variant: 'high',
promptDispatched: true,
});
const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
const promptBody = JSON.parse(promptCall[1].body);
expect(promptBody).toMatchObject({
model: { providerID: 'anthropic', modelID: 'claude-sonnet-5' },
agent: 'plan',
variant: 'high',
});
// The default-selection inputs (config/providers/agents) must not be consulted.
expect(fetchMock.mock.calls.every(([url]) => String(url).includes('/prompt_async'))).toBe(true);
} finally {
globalThis.fetch = originalFetch;
}
});
it('forks from a message, dispatches the prompt, and emits the new session', async () => {
const originalFetch = globalThis.fetch;
const emitSessionCreatedEvent = vi.fn();
globalThis.fetch = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' });
try {
const { app } = createApp({ emitSessionCreatedEvent });
const response = await request(app)
.post('/api/openchamber/sessions/ses_source/fork')
.send({
directory: '/repo/app',
messageId: 'msg_branch_point',
prompt: 'Try the alternative implementation',
model: 'openai/gpt-5.5',
agent: 'build',
variant: 'high',
})
.expect(200);
expect(sessionForkMock).toHaveBeenCalledWith({
sessionID: 'ses_source',
directory: '/repo/app',
messageID: 'msg_branch_point',
});
expect(response.body).toMatchObject({
action: 'fork',
sourceSessionId: 'ses_source',
sessionId: 'ses_fork',
directory: '/repo/app',
promptDispatched: true,
});
expect(sessionMessagesMock).toHaveBeenCalledWith({
sessionID: 'ses_fork',
directory: '/repo/app',
limit: 100,
});
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://opencode.test/session/ses_fork/prompt_async?directory=%2Frepo%2Fapp',
expect.objectContaining({ method: 'POST' }),
);
expect(emitSessionCreatedEvent).toHaveBeenCalledWith(expect.objectContaining({
sessionID: 'ses_fork',
sourceSessionID: 'ses_source',
directory: '/repo/app',
promptDispatched: true,
}));
} finally {
globalThis.fetch = originalFetch;
}
});
it('rejects send and fork requests without a prompt before calling OpenCode', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn();
globalThis.fetch = fetchMock;
try {
const { app } = createApp();
await request(app)
.post('/api/openchamber/sessions/ses_source/send')
.send({ directory: '/repo/app' })
.expect(400, { error: 'prompt is required' });
await request(app)
.post('/api/openchamber/sessions/ses_source/fork')
.send({ directory: '/repo/app' })
.expect(400, { error: 'prompt is required' });
expect(fetchMock).not.toHaveBeenCalled();
expect(sessionForkMock).not.toHaveBeenCalled();
} finally {
globalThis.fetch = originalFetch;
}
});
it('reports the forked session when prompt dispatch fails', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async (url) => selectionInputResponse(url) || { ok: false, status: 500, text: async () => 'dispatch failed' });
try {
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions/ses_source/fork')
.send({
directory: '/repo/app',
prompt: 'Try another approach',
model: 'openai/gpt-5.5',
agent: 'build',
variant: 'high',
})
.expect(500);
expect(response.body).toMatchObject({
partial: true,
partialAction: 'fork-created',
sessionId: 'ses_fork',
directory: '/repo/app',
});
} finally {
globalThis.fetch = originalFetch;
}
});
it('does not apply a default variant to an explicitly requested model', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => {
const text = String(url);
if (text.includes('/prompt_async')) return { ok: true, text: async () => '' };
if (text.includes('/config/providers')) {
return {
ok: true,
json: async () => ({
providers: [
{ id: 'openai', models: { requested: { id: 'requested' }, default: { id: 'default', variants: { high: {} } } } },
],
}),
};
}
if (text.includes('/agent')) return { ok: true, json: async () => [{ name: 'build', mode: 'primary' }] };
if (text.includes('/config')) return { ok: true, json: async () => ({}) };
return { ok: true, json: async () => ({ id: 'ses_123' }) };
});
globalThis.fetch = fetchMock;
try {
const { app } = createApp({
readSettingsFromDiskMigrated: async () => ({
defaultModel: 'openai/default',
defaultVariant: 'high',
projects: [{ id: 'proj_1', path: '/repo/app' }],
}),
});
await request(app)
.post('/api/openchamber/sessions/ses_source/send')
.send({ directory: '/repo/app', prompt: 'Continue', model: 'openai/requested', agent: 'build' })
.expect(200);
const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
expect(JSON.parse(promptCall[1].body)).not.toHaveProperty('variant');
} finally {
globalThis.fetch = originalFetch;
}
});
it('rejects an unknown agent before creating a session or worktree', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, json: async () => ({ id: 'ses_123' }) });
globalThis.fetch = fetchMock;
try {
const { app } = createApp();
await request(app)
.post('/api/openchamber/sessions')
.send({
directory: '/repo/app',
prompt: 'Run this',
agent: 'not-an-agent',
worktree: { name: 'side-task' },
})
.expect(400, { error: "Unknown agent 'not-an-agent' for /repo/app" });
expect(createWorktreeMock).not.toHaveBeenCalled();
expect(fetchMock.mock.calls.some(([url]) => String(url) === 'http://opencode.test/session?directory=%2Frepo%2Fapp')).toBe(false);
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
});
it('rejects an unknown model and an unknown variant before dispatching', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, json: async () => ({ id: 'ses_123' }) });
globalThis.fetch = fetchMock;
try {
const { app } = createApp();
await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-nope' })
.expect(400, { error: "Unknown model 'openai/gpt-nope' for /repo/app" });
await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5', variant: 'ultra' })
.expect(400, { error: "Unknown variant 'ultra' for model 'openai/gpt-5.5'" });
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
});
it('reports promptDispatched false when the accepted prompt never reaches the session', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => {
if (String(url).includes('/prompt_async')) return { ok: true, text: async () => '' };
return selectionInputResponse(url) || { ok: true, json: async () => ({ id: 'ses_123' }) };
});
globalThis.fetch = fetchMock;
sessionMessagesMock.mockResolvedValue({ data: [] });
try {
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5' })
.expect(200);
expect(response.body.sessionId).toBe('ses_123');
expect(response.body.promptDispatched).toBe(false);
expect(response.body.promptError).toBeTruthy();
} finally {
globalThis.fetch = originalFetch;
}
}, 20_000);
it('does not retry a failed slash command as a normal prompt', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => selectionInputResponse(url));
commandListMock.mockResolvedValue({ data: [{ name: 'review' }] });
sessionCommandMock.mockRejectedValue(new Error('command response failed'));
globalThis.fetch = fetchMock;
try {
const { app } = createApp();
await request(app)
.post('/api/openchamber/sessions/ses_source/send')
.send({
directory: '/repo/app',
prompt: '/review fix this',
model: 'openai/gpt-5.5',
agent: 'build',
variant: 'high',
})
.expect(500);
expect(sessionCommandMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
} finally {
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;
}
});
});