fix: gate session goal audits on live child activity
Re-checks authoritative session status after the quiet window Skips auditing while a direct child session is still busy or retrying Retries the quiet window when live status data is unavailable
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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,
|
||||
idleQuietMs: 10,
|
||||
});
|
||||
runtime.processPayload({
|
||||
type: 'session.status',
|
||||
properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
return { runtime, getSmallModelService };
|
||||
};
|
||||
|
||||
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);
|
||||
requests.push({ pathname, method: init.method ?? 'GET' });
|
||||
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"}',
|
||||
})),
|
||||
};
|
||||
vi.stubGlobal('fetch', fetchImpl);
|
||||
const runtime = createSessionGoalRuntime({
|
||||
buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
getSmallModelService: async () => service,
|
||||
idleQuietMs: 10,
|
||||
});
|
||||
|
||||
runtime.processPayload({
|
||||
type: 'session.status',
|
||||
properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(service.generateSmallModelText).toHaveBeenCalledOnce();
|
||||
expect(requests).toContainEqual({ pathname: `/session/${SESSION_ID}`, method: 'PATCH' });
|
||||
runtime.stop();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user