feat: surface output-token-limit info (#10831)

This commit is contained in:
Lifei Zhou
2026-08-04 22:44:08 +10:00
committed by GitHub
parent 6a1344ba49
commit bb539f7d6f
17 changed files with 1471 additions and 326 deletions
@@ -18,6 +18,8 @@ const DEFAULT_TOOL_CALL_UPDATE = {
sessionUpdate: 'tool_call_update',
toolCallId: 'tool-1',
} as const;
const OUTPUT_TOKEN_LIMIT_FALLBACK_TEXT =
'Response stopped because the model reached its output-token limit.';
function acpUpdate(update: SessionNotification['update']): SessionNotification {
return {
@@ -145,6 +147,42 @@ describe('createAcpSessionNotificationAdapter', () => {
expect(firstContent(messages[0])).toMatchObject({ type: 'text', text: 'Hell' });
});
it('keeps streamed content visible when an output-limit fallback chunk arrives later', () => {
const adapter = createAcpSessionNotificationAdapter();
adapter.apply(
acpUpdate({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Partial response' },
_meta: { goose: { messageId: 'msg-1' } },
} as SessionNotification['update'])
);
const messages = expectOnlyMessagesChange(
adapter.apply(
acpUpdate({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: OUTPUT_TOKEN_LIMIT_FALLBACK_TEXT },
_meta: {
goose: {
messageId: 'msg-1',
outputTokenLimitReached: true,
fallbackContent: true,
},
},
} as SessionNotification['update'])
)
);
expect(messages).toHaveLength(1);
expect(firstContent(messages[0])).toMatchObject({
type: 'text',
text: 'Partial response',
});
expect(messages[0].metadata.outputTokenLimitReached).toBe(true);
expect(messages[0].metadata.fallbackContent).toBeUndefined();
});
it('reconciles locally rendered steer text with server chunks', () => {
const adapter = createAcpSessionNotificationAdapter([
{
@@ -296,6 +334,62 @@ describe('createAcpSessionNotificationAdapter', () => {
signature: '',
});
});
it('preserves output-limit metadata when creating a thought message', () => {
const adapter = createAcpSessionNotificationAdapter();
const messages = expectOnlyMessagesChange(
adapter.apply(
acpUpdate({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text: 'Truncated thinking' },
_meta: {
goose: {
messageId: 'thought-1',
outputTokenLimitReached: true,
},
},
} as SessionNotification['update'])
)
);
expect(messages).toHaveLength(1);
expect(messages[0].metadata.outputTokenLimitReached).toBe(true);
});
it('preserves output-limit metadata when updating a thought message', () => {
const adapter = createAcpSessionNotificationAdapter();
adapter.apply(
acpUpdate({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text: 'Truncated ' },
_meta: { goose: { messageId: 'thought-1' } },
} as SessionNotification['update'])
);
const messages = expectOnlyMessagesChange(
adapter.apply(
acpUpdate({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text: 'thinking' },
_meta: {
goose: {
messageId: 'thought-1',
outputTokenLimitReached: true,
},
},
} as SessionNotification['update'])
)
);
expect(messages).toHaveLength(1);
expect(firstContent(messages[0])).toMatchObject({
type: 'thinking',
thinking: 'Truncated thinking',
});
expect(messages[0].metadata.outputTokenLimitReached).toBe(true);
});
});
describe('tools', () => {
+31 -12
View File
@@ -31,6 +31,20 @@ export function applyContentChunk(
const existing = findMessageForChunk(state, role, messageId, gooseMeta.created);
if (existing) {
const isOutputLimitFallbackChunk =
gooseMeta.outputTokenLimitReached === true && gooseMeta.fallbackContent === true;
const existingMessageHasContent = existing.content.length > 0;
const shouldSkipFallbackChunk = isOutputLimitFallbackChunk && existingMessageHasContent;
existing.metadata.outputTokenLimitReached = gooseMeta.outputTokenLimitReached;
existing.metadata.fallbackContent = shouldSkipFallbackChunk
? undefined
: gooseMeta.fallbackContent;
if (shouldSkipFallbackChunk) {
return messagesChangeWithLocalSteerConfirmation(state, existing, gooseMeta.steer);
}
const lastContent = existing.content[existing.content.length - 1];
if (reconcileLocalSteerTextChunk(state, existing, content, gooseMeta.steer)) {
return messagesChangeWithLocalSteerConfirmation(state, existing, gooseMeta.steer);
@@ -54,6 +68,8 @@ export function applyContentChunk(
metadata: {
...DEFAULT_VISIBLE_MESSAGE_METADATA,
...(gooseMeta.steer ? { steer: true } : {}),
outputTokenLimitReached: gooseMeta.outputTokenLimitReached,
fallbackContent: gooseMeta.fallbackContent,
},
});
}
@@ -71,23 +87,26 @@ export function applyThoughtChunk(
const gooseMeta = getGooseMessageMeta(update);
const messageId = update.messageId ?? gooseMeta.messageId;
const existing = findMessageForChunk(state, 'assistant', messageId, gooseMeta.created);
let message = findMessageForChunk(state, 'assistant', messageId, gooseMeta.created);
if (existing) {
const lastContent = existing.content[existing.content.length - 1];
if (lastContent?.type === 'thinking') {
lastContent.thinking += update.content.text;
} else {
existing.content.push({ type: 'thinking', thinking: update.content.text, signature: '' });
}
} else {
state.messages.push({
if (!message) {
message = {
...(messageId ? { id: messageId } : {}),
role: 'assistant',
created: gooseMeta.created ?? Math.floor(Date.now() / 1000),
content: [{ type: 'thinking', thinking: update.content.text, signature: '' }],
content: [],
metadata: { ...DEFAULT_VISIBLE_MESSAGE_METADATA },
});
};
state.messages.push(message);
}
message.metadata.outputTokenLimitReached = gooseMeta.outputTokenLimitReached;
const lastContent = message.content[message.content.length - 1];
if (lastContent?.type === 'thinking') {
lastContent.thinking += update.content.text;
} else {
message.content.push({ type: 'thinking', thinking: update.content.text, signature: '' });
}
return messagesChange(state);
+6
View File
@@ -26,6 +26,8 @@ export type ToolCallState = Omit<ToolCallUpdate, '_meta'>;
export interface GooseMessageMeta {
messageId?: string;
created?: number;
outputTokenLimitReached?: boolean;
fallbackContent?: boolean;
steer?: boolean;
}
@@ -65,9 +67,13 @@ export function getGooseMessageMeta(update: { _meta?: unknown }): GooseMessageMe
return {};
}
const outputTokenLimitReached = goose.outputTokenLimitReached === true;
return {
created: typeof goose.created === 'number' ? goose.created : undefined,
messageId: typeof goose.messageId === 'string' ? goose.messageId : undefined,
outputTokenLimitReached: outputTokenLimitReached ? true : undefined,
fallbackContent: goose.fallbackContent === true ? true : undefined,
steer: goose.steer === true ? true : undefined,
};
}
+18 -2
View File
@@ -1,4 +1,5 @@
import { useMemo, useRef } from 'react';
import { AlertTriangle } from 'lucide-react';
import ImagePreview from './ImagePreview';
import { formatMessageTimestamp } from '../utils/timeUtils';
import MarkdownContent from './MarkdownContent';
@@ -49,8 +50,13 @@ export default function GooseMessage({
}: GooseMessageProps) {
const contentRef = useRef<HTMLDivElement | null>(null);
const { textContent: displayText, imagePaths } = getTextAndImageContent(message);
const thinkingContent = getThinkingContent(message);
const outputTokenLimitReached = message.metadata.outputTokenLimitReached === true;
const isOutputTokenLimitFallback =
outputTokenLimitReached && message.metadata.fallbackContent === true;
const { textContent, imagePaths: allImagePaths } = getTextAndImageContent(message);
const displayText = isOutputTokenLimitFallback ? '' : textContent;
const imagePaths = isOutputTokenLimitFallback ? [] : allImagePaths;
const thinkingContent = isOutputTokenLimitFallback ? null : getThinkingContent(message);
const timestamp = useMemo(() => formatMessageTimestamp(message.created), [message.created]);
const toolRequests = getToolRequests(message);
@@ -76,6 +82,9 @@ export default function GooseMessage({
);
const hasToolConfirmation = toolConfirmationContent !== undefined;
const hasElicitation = elicitationContent !== undefined;
const outputTokenLimitNotice = isOutputTokenLimitFallback
? "Response reached the model's output-token limit before returning content."
: "Response reached the model's output-token limit and may be incomplete.";
const elicitationData =
elicitationContent?.data.actionType === 'elicitation'
? (elicitationContent.data as typeof elicitationContent.data & {
@@ -219,6 +228,13 @@ export default function GooseMessage({
</div>
)}
{outputTokenLimitReached && (
<div className="mt-2 flex items-start gap-1.5 text-xs text-text-secondary">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-yellow-600 dark:text-yellow-400" />
<span>{outputTokenLimitNotice}</span>
</div>
)}
{hasToolConfirmation && !toolConfirmationShownInline && (
<ToolCallConfirmation
sessionId={sessionId}
+2
View File
@@ -186,7 +186,9 @@ export type MessageUsage = {
export type MessageMetadata = {
agentVisible: boolean;
fallbackContent?: boolean;
inference?: InferenceMetadata | null;
outputTokenLimitReached?: boolean;
steer?: boolean;
usage?: MessageUsage | null;
userVisible: boolean;