fix: complete goal resume and comment input follow-ups (#3361)

This commit is contained in:
Bohdan Triapitsyn
2026-09-05 10:53:34 +03:00
committed by GitHub
parent f3f844463b
commit 7c9fdd1ee5
9 changed files with 172 additions and 62 deletions
@@ -124,7 +124,10 @@ before touching the filesystem). Rationale: metadata rides every
The consecutive state is derived from the loaded message history, not
persisted, using `info.time.created` chronology rather than message IDs.
Summary messages are not agent turns; an ordinary completed assistant
turn naturally breaks the consecutive condition;
turn naturally breaks the consecutive condition. Explicit Resume grants
one new recovery attempt over the same transcript; the continuation
consumes that permission, so another truncation blocks again. Resume
does not bypass assistant errors or the token budget;
- otherwise, small-model audit of the objective + the last assistant turn
only — no conversation history and no continuation prompts
(`restrictToPreferredProvider`, session's own provider/model preferred):
@@ -707,7 +707,7 @@ export const createSessionGoalRuntime = ({
// A second consecutive completed, non-summary length-truncated turn is a
// bounded recovery failure. Derive this from the loaded transcript rather
// than persisting another goal counter.
if (lengthTail && hasRepeatedLengthTail(messages, lastAssistant, goal.createdAt)) {
if (lengthTail && goal.statusReason !== 'resumed' && hasRepeatedLengthTail(messages, lastAssistant, goal.createdAt)) {
await settleGoal({
sessionId, directory, goal, status: 'blocked', statusReason: 'repeated output truncation', tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
});
@@ -77,7 +77,10 @@ const createRuntimeHarness = ({ messages, messageFactory, goalOverrides = {}, ma
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(activeSession);
if (pathname === `/session/${SESSION_ID}` && init.method === 'PATCH') {
activeSession.metadata = JSON.parse(init.body).metadata;
return jsonResponse(activeSession);
}
if (pathname === `/session/${SESSION_ID}`) return jsonResponse(activeSession);
if (pathname === '/session/status') return jsonResponse({});
if (pathname === `/session/${SESSION_ID}/children`) return jsonResponse([]);
@@ -98,7 +101,7 @@ const createRuntimeHarness = ({ messages, messageFactory, goalOverrides = {}, ma
idleQuietMs: 10,
maxAutoTurns,
});
return { runtime, requests, service };
return { runtime, requests, service, activeSession };
};
const runIdleTick = async (runtime) => {
@@ -420,6 +423,45 @@ describe('session goal live activity gate', () => {
runtime.stop();
});
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();
}
});
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', {