diff --git a/server/src/nest/assistant/assistant-agent.ts b/server/src/nest/assistant/assistant-agent.ts index 2d2f4e9fd..25ea43892 100644 --- a/server/src/nest/assistant/assistant-agent.ts +++ b/server/src/nest/assistant/assistant-agent.ts @@ -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 { // 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 { // 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) { diff --git a/server/tests/unit/nest/assistant/assistant-agent.test.ts b/server/tests/unit/nest/assistant/assistant-agent.test.ts index ae0d75cd2..1376d9189 100644 --- a/server/tests/unit/nest/assistant/assistant-agent.test.ts +++ b/server/tests/unit/nest/assistant/assistant-agent.test.ts @@ -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:' }],