fix: isolate visual tool failures from agent runs

This commit is contained in:
john
2026-07-27 19:20:23 +08:00
parent 7002007128
commit 17c59fde71
10 changed files with 444 additions and 16 deletions
+141
View File
@@ -1246,6 +1246,103 @@ test('agent run replaces reasoning-poisoned Goose session and retries with visib
assert.equal(replacedData?.reason, 'SESSION_REASONING_CONTENT_POISONED');
});
test('agent run degrades visual inspection after an earlier session-history recovery', async () => {
const pool = createFakePool();
const submitted = [];
const started = [];
const replacementIds = ['session-tool-clean', 'session-visual-fallback'];
const priorConversation = [
{
role: 'user',
content: [{ type: 'text', text: '帮我写一首诗,做成页面' }],
},
{
role: 'assistant',
content: [{ type: 'text', text: '页面已经生成。' }],
},
];
const gateway = createAgentRunGateway({
pool,
userAuth: {},
tkmindProxy: {
async startSessionForUser(_userId, options) {
started.push(options ?? null);
return { id: replacementIds.shift() };
},
async fetchSessionConversationForUser() {
return priorConversation;
},
async submitSessionReplyAndAwaitFinishForUser(
_userId,
sessionId,
_requestId,
userMessage,
options,
) {
submitted.push({ sessionId, userMessage, options });
if (sessionId === 'session-poisoned') {
const error = new Error('tool history requires fresh session');
error.code = 'SESSION_TOOL_HISTORY_FRESH_SESSION_REQUIRED';
error.repairedConversation = priorConversation;
throw error;
}
if (sessionId === 'session-tool-clean') {
const error = new Error(
'messages[8]: unknown variant `image_url`, expected `text`',
);
error.code = 'SESSION_VISUAL_CONTEXT_UNSUPPORTED';
error.retryable = false;
throw error;
}
return {
ok: true,
finishEvent: { type: 'Finish' },
toolEvidence: { calls: ['sandbox-fs__edit_file'] },
};
},
},
retryDelaysMs: [],
});
const run = await gateway.createRun('user-1', {
sessionId: 'session-poisoned',
requestId: 'req-visual-fallback',
userMessage: {
role: 'user',
content: [{ type: 'text', text: '页面再精美一点' }],
},
});
await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded');
assert.deepEqual(submitted.map((item) => item.sessionId), [
'session-poisoned',
'session-tool-clean',
'session-visual-fallback',
]);
assert.deepEqual(started, [
null,
{ disableImageReading: true },
]);
assert.equal(
submitted.at(-1).options.disableImageReading,
true,
);
assert.match(
submitted.at(-1).userMessage.content[0].text,
/视觉检查已降级/,
);
assert.match(
submitted.at(-1).userMessage.content[0].text,
/不要再次调用 read_image/,
);
assert.equal(
pool.events.filter(
(event) => event.eventType === 'poisoned_session_replaced',
).length,
2,
);
});
test('Page Data run fails closed when Finish arrives without a generated page', async () => {
const pool = createFakePool();
const repairSubmits = [];
@@ -2756,6 +2853,50 @@ test('agent run retries transient failures and then becomes terminal', async ()
assert.match(pool.runs.get(run.id).error_message, /upstream unavailable/);
});
test('agent run cancels an active upstream request before retrying', async () => {
const pool = createFakePool();
const calls = [];
let submissions = 0;
const gateway = createAgentRunGateway({
pool,
userAuth: {},
tkmindProxy: {
async startSessionForUser() {
return { id: 'session-retry-cancel' };
},
async submitSessionReplyForUser() {
submissions += 1;
calls.push(`submit-${submissions}`);
if (submissions === 1) throw new Error('upstream unavailable');
},
},
async cancelSessionOnRetry(input) {
calls.push('cancel');
assert.equal(input.sessionId, 'session-retry-cancel');
assert.equal(input.requestId, 'req-retry-cancel');
return { cancelled: true, skipped: false };
},
retryDelaysMs: [0, 0],
});
const run = await gateway.createRun('user-1', {
requestId: 'req-retry-cancel',
userMessage: {
role: 'user',
content: [{ type: 'text', text: '继续任务' }],
},
});
await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded');
assert.deepEqual(calls, ['submit-1', 'cancel', 'submit-2']);
assert.equal(
pool.events.some(
(event) => event.eventType === 'session_retry_cancelled',
),
true,
);
});
test('agent run queue limits concurrent execution', async () => {
const pool = createFakePool();
let active = 0;