`prompt_async` answers 204 as soon as OpenCode forks the run and reports every later failure only on the session event stream, so an unusable model, agent, or variant produced a session with no message while the result still claimed `promptDispatched: true`. Validate an explicitly requested model, agent, and variant against the directory's own agent and provider lists before any session, worktree, or goal is created, and confirm a new user message actually reached the session before reporting the dispatch. A failed or empty lookup never turns a valid selection into a rejection.
726 lines
27 KiB
JavaScript
726 lines
27 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 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: [] }));
|
|
|
|
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;
|
|
|
|
let registerOpenChamberSessionRoutes;
|
|
|
|
vi.mock('@opencode-ai/sdk/v2', () => ({
|
|
createOpencodeClient: () => ({
|
|
session: {
|
|
create: sessionCreateMock,
|
|
fork: sessionForkMock,
|
|
messages: sessionMessagesMock,
|
|
command: sessionCommandMock,
|
|
},
|
|
command: {
|
|
list: commandListMock,
|
|
},
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../git/index.js', () => ({
|
|
createWorktree: (...args) => globalThis.__openchamberCreateWorktreeMock(...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();
|
|
sessionCreateMock.mockClear();
|
|
sessionForkMock.mockClear();
|
|
existingSessionMessages = [];
|
|
dispatchedUserMessageSeq = 0;
|
|
sessionMessagesMock.mockReset();
|
|
sessionMessagesMock.mockImplementation(recordedSessionMessages);
|
|
sessionCommandMock.mockReset();
|
|
sessionCommandMock.mockResolvedValue({ data: {} });
|
|
commandListMock.mockReset();
|
|
commandListMock.mockResolvedValue({ data: [] });
|
|
});
|
|
|
|
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('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('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;
|
|
}
|
|
});
|
|
});
|