2026-07-14 10:31:15 +03:00
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
|
|
|
|
|
|
import { createSessionGoalRuntime } from './runtime.js';
|
|
|
|
|
|
|
|
|
|
const SESSION_ID = 'ses_parent';
|
|
|
|
|
const CHILD_ID = 'ses_child';
|
|
|
|
|
const DIRECTORY = '/workspace';
|
|
|
|
|
|
|
|
|
|
const goal = {
|
|
|
|
|
id: 'goal_1',
|
|
|
|
|
objective: 'Finish the task',
|
|
|
|
|
status: 'active',
|
|
|
|
|
turnsUsed: 1,
|
|
|
|
|
createdAt: 1,
|
|
|
|
|
updatedAt: 1,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const session = {
|
|
|
|
|
id: SESSION_ID,
|
|
|
|
|
directory: DIRECTORY,
|
|
|
|
|
metadata: { openchamber: { goal } },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const jsonResponse = (body, status = 200) => new Response(JSON.stringify(body), {
|
|
|
|
|
status,
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const requestPath = (input) => new URL(typeof input === 'string' ? input : input.url).pathname;
|
|
|
|
|
|
|
|
|
|
const startIdleTick = async (fetchImpl) => {
|
|
|
|
|
const getSmallModelService = vi.fn();
|
|
|
|
|
vi.stubGlobal('fetch', fetchImpl);
|
|
|
|
|
const runtime = createSessionGoalRuntime({
|
|
|
|
|
buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
|
|
|
|
|
getOpenCodeAuthHeaders: () => ({}),
|
|
|
|
|
getSmallModelService,
|
2026-08-26 11:15:49 -03:00
|
|
|
isEnabled: () => true,
|
2026-07-14 10:31:15 +03:00
|
|
|
idleQuietMs: 10,
|
|
|
|
|
});
|
|
|
|
|
runtime.processPayload({
|
|
|
|
|
type: 'session.status',
|
|
|
|
|
properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
|
|
|
|
|
});
|
2026-08-26 11:15:49 -03:00
|
|
|
await vi.runOnlyPendingTimersAsync();
|
2026-07-14 10:31:15 +03:00
|
|
|
return { runtime, getSmallModelService };
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-05 18:46:18 +11:00
|
|
|
const assistantMessage = (id, infoOverrides = {}) => ({
|
|
|
|
|
info: {
|
|
|
|
|
id,
|
|
|
|
|
sessionID: SESSION_ID,
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
providerID: 'provider',
|
|
|
|
|
modelID: 'model',
|
|
|
|
|
time: { created: 2, completed: 2 },
|
|
|
|
|
tokens: { input: 1, output: 1, cache: { read: 0 } },
|
|
|
|
|
...infoOverrides,
|
|
|
|
|
},
|
|
|
|
|
parts: [{ type: 'text', text: 'The agent made progress.' }],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const createRuntimeHarness = ({ messages, messageFactory, goalOverrides = {}, maxAutoTurns = 20 }) => {
|
|
|
|
|
const requests = [];
|
|
|
|
|
let messageFetchCount = 0;
|
|
|
|
|
const activeSession = {
|
|
|
|
|
...session,
|
|
|
|
|
metadata: { openchamber: { goal: { ...goal, ...goalOverrides } } },
|
|
|
|
|
};
|
|
|
|
|
const service = {
|
|
|
|
|
generateSmallModelText: vi.fn(async () => ({
|
|
|
|
|
text: '{"verdict":"continue","note":"More work remains"}',
|
|
|
|
|
providerID: 'provider',
|
|
|
|
|
modelID: 'model',
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
const fetchImpl = vi.fn(async (input, init = {}) => {
|
|
|
|
|
const pathname = requestPath(input);
|
|
|
|
|
requests.push({ pathname, method: init.method ?? 'GET', body: init.body });
|
2026-09-05 10:53:34 +03:00
|
|
|
if (pathname === `/session/${SESSION_ID}` && init.method === 'PATCH') {
|
|
|
|
|
activeSession.metadata = JSON.parse(init.body).metadata;
|
|
|
|
|
return jsonResponse(activeSession);
|
|
|
|
|
}
|
2026-09-05 18:46:18 +11:00
|
|
|
if (pathname === `/session/${SESSION_ID}`) return jsonResponse(activeSession);
|
|
|
|
|
if (pathname === '/session/status') return jsonResponse({});
|
|
|
|
|
if (pathname === `/session/${SESSION_ID}/children`) return jsonResponse([]);
|
|
|
|
|
if (pathname === `/session/${SESSION_ID}/message`) {
|
|
|
|
|
const nextMessages = messageFactory ? messageFactory(messageFetchCount) : messages;
|
|
|
|
|
messageFetchCount += 1;
|
|
|
|
|
return jsonResponse(nextMessages);
|
|
|
|
|
}
|
|
|
|
|
if (pathname === `/session/${SESSION_ID}/prompt_async`) return jsonResponse({ ok: true });
|
|
|
|
|
throw new Error(`Unexpected request: ${pathname}`);
|
|
|
|
|
});
|
|
|
|
|
vi.stubGlobal('fetch', fetchImpl);
|
|
|
|
|
const runtime = createSessionGoalRuntime({
|
|
|
|
|
buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
|
|
|
|
|
getOpenCodeAuthHeaders: () => ({}),
|
|
|
|
|
getSmallModelService: async () => service,
|
|
|
|
|
isEnabled: () => true,
|
|
|
|
|
idleQuietMs: 10,
|
|
|
|
|
maxAutoTurns,
|
|
|
|
|
});
|
2026-09-05 10:53:34 +03:00
|
|
|
return { runtime, requests, service, activeSession };
|
2026-09-05 18:46:18 +11:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const runIdleTick = async (runtime) => {
|
|
|
|
|
runtime.processPayload({
|
|
|
|
|
type: 'session.status',
|
|
|
|
|
properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
|
|
|
|
|
});
|
|
|
|
|
await vi.runOnlyPendingTimersAsync();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const lastPatchedGoal = (requests) => {
|
|
|
|
|
const patches = requests.filter((request) => request.pathname === `/session/${SESSION_ID}` && request.method === 'PATCH');
|
|
|
|
|
expect(patches.length).toBeGreaterThan(0);
|
|
|
|
|
return JSON.parse(patches.at(-1).body).metadata.openchamber.goal;
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-14 10:31:15 +03:00
|
|
|
describe('session goal live activity gate', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.useFakeTimers();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
vi.unstubAllGlobals();
|
|
|
|
|
vi.useRealTimers();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('waits for the next parent idle when the parent resumed during the quiet window', async () => {
|
|
|
|
|
const paths = [];
|
|
|
|
|
const { runtime, getSmallModelService } = await startIdleTick(vi.fn(async (input) => {
|
|
|
|
|
const pathname = requestPath(input);
|
|
|
|
|
paths.push(pathname);
|
|
|
|
|
if (pathname === `/session/${SESSION_ID}`) return jsonResponse(session);
|
|
|
|
|
if (pathname === '/session/status') return jsonResponse({ [SESSION_ID]: { type: 'busy' } });
|
|
|
|
|
throw new Error(`Unexpected request: ${pathname}`);
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
expect(paths).toEqual([`/session/${SESSION_ID}`, '/session/status']);
|
|
|
|
|
expect(getSmallModelService).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
|
|
|
expect(paths).toHaveLength(2);
|
|
|
|
|
runtime.stop();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('waits for the parent result cycle while a direct child is working', async () => {
|
|
|
|
|
const paths = [];
|
|
|
|
|
const { runtime, getSmallModelService } = await startIdleTick(vi.fn(async (input) => {
|
|
|
|
|
const pathname = requestPath(input);
|
|
|
|
|
paths.push(pathname);
|
|
|
|
|
if (pathname === `/session/${SESSION_ID}`) return jsonResponse(session);
|
|
|
|
|
if (pathname === '/session/status') return jsonResponse({ [CHILD_ID]: { type: 'busy' } });
|
|
|
|
|
if (pathname === `/session/${SESSION_ID}/children`) return jsonResponse([{ id: CHILD_ID, parentID: SESSION_ID }]);
|
|
|
|
|
throw new Error(`Unexpected request: ${pathname}`);
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
expect(paths).toEqual([
|
|
|
|
|
`/session/${SESSION_ID}`,
|
|
|
|
|
'/session/status',
|
|
|
|
|
`/session/${SESSION_ID}/children`,
|
|
|
|
|
]);
|
|
|
|
|
expect(getSmallModelService).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
|
|
|
expect(paths).toHaveLength(3);
|
|
|
|
|
runtime.stop();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('retries the quiet window when live status cannot be read', async () => {
|
|
|
|
|
const paths = [];
|
|
|
|
|
const { runtime, getSmallModelService } = await startIdleTick(vi.fn(async (input) => {
|
|
|
|
|
const pathname = requestPath(input);
|
|
|
|
|
paths.push(pathname);
|
|
|
|
|
if (pathname === `/session/${SESSION_ID}`) return jsonResponse(session);
|
|
|
|
|
if (pathname === '/session/status') return jsonResponse({ error: 'unavailable' }, 503);
|
|
|
|
|
throw new Error(`Unexpected request: ${pathname}`);
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
expect(paths).toEqual([`/session/${SESSION_ID}`, '/session/status']);
|
|
|
|
|
expect(getSmallModelService).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
|
expect(paths).toEqual([
|
|
|
|
|
`/session/${SESSION_ID}`,
|
|
|
|
|
'/session/status',
|
|
|
|
|
`/session/${SESSION_ID}`,
|
|
|
|
|
'/session/status',
|
|
|
|
|
]);
|
|
|
|
|
runtime.stop();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('audits normally when the idle parent has no working children', async () => {
|
|
|
|
|
const requests = [];
|
|
|
|
|
const fetchImpl = vi.fn(async (input, init = {}) => {
|
|
|
|
|
const pathname = requestPath(input);
|
2026-07-15 08:56:10 +03:00
|
|
|
requests.push({ pathname, method: init.method ?? 'GET', body: init.body });
|
2026-07-14 10:31:15 +03:00
|
|
|
if (pathname === `/session/${SESSION_ID}` && init.method === 'PATCH') return jsonResponse(session);
|
|
|
|
|
if (pathname === `/session/${SESSION_ID}`) return jsonResponse(session);
|
|
|
|
|
if (pathname === '/session/status') return jsonResponse({});
|
|
|
|
|
if (pathname === `/session/${SESSION_ID}/children`) return jsonResponse([]);
|
|
|
|
|
if (pathname === `/session/${SESSION_ID}/message`) {
|
|
|
|
|
return jsonResponse([{
|
|
|
|
|
info: {
|
|
|
|
|
id: 'msg_assistant',
|
|
|
|
|
sessionID: SESSION_ID,
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
providerID: 'provider',
|
|
|
|
|
modelID: 'model',
|
|
|
|
|
time: { completed: 2 },
|
|
|
|
|
tokens: { input: 1, output: 1, cache: { read: 0 } },
|
|
|
|
|
},
|
|
|
|
|
parts: [{ type: 'text', text: 'The task is verified complete.' }],
|
|
|
|
|
}]);
|
|
|
|
|
}
|
|
|
|
|
throw new Error(`Unexpected request: ${pathname}`);
|
|
|
|
|
});
|
|
|
|
|
const service = {
|
|
|
|
|
generateSmallModelText: vi.fn(async () => ({
|
|
|
|
|
text: '{"verdict":"complete","note":"Task verified complete"}',
|
2026-07-15 08:56:10 +03:00
|
|
|
providerID: 'provider',
|
|
|
|
|
modelID: 'model',
|
2026-07-14 10:31:15 +03:00
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
vi.stubGlobal('fetch', fetchImpl);
|
|
|
|
|
const runtime = createSessionGoalRuntime({
|
|
|
|
|
buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
|
|
|
|
|
getOpenCodeAuthHeaders: () => ({}),
|
|
|
|
|
getSmallModelService: async () => service,
|
2026-08-26 11:15:49 -03:00
|
|
|
isEnabled: () => true,
|
2026-07-14 10:31:15 +03:00
|
|
|
idleQuietMs: 10,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
runtime.processPayload({
|
|
|
|
|
type: 'session.status',
|
|
|
|
|
properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
|
|
|
|
|
});
|
2026-08-26 11:15:49 -03:00
|
|
|
await vi.runOnlyPendingTimersAsync();
|
2026-07-14 10:31:15 +03:00
|
|
|
|
|
|
|
|
expect(service.generateSmallModelText).toHaveBeenCalledOnce();
|
2026-07-15 08:56:10 +03:00
|
|
|
const patch = requests.find((request) => request.pathname === `/session/${SESSION_ID}` && request.method === 'PATCH');
|
|
|
|
|
expect(patch).toBeDefined();
|
|
|
|
|
const writtenGoal = JSON.parse(patch.body).metadata.openchamber.goal;
|
|
|
|
|
expect(writtenGoal).toMatchObject({
|
|
|
|
|
status: 'complete',
|
|
|
|
|
evaluationProviderID: 'provider',
|
|
|
|
|
evaluationModelID: 'model',
|
|
|
|
|
});
|
2026-07-14 10:31:15 +03:00
|
|
|
runtime.stop();
|
|
|
|
|
});
|
2026-09-05 18:46:18 +11:00
|
|
|
|
|
|
|
|
it('skips audit and sends continuation prompt when assistant message finishes with length stop', async () => {
|
|
|
|
|
const requests = [];
|
|
|
|
|
const fetchImpl = vi.fn(async (input, init = {}) => {
|
|
|
|
|
const pathname = requestPath(input);
|
|
|
|
|
requests.push({ pathname, method: init.method ?? 'GET', body: init.body });
|
|
|
|
|
if (pathname === `/session/${SESSION_ID}` && init.method === 'PATCH') return jsonResponse(session);
|
|
|
|
|
if (pathname === `/session/${SESSION_ID}`) return jsonResponse(session);
|
|
|
|
|
if (pathname === '/session/status') return jsonResponse({});
|
|
|
|
|
if (pathname === `/session/${SESSION_ID}/children`) return jsonResponse([]);
|
|
|
|
|
if (pathname === `/session/${SESSION_ID}/prompt_async`) return jsonResponse({ ok: true });
|
|
|
|
|
if (pathname === `/session/${SESSION_ID}/message`) {
|
|
|
|
|
return jsonResponse([{
|
|
|
|
|
info: {
|
|
|
|
|
id: 'msg_assistant_len',
|
|
|
|
|
sessionID: SESSION_ID,
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
providerID: 'provider',
|
|
|
|
|
modelID: 'model',
|
|
|
|
|
finish: 'length',
|
|
|
|
|
time: { completed: 2 },
|
|
|
|
|
tokens: { input: 100, output: 4096, reasoning: 4096, cache: { read: 0 } },
|
|
|
|
|
},
|
|
|
|
|
parts: [{ type: 'reasoning', text: 'Drafting extensive implementation...' }],
|
|
|
|
|
}]);
|
|
|
|
|
}
|
|
|
|
|
throw new Error(`Unexpected request: ${pathname}`);
|
|
|
|
|
});
|
|
|
|
|
const service = {
|
|
|
|
|
generateSmallModelText: vi.fn(),
|
|
|
|
|
};
|
|
|
|
|
vi.stubGlobal('fetch', fetchImpl);
|
|
|
|
|
const runtime = createSessionGoalRuntime({
|
|
|
|
|
buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
|
|
|
|
|
getOpenCodeAuthHeaders: () => ({}),
|
|
|
|
|
getSmallModelService: async () => service,
|
|
|
|
|
isEnabled: () => true,
|
|
|
|
|
idleQuietMs: 10,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
runtime.processPayload({
|
|
|
|
|
type: 'session.status',
|
|
|
|
|
properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
|
|
|
|
|
});
|
|
|
|
|
await vi.runOnlyPendingTimersAsync();
|
|
|
|
|
|
|
|
|
|
// Audit must be skipped on length-truncated turn
|
|
|
|
|
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
// Goal accounting is persisted and turnsUsed incremented
|
|
|
|
|
const patch = requests.find((request) => request.pathname === `/session/${SESSION_ID}` && request.method === 'PATCH');
|
|
|
|
|
expect(patch).toBeDefined();
|
|
|
|
|
const writtenGoal = JSON.parse(patch.body).metadata.openchamber.goal;
|
|
|
|
|
expect(writtenGoal).toMatchObject({
|
|
|
|
|
status: 'active',
|
|
|
|
|
turnsUsed: 2,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Continuation prompt is dispatched
|
|
|
|
|
const promptAsync = requests.find((request) => request.pathname === `/session/${SESSION_ID}/prompt_async` && request.method === 'POST');
|
|
|
|
|
expect(promptAsync).toBeDefined();
|
|
|
|
|
const promptBody = JSON.parse(promptAsync.body);
|
|
|
|
|
expect(promptBody.parts[0].text).toContain('Continue working toward the active session goal.');
|
|
|
|
|
runtime.stop();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('skips audit and sends continuation prompt when assistant message carries MessageOutputLengthError', async () => {
|
|
|
|
|
const requests = [];
|
|
|
|
|
const fetchImpl = vi.fn(async (input, init = {}) => {
|
|
|
|
|
const pathname = requestPath(input);
|
|
|
|
|
requests.push({ pathname, method: init.method ?? 'GET', body: init.body });
|
|
|
|
|
if (pathname === `/session/${SESSION_ID}` && init.method === 'PATCH') return jsonResponse(session);
|
|
|
|
|
if (pathname === `/session/${SESSION_ID}`) return jsonResponse(session);
|
|
|
|
|
if (pathname === '/session/status') return jsonResponse({});
|
|
|
|
|
if (pathname === `/session/${SESSION_ID}/children`) return jsonResponse([]);
|
|
|
|
|
if (pathname === `/session/${SESSION_ID}/prompt_async`) return jsonResponse({ ok: true });
|
|
|
|
|
if (pathname === `/session/${SESSION_ID}/message`) {
|
|
|
|
|
return jsonResponse([{
|
|
|
|
|
info: {
|
|
|
|
|
id: 'msg_assistant_len_err',
|
|
|
|
|
sessionID: SESSION_ID,
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
providerID: 'provider',
|
|
|
|
|
modelID: 'model',
|
|
|
|
|
error: { name: 'MessageOutputLengthError', message: 'Maximum token limit reached' },
|
|
|
|
|
time: { completed: 2 },
|
|
|
|
|
tokens: { input: 100, output: 4096, reasoning: 4096, cache: { read: 0 } },
|
|
|
|
|
},
|
|
|
|
|
parts: [{ type: 'reasoning', text: 'Drafting extensive implementation...' }],
|
|
|
|
|
}]);
|
|
|
|
|
}
|
|
|
|
|
throw new Error(`Unexpected request: ${pathname}`);
|
|
|
|
|
});
|
|
|
|
|
const service = {
|
|
|
|
|
generateSmallModelText: vi.fn(),
|
|
|
|
|
};
|
|
|
|
|
vi.stubGlobal('fetch', fetchImpl);
|
|
|
|
|
const runtime = createSessionGoalRuntime({
|
|
|
|
|
buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
|
|
|
|
|
getOpenCodeAuthHeaders: () => ({}),
|
|
|
|
|
getSmallModelService: async () => service,
|
|
|
|
|
isEnabled: () => true,
|
|
|
|
|
idleQuietMs: 10,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
runtime.processPayload({
|
|
|
|
|
type: 'session.status',
|
|
|
|
|
properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
|
|
|
|
|
});
|
|
|
|
|
await vi.runOnlyPendingTimersAsync();
|
|
|
|
|
|
|
|
|
|
// Audit must be skipped on MessageOutputLengthError
|
|
|
|
|
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
// Goal remains active and turnsUsed incremented
|
|
|
|
|
const patch = requests.find((request) => request.pathname === `/session/${SESSION_ID}` && request.method === 'PATCH');
|
|
|
|
|
expect(patch).toBeDefined();
|
|
|
|
|
const writtenGoal = JSON.parse(patch.body).metadata.openchamber.goal;
|
|
|
|
|
expect(writtenGoal).toMatchObject({
|
|
|
|
|
status: 'active',
|
|
|
|
|
turnsUsed: 2,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Continuation prompt is dispatched
|
|
|
|
|
const promptAsync = requests.find((request) => request.pathname === `/session/${SESSION_ID}/prompt_async` && request.method === 'POST');
|
|
|
|
|
expect(promptAsync).toBeDefined();
|
|
|
|
|
runtime.stop();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
{ name: 'APIError', error: { name: 'APIError', message: 'provider failed' } },
|
|
|
|
|
{ name: 'StructuredOutputError', error: { name: 'StructuredOutputError', message: 'invalid output' } },
|
|
|
|
|
{ name: 'unnamed errors', error: {} },
|
|
|
|
|
])('blocks a length finish when the assistant also has a non-length $name', async ({ error }) => {
|
|
|
|
|
const { runtime, requests, service } = createRuntimeHarness({
|
|
|
|
|
messages: [assistantMessage('msg_assistant_error', { finish: 'length', error })],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await runIdleTick(runtime);
|
|
|
|
|
|
|
|
|
|
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
|
|
|
expect(requests.some((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toBe(false);
|
|
|
|
|
expect(lastPatchedGoal(requests)).toMatchObject({
|
|
|
|
|
status: 'blocked',
|
|
|
|
|
statusReason: error.name || 'assistant turn failed',
|
|
|
|
|
});
|
|
|
|
|
runtime.stop();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('blocks after two consecutive length-truncated agent turns without persisting a counter, regardless of message IDs', async () => {
|
|
|
|
|
const firstLength = assistantMessage('z', { finish: 'length', time: { created: 10, completed: 11 } });
|
|
|
|
|
const summary = assistantMessage('summary', { summary: true, time: { created: 15, completed: 16 } });
|
|
|
|
|
const secondLength = assistantMessage('a', { finish: 'length', time: { created: 20, completed: 21 } });
|
|
|
|
|
const { runtime, requests, service } = createRuntimeHarness({
|
|
|
|
|
messageFactory: (fetchCount) => fetchCount < 2 ? [firstLength] : [firstLength, summary, secondLength],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await runIdleTick(runtime);
|
|
|
|
|
runtime.processPayload({
|
|
|
|
|
type: 'session.status',
|
|
|
|
|
properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
|
|
|
|
|
});
|
|
|
|
|
await vi.runOnlyPendingTimersAsync();
|
|
|
|
|
|
|
|
|
|
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
|
|
|
expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(1);
|
|
|
|
|
expect(lastPatchedGoal(requests)).toMatchObject({
|
|
|
|
|
status: 'blocked',
|
|
|
|
|
statusReason: 'repeated output truncation',
|
|
|
|
|
});
|
|
|
|
|
runtime.stop();
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-05 10:53:34 +03:00
|
|
|
it('allows one explicit Resume after repeated truncation, then blocks another cutoff', async () => {
|
|
|
|
|
const first = assistantMessage('first', { finish: 'length', time: { created: 10, completed: 11 } });
|
|
|
|
|
const second = assistantMessage('second', { finish: 'length', time: { created: 20, completed: 21 } });
|
|
|
|
|
const messages = [first, second];
|
|
|
|
|
const { runtime, requests, activeSession } = createRuntimeHarness({ messages });
|
|
|
|
|
try {
|
|
|
|
|
await runIdleTick(runtime);
|
|
|
|
|
expect(lastPatchedGoal(requests).status).toBe('blocked');
|
|
|
|
|
expect(requests.filter((request) => request.pathname.endsWith('/prompt_async'))).toHaveLength(0);
|
|
|
|
|
Object.assign(activeSession.metadata.openchamber.goal, { status: 'active', statusReason: 'resumed', turnsUsed: 0 });
|
|
|
|
|
await runIdleTick(runtime);
|
|
|
|
|
expect(lastPatchedGoal(requests)).toMatchObject({ status: 'active', statusReason: '', turnsUsed: 1 });
|
|
|
|
|
expect(requests.filter((request) => request.pathname.endsWith('/prompt_async'))).toHaveLength(1);
|
|
|
|
|
messages.push(assistantMessage('third', { finish: 'length', time: { created: 30, completed: 31 } }));
|
|
|
|
|
await runIdleTick(runtime);
|
|
|
|
|
expect(lastPatchedGoal(requests)).toMatchObject({ status: 'blocked', statusReason: 'repeated output truncation' });
|
|
|
|
|
expect(requests.filter((request) => request.pathname.endsWith('/prompt_async'))).toHaveLength(1);
|
|
|
|
|
} finally {
|
|
|
|
|
runtime.stop();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
{ tokenBudget: 1, error: undefined, expectedStatus: 'budgetLimited' },
|
|
|
|
|
{ tokenBudget: null, error: { name: 'APIError' }, expectedStatus: 'blocked' },
|
|
|
|
|
])('keeps $expectedStatus protection on explicit Resume', async ({ tokenBudget, error, expectedStatus }) => {
|
|
|
|
|
const { runtime, requests } = createRuntimeHarness({
|
|
|
|
|
goalOverrides: { statusReason: 'resumed', tokenBudget },
|
|
|
|
|
messages: [assistantMessage('resumed', { finish: 'length', error })],
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
await runIdleTick(runtime);
|
|
|
|
|
expect(lastPatchedGoal(requests).status).toBe(expectedStatus);
|
|
|
|
|
expect(requests.filter((request) => request.pathname.endsWith('/prompt_async'))).toHaveLength(0);
|
|
|
|
|
} finally {
|
|
|
|
|
runtime.stop();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-05 18:46:18 +11:00
|
|
|
it('continues after a truncated agent turn followed by a length-finished summary', async () => {
|
|
|
|
|
const firstLength = assistantMessage('agent-length', { finish: 'length', time: { created: 10, completed: 11 } });
|
|
|
|
|
const summary = assistantMessage('summary', {
|
|
|
|
|
summary: true,
|
|
|
|
|
finish: 'length',
|
|
|
|
|
time: { created: 15, completed: 16 },
|
|
|
|
|
});
|
|
|
|
|
const { runtime, requests, service } = createRuntimeHarness({
|
|
|
|
|
messageFactory: (fetchCount) => fetchCount < 2 ? [firstLength] : [firstLength, summary],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await runIdleTick(runtime);
|
|
|
|
|
await runIdleTick(runtime);
|
|
|
|
|
|
|
|
|
|
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
|
|
|
expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(2);
|
|
|
|
|
expect(lastPatchedGoal(requests)).toMatchObject({ status: 'active' });
|
|
|
|
|
runtime.stop();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does not infer a repeated streak when a completed assistant timestamp is missing', async () => {
|
|
|
|
|
const firstLength = assistantMessage('z', { finish: 'length', time: { completed: 11 } });
|
|
|
|
|
const secondLength = assistantMessage('a', { finish: 'length', time: { created: 20, completed: 21 } });
|
|
|
|
|
const { runtime, requests, service } = createRuntimeHarness({
|
|
|
|
|
messageFactory: (fetchCount) => fetchCount < 2 ? [firstLength] : [firstLength, secondLength],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await runIdleTick(runtime);
|
|
|
|
|
await runIdleTick(runtime);
|
|
|
|
|
|
|
|
|
|
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
|
|
|
expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(2);
|
|
|
|
|
expect(lastPatchedGoal(requests)).toMatchObject({ status: 'active' });
|
|
|
|
|
runtime.stop();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('ignores a missing timestamp on an older assistant when known post-goal turns establish a streak', async () => {
|
|
|
|
|
const olderLength = assistantMessage('legacy', { finish: 'length', time: { completed: 5 } });
|
|
|
|
|
const firstLength = assistantMessage('z', { finish: 'length', time: { created: 10, completed: 11 } });
|
|
|
|
|
const secondLength = assistantMessage('a', { finish: 'length', time: { created: 20, completed: 21 } });
|
|
|
|
|
const { runtime, requests, service } = createRuntimeHarness({
|
|
|
|
|
goalOverrides: { createdAt: 6 },
|
|
|
|
|
messageFactory: (fetchCount) => fetchCount < 2 ? [firstLength] : [olderLength, firstLength, secondLength],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await runIdleTick(runtime);
|
|
|
|
|
await runIdleTick(runtime);
|
|
|
|
|
|
|
|
|
|
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
|
|
|
expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(1);
|
|
|
|
|
expect(lastPatchedGoal(requests)).toMatchObject({
|
|
|
|
|
status: 'blocked',
|
|
|
|
|
statusReason: 'repeated output truncation',
|
|
|
|
|
});
|
|
|
|
|
runtime.stop();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does not count a previous truncated turn created at the goal boundary', async () => {
|
|
|
|
|
const firstLength = assistantMessage('before-boundary', { finish: 'length', time: { created: 10, completed: 11 } });
|
|
|
|
|
const secondLength = assistantMessage('after-boundary', { finish: 'length', time: { created: 20, completed: 21 } });
|
|
|
|
|
const { runtime, requests, service } = createRuntimeHarness({
|
|
|
|
|
goalOverrides: { createdAt: 10 },
|
|
|
|
|
messageFactory: (fetchCount) => fetchCount < 2 ? [firstLength] : [firstLength, secondLength],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await runIdleTick(runtime);
|
|
|
|
|
await runIdleTick(runtime);
|
|
|
|
|
|
|
|
|
|
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
|
|
|
expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(2);
|
|
|
|
|
expect(lastPatchedGoal(requests)).toMatchObject({ status: 'active' });
|
|
|
|
|
runtime.stop();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('allows length recovery after an ordinary assistant turn breaks the streak', async () => {
|
|
|
|
|
const firstLength = assistantMessage('msg_length_before_normal', { finish: 'length' });
|
|
|
|
|
const normal = assistantMessage('msg_normal');
|
|
|
|
|
const secondLength = assistantMessage('msg_length_after_normal', { finish: 'length' });
|
|
|
|
|
const { runtime, requests, service } = createRuntimeHarness({
|
|
|
|
|
messageFactory: (fetchCount) => fetchCount < 2
|
|
|
|
|
? [firstLength]
|
|
|
|
|
: [firstLength, normal, secondLength],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await runIdleTick(runtime);
|
|
|
|
|
await runIdleTick(runtime);
|
|
|
|
|
|
|
|
|
|
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
|
|
|
expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(2);
|
|
|
|
|
expect(lastPatchedGoal(requests)).toMatchObject({ status: 'active' });
|
|
|
|
|
runtime.stop();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('keeps MessageAbortedError pause behavior instead of continuing', async () => {
|
|
|
|
|
const { runtime, requests, service } = createRuntimeHarness({
|
|
|
|
|
messages: [assistantMessage('msg_aborted', { error: { name: 'MessageAbortedError' } })],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await runIdleTick(runtime);
|
|
|
|
|
|
|
|
|
|
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
|
|
|
expect(requests.some((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toBe(false);
|
|
|
|
|
expect(lastPatchedGoal(requests)).toMatchObject({
|
|
|
|
|
status: 'paused',
|
|
|
|
|
statusReason: 'paused after abort',
|
|
|
|
|
});
|
|
|
|
|
runtime.stop();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('checks the token budget before allowing length recovery', async () => {
|
|
|
|
|
const { runtime, requests, service } = createRuntimeHarness({
|
|
|
|
|
goalOverrides: { tokenBudget: 5 },
|
|
|
|
|
messages: [assistantMessage('msg_budget_length', {
|
|
|
|
|
finish: 'length',
|
|
|
|
|
tokens: { input: 3, output: 3, cache: { read: 0 } },
|
|
|
|
|
})],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await runIdleTick(runtime);
|
|
|
|
|
|
|
|
|
|
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
|
|
|
expect(requests.some((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toBe(false);
|
|
|
|
|
expect(lastPatchedGoal(requests)).toMatchObject({ status: 'budgetLimited' });
|
|
|
|
|
runtime.stop();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('checks the auto-continuation cap before allowing length recovery', async () => {
|
|
|
|
|
const { runtime, requests, service } = createRuntimeHarness({
|
|
|
|
|
maxAutoTurns: 1,
|
|
|
|
|
messages: [assistantMessage('msg_cap_length', { finish: 'length' })],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await runIdleTick(runtime);
|
|
|
|
|
|
|
|
|
|
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
|
|
|
expect(requests.some((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toBe(false);
|
|
|
|
|
expect(lastPatchedGoal(requests)).toMatchObject({
|
|
|
|
|
status: 'blocked',
|
|
|
|
|
statusReason: 'auto-continuation limit reached',
|
|
|
|
|
});
|
|
|
|
|
runtime.stop();
|
|
|
|
|
});
|
2026-07-14 10:31:15 +03:00
|
|
|
});
|