Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9766,6 +9766,167 @@ describe('Session', () => {
expect(core.getInvocationContext()).toBeUndefined();
});

describe('output-length truncation stop reason (issue #12113)', () => {
// When bounded output recovery (llm-chat.ts's
// MAX_OUTPUT_RECOVERY_ATTEMPTS loop) runs and still ends on a
// provider `MAX_TOKENS` finish reason, the visible response is a
// truncated partial, not a completed turn -- the ACP client is
// entitled to the protocol's own `max_tokens` stop reason rather
// than `end_turn`. See https://github.com/QwenLM/qwen-code/issues/12113.
it('reports max_tokens when the final finish reason is still MAX_TOKENS after recovery', async () => {
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
candidates: [
{
content: { parts: [{ text: 'partial fixture reply' }] },
finishReason: 'MAX_TOKENS',
},
],
},
},
]),
);

const result = await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'reply briefly' }],
});

expect(result.stopReason).toBe('max_tokens');
});

it('still reports end_turn for a normal, non-truncated completion', async () => {
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
candidates: [
{
content: { parts: [{ text: 'complete reply' }] },
finishReason: 'STOP',
},
],
},
},
]),
);

const result = await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'reply briefly' }],
});

expect(result.stopReason).toBe('end_turn');
});

it('reports end_turn when a truncated segment is followed by a successful one', async () => {
// A recovery attempt that truncates, followed by a later attempt
// that completes, must not leak the earlier MAX_TOKENS into the
// terminal decision -- only the LAST observed segment's finish
// reason counts.
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
candidates: [
{
content: { parts: [{ text: 'partial fixture reply' }] },
finishReason: 'MAX_TOKENS',
},
],
},
},
{
type: core.StreamEventType.CHUNK,
value: {
candidates: [
{
content: { parts: [{ text: ' continued and done' }] },
finishReason: 'STOP',
},
],
},
},
]),
);

const result = await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'reply briefly' }],
});

expect(result.stopReason).toBe('end_turn');
});

it('does not leak a truncated attempt into a later attempt that ends with no finish reason at all', async () => {
// Reviewer-flagged gap: `lastFinishReason` is reused across every
// attempt within one turn (the same AgentResponseCapture object is
// threaded through each tool-call round). If it were only ever
// overwritten -- never reset -- a MAX_TOKENS from an earlier round
// would survive into a later round whose own final segment never
// yields a finishReason at all (e.g. an empty stream after the tool
// result is sent back), wrongly reporting max_tokens for what the
// provider actually ended normally. `beginChannelDeliveryResponseBlock`
// resets it at the start of every attempt, so this must read back
// as end_turn.
const execute = vi.fn().mockResolvedValue({
llmContent: 'tool ok',
returnDisplay: 'tool ok',
});
mockToolRegistry.getTool.mockReturnValue({
name: 'demo_tool',
kind: core.Kind.Execute,
build: vi.fn().mockReturnValue({
params: {},
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
getDescription: vi.fn().mockReturnValue('demo_tool'),
toolLocations: vi.fn().mockReturnValue([]),
execute,
}),
});
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValueOnce(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
candidates: [
{
content: {
parts: [{ text: 'partial fixture reply' }],
},
finishReason: 'MAX_TOKENS',
},
],
functionCalls: [
{ id: 'call-1', name: 'demo_tool', args: {} },
],
},
},
]),
)
// The second attempt's stream ends without ever yielding a
// candidate -- so `candidate.finishReason` is never touched at
// all this round, unlike a chunk that carries an explicit STOP.
.mockResolvedValueOnce(createEmptyStream());

const result = await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'reply briefly' }],
});

expect(execute).toHaveBeenCalled();
expect(result.stopReason).toBe('end_turn');
});
});

describe('turn result recording', () => {
const trustedContext: core.InvocationContextV1 = {
version: 1,
Expand Down
70 changes: 69 additions & 1 deletion packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import type {
Content,
FinishReason,
FunctionCall,
GenerateContentResponseUsageMetadata,
Part,
Expand Down Expand Up @@ -1605,6 +1606,49 @@ interface AgentResponseCapture {
finalText: string;
};
agentOutput: AgentOutputMessageCapture;
/**
* The provider `finishReason` of the most recently observed stream
* candidate for the CURRENT model attempt, tracked independently of
* `agentOutput` because `AgentOutputMessageCapture.observeFinishReason`
* is a telemetry sink that no-ops unless sensitive span attributes are
* enabled -- it cannot be relied on to decide the ACP terminal stop
* reason. Set at every site that also calls `observeFinishReason`, and
* reset to `undefined` by `beginChannelDeliveryResponseBlock` at the
* start of each new attempt (tool-call round, or Stop hook / TODO-guard
* continuation) -- so it reflects only the last attempt's terminal
* reason, never a stale one from an earlier attempt in the same turn.
* An attempt whose final chunk carries no finishReason at all therefore
* reads back as `undefined` (safe `end_turn`), not a leaked prior
* `MAX_TOKENS`.
*/
lastFinishReason?: FinishReason;
}

/**
* `'MAX_TOKENS'` as a `FinishReason` without importing the Google GenAI SDK as
* a runtime value. The ACP agent entry point's static import closure is
* checked by the repo's bundle policy and must not reach `@google/genai`, so
* this mirrors the same cast the shared compat module already uses
* (`packages/core/src/core/genai-compat.ts`: `MAX_TOKENS: 'MAX_TOKENS' as
* GenAiFinishReason`) rather than importing that module across the package
* boundary.
*/
const FINISH_REASON_MAX_TOKENS = 'MAX_TOKENS' as FinishReason;

/**
* True when the turn's final observed provider finish reason is still an
* unresolved output-length truncation. Bounded output recovery
* (`MAX_OUTPUT_RECOVERY_ATTEMPTS` in `llm-chat.ts`) already tried and
* exhausted its attempts by the time `#handleStopHookLoop`'s natural-stop
* branch is reached -- if the provider's last segment still ended on
* `MAX_TOKENS`, the visible response is a truncated partial, not a
* completed turn, and the ACP client is entitled to be told so via the
* protocol's own `max_tokens` stop reason rather than `end_turn`.
*/
function isUnresolvedOutputTruncation(
responseCapture: AgentResponseCapture | undefined,
): boolean {
return responseCapture?.lastFinishReason === FINISH_REASON_MAX_TOKENS;
}

interface ChannelDeliveryResponseBlock {
Expand All @@ -1624,6 +1668,15 @@ function beginChannelDeliveryResponseBlock(
capture: AgentResponseCapture | undefined,
): ChannelDeliveryResponseBlock | undefined {
capture?.agentOutput.beginResponse();
// Mirrors `agentOutput.beginResponse()`'s own per-attempt reset: this is
// the single point all three send sites call exactly once before
// consuming a new attempt's stream. Without this, a MAX_TOKENS segment
// from an earlier attempt in the same turn (a tool-call round, or a Stop
// hook / TODO-guard continuation) would survive into a later attempt
// that legitimately ends without ever yielding a finishReason on its
// final chunk, so the fallback branch would wrongly read the stale value
// and report `max_tokens` for what is actually a normal `end_turn`.
if (capture) capture.lastFinishReason = undefined;
if (capture?.channelDelivery) capture.channelDelivery.finalText = '';
if (capture?.turnResult) capture.turnResult.finalText = '';
if (
Expand Down Expand Up @@ -6714,6 +6767,10 @@ export class Session implements SessionContext {
responseCapture.agentOutput.observeFinishReason(
candidate.finishReason,
);
if (candidate.finishReason) {
responseCapture.lastFinishReason =
candidate.finishReason;
}
}

if (
Expand Down Expand Up @@ -7339,7 +7396,11 @@ export class Session implements SessionContext {
}

if (!externalReason && !guardContinuation) {
return { stopReason: 'end_turn' };
return {
stopReason: isUnresolvedOutputTruncation(responseCapture)
? 'max_tokens'
: 'end_turn',
};
}

const continueParts: Part[] = [];
Expand Down Expand Up @@ -7868,6 +7929,9 @@ export class Session implements SessionContext {
options.responseCapture?.agentOutput.observeFinishReason(
candidate.finishReason,
);
if (candidate.finishReason && options.responseCapture) {
options.responseCapture.lastFinishReason = candidate.finishReason;
}
}

if (
Expand Down Expand Up @@ -10162,6 +10226,10 @@ export class Session implements SessionContext {
responseCapture.agentOutput.observeFinishReason(
candidate.finishReason,
);
if (candidate.finishReason) {
responseCapture.lastFinishReason =
candidate.finishReason;
}
}

if (
Expand Down
Loading