fix(assistant): Claude Sonnet 5.5 no longer ends a turn with 'came back without an answer'

Sonnet 5.5 hands back the words written before a tool call as a hidden thinking block. After the ask for the answer, an empty step got the general push with tools open again; the model answered, offered follow-ups once more, and the answer was lost a second time. That push now asks for the answer as text and stays text-only.
This commit is contained in:
Maurice
2026-11-12 08:42:16 +01:00
parent 009be05d22
commit 1323183cce
2 changed files with 35 additions and 2 deletions
+18 -1
View File
@@ -199,6 +199,10 @@ const GO_ON =
'Your last reply came back empty or was cut off. Carry on from where you were: answer, or make the calls now. ' +
'If the plan is large, propose part of it now; after the user decided you continue with the rest.';
/** The push for an empty step after the ask for the answer: no calls are open to it any more. */
const ANSWER_NOW =
'Your last reply came back empty, and the user still sees no answer. Write your answer to them now, as text.';
/**
* Runs a read at once and records it: where it looks (streamed only), its
* call and answer, and the cards its answer makes (its package's card first,
@@ -590,7 +594,14 @@ async function runStep(turn: Turn, step: number): Promise<AgentOutcome | null> {
// An answer that offered follow-ups is finished, however it ends.
const settled = asks.length > 0 && !empty;
const announced = !settled && received.length === 0 && step === 0 && ANNOUNCED.test(text.trimEnd());
const push = empty || (!settled && received.length === 0 && cutOff) ? GO_ON : announced ? MAKE_THE_CALLS : null;
const push =
empty && turn.askedForAnswer
? ANSWER_NOW
: empty || (!settled && received.length === 0 && cutOff)
? GO_ON
: announced
? MAKE_THE_CALLS
: null;
// Follow-ups with no answer a second time end the turn: the model was already asked for the answer.
const wordlessAgain = empty && asks.length > 0;
if (push && !turn.nudged && !wordlessAgain) {
@@ -600,6 +611,12 @@ async function runStep(turn: Turn, step: number): Promise<AgentOutcome | null> {
// An empty assistant message is one the providers refuse; only the push goes back.
if (!empty) history.push({ role: 'assistant', text, toolCalls: [], providerState });
history.push({ role: 'user', text: push });
// Once the answer was asked for, the push is held to text as well. Claude
// Sonnet 5.5 hands back the words it writes before a tool call as a hidden
// thinking block: with tools open again it answers, offers follow-ups once
// more, and the answer is lost a second time. The chips of the first offer
// are kept in turn.followUps.
if (turn.askedForAnswer) turn.textOnlyNext = true;
return null;
}
if (empty) {
@@ -866,7 +866,7 @@ describe('runAgentLoop', () => {
expect(t.requests).toHaveLength(2);
});
it('ASTAGT-047: the text-only request is the one step after the ask, never the ones before or after it (#3016)', async () => {
it('ASTAGT-047: the text-only request starts with the step after the ask, never before it (#3016)', async () => {
const t = setup([
[{ type: 'tool-call', call: { id: 'l1', name: 'list_places', args: {} } }],
[ask('toolu_9', ['More stops']), { type: 'finish', reason: 'tool-calls' }],
@@ -880,6 +880,22 @@ describe('runAgentLoop', () => {
expect(t.requests.map((request) => request.toolChoice)).toEqual([undefined, undefined, 'none']);
});
it('ASTAGT-050: an empty step after the ask gets a push that is text-only too, so offered follow-ups cannot hide the answer again', async () => {
const t = setup([
[ask('toolu_10', ['Stops on the drive']), { type: 'finish', reason: 'tool-calls' }],
[{ type: 'finish', reason: 'stop' }],
[
{ type: 'text-delta', delta: 'You leave Lisbon on day 4.' },
{ type: 'finish', reason: 'stop' },
],
]);
const outcome = await runAgentLoop(t.deps, t.input, t.writer, new AbortController().signal);
expect(outcome.end).toBe('stop');
expect(t.requests.map((request) => request.toolChoice)).toEqual([undefined, 'none', 'none']);
expect(t.requests[2].messages.at(-1)).toMatchObject({ role: 'user', text: expect.stringContaining('as text') });
expect(t.chunks).toContainEqual({ type: 'data-trek-follow-ups', data: { questions: ['Stops on the drive'] } });
});
it('ASTAGT-045: follow-ups with no answer after an announcing first answer are still asked for the answer', async () => {
const t = setup([
[{ type: 'text-delta', delta: 'Let me check your places:' }],