feat: surface output-token-limit info (#10831)
This commit is contained in:
@@ -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,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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user