mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-13 10:14:20 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f05ea0c9b7 |
No files matched your search
@@ -0,0 +1,18 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { enAiMessages } from './en/ai';
|
||||
import { esAiMessages } from './es/ai';
|
||||
import { ruAiMessages } from './ru/ai';
|
||||
import { zhCNAiMessages } from './zh-CN/ai';
|
||||
import { zhTWAiMessages } from './zh-TW/ai';
|
||||
|
||||
for (const [locale, messages] of Object.entries({
|
||||
en: enAiMessages, es: esAiMessages, ru: ruAiMessages,
|
||||
'zh-CN': zhCNAiMessages, 'zh-TW': zhTWAiMessages,
|
||||
})) {
|
||||
test(`${locale} translates the Catty iteration limit notice and Continue action`, () => {
|
||||
assert.ok(messages['ai.chat.iterationLimitReached']);
|
||||
assert.match(messages['ai.chat.iterationLimitReached'], /\{limit\}/);
|
||||
assert.ok(messages['ai.chat.continue']);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enAiMessages: Messages = {
|
||||
'ai.chat.iterationLimitReached': 'Tool-loop limit ({limit} iterations) reached. You can continue in this chat. Running terminal jobs are not stopped.',
|
||||
'ai.chat.continue': 'Continue',
|
||||
'ai.chat.attachmentContentUnavailable': 'Content not available on this device',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esAiMessages: Messages = {
|
||||
'ai.chat.iterationLimitReached': 'Se alcanzó el límite del bucle de herramientas ({limit} iteraciones). Puedes continuar en este chat. Las tareas de terminal en ejecución no se detienen.',
|
||||
'ai.chat.continue': 'Continuar',
|
||||
'ai.chat.attachmentContentUnavailable': 'Contenido no disponible en este dispositivo',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Configuración del agente',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruAiMessages: Messages = {
|
||||
'ai.chat.iterationLimitReached': 'Достигнут лимит цикла инструментов ({limit} итераций). Можно продолжить в этом чате. Запущенные задачи терминала не останавливаются.',
|
||||
'ai.chat.continue': 'Продолжить',
|
||||
'ai.chat.attachmentContentUnavailable': 'Содержимое недоступно на этом устройстве',
|
||||
'ai.chat.contextUsage': 'Контекст: {used} / {max} токенов',
|
||||
// AI Settings
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.iterationLimitReached': '已达到工具循环上限({limit} 次迭代)。你可以在此对话中继续,正在运行的终端任务不会停止。',
|
||||
'ai.chat.continue': '继续',
|
||||
'ai.chat.attachmentContentUnavailable': '附件内容未保留在此设备',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.iterationLimitReached': '已達到工具迴圈上限({limit} 次迭代)。你可以在此對話中繼續,正在執行的終端工作不會停止。',
|
||||
'ai.chat.continue': '繼續',
|
||||
'ai.chat.attachmentContentUnavailable': '附件內容未保留在此裝置',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 設定',
|
||||
|
||||
@@ -55,7 +55,7 @@ interface AIChatPanelContentProps {
|
||||
contextUsage?: import('../application/state/useAgentCompactionUi').AgentContextUsage | null;
|
||||
inputValue: string;
|
||||
setInputValue: (value: string) => void;
|
||||
handleSend: () => void;
|
||||
handleSend: (textOverride?: string) => void;
|
||||
handleCompact: () => void;
|
||||
/** Rename a chat by id (history drawer edit affordance). Panel action, never agent content. */
|
||||
onRenameSession?: (sessionId: string, title: string) => void;
|
||||
@@ -211,6 +211,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
parked = false,
|
||||
sending = false,
|
||||
}) => {
|
||||
const handleContinue = React.useCallback(() => handleSend(t('ai.chat.continue')), [handleSend, t]);
|
||||
const mentionHosts = React.useMemo(
|
||||
() => terminalSessions.map((session) => ({
|
||||
sessionId: session.sessionId,
|
||||
@@ -345,6 +346,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
onContinue={handleContinue}
|
||||
activeSessionId={activeSessionId}
|
||||
activeCompaction={activeCompaction}
|
||||
contextCompaction={activeSession?.contextCompaction}
|
||||
|
||||
@@ -1610,14 +1610,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
|
||||
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const handleSend = useCallback(async (textOverride?: string) => {
|
||||
const draft = currentDraftRef.current;
|
||||
const currentPanelView = panelViewRef.current;
|
||||
const currentSessionView = activeSessionRef.current;
|
||||
if (!validateNoteMentions(draft?.attachments ?? [])) return;
|
||||
const trimmed = draft?.text.trim() ?? '';
|
||||
if (textOverride === undefined && !validateNoteMentions(draft?.attachments ?? [])) return;
|
||||
const trimmed = (textOverride ?? draft?.text ?? '').trim();
|
||||
const sendScopeKey = scopeKey;
|
||||
const attachments = (draft?.attachments ?? []).map((file) => ({
|
||||
const attachments = (textOverride === undefined ? draft?.attachments ?? [] : []).map((file) => ({
|
||||
base64Data: file.base64Data,
|
||||
mediaType: file.mediaType,
|
||||
filename: file.filename,
|
||||
@@ -1645,12 +1645,12 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
|
||||
if (sendAgentId !== 'catty' && !agentConfig) return;
|
||||
|
||||
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
|
||||
const selectedSkillSlugs = textOverride === undefined ? draft?.selectedUserSkillSlugs ?? [] : [];
|
||||
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
|
||||
const modelAttachments = attachments.filter((attachment) => !isInlineTextAttachment(attachment));
|
||||
const isDraftMode = currentPanelView.mode === 'draft';
|
||||
|
||||
flushDraftText();
|
||||
if (textOverride === undefined) flushDraftText();
|
||||
const submittedText = draft?.text ?? '';
|
||||
const keepPendingAfterSend = () => {
|
||||
const pending = pendingComposerTextRef.current;
|
||||
@@ -1739,7 +1739,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const createdSession = createSession(scope, sendAgentId);
|
||||
sessionId = createdSession.id;
|
||||
currentSession = createdSession;
|
||||
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
if (textOverride === undefined) clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
showScopeSessionView(createdSession.id);
|
||||
setActiveSessionId(createdSession.id);
|
||||
sessionSendGateKey = sessionId;
|
||||
@@ -1770,7 +1770,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
});
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
if (textOverride === undefined) clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
showScopeSessionView(sessionId);
|
||||
}
|
||||
return;
|
||||
@@ -1785,7 +1785,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
});
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
if (textOverride === undefined) clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
showScopeSessionView(sessionId);
|
||||
}
|
||||
return;
|
||||
@@ -1798,7 +1798,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
if (textOverride === undefined) clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
showScopeSessionView(sessionId);
|
||||
setActiveSessionId(sessionId);
|
||||
setStreamingForScope(sessionId, true);
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
@@ -644,3 +645,64 @@ test('fork is offered only for safe completed replies and hidden while streaming
|
||||
assert.equal((render(false).match(/Fork from here/g) ?? []).length, 1);
|
||||
assert.equal((render(true).match(/Fork from here/g) ?? []).length, 0);
|
||||
});
|
||||
|
||||
test('ChatMessageList renders a non-error iteration limit notice with a Continue action', () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{ id: 'poll', role: 'tool', content: '', timestamp: 1,
|
||||
toolResults: [{ toolCallId: 'poll-1', content: '{"jobId":"job-1","status":"running"}' }] },
|
||||
{ id: 'limit', role: 'assistant', content: '', timestamp: 2, iterationLimit: 20 },
|
||||
];
|
||||
const render = (isStreaming: boolean) => renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<TooltipProvider>
|
||||
<ChatMessageList messages={messages} isStreaming={isStreaming} onContinue={() => {}} />
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
const markup = render(false);
|
||||
assert.match(markup, /Tool-loop limit \(20 iterations\) reached/);
|
||||
assert.match(markup, /<button[^>]*>Continue<\/button>/);
|
||||
assert.doesNotMatch(markup, /bg-destructive/);
|
||||
assert.match(render(true), /<button[^>]*disabled=""[^>]*>Continue<\/button>/);
|
||||
|
||||
const source = readFileSync(new URL('./ChatMessageList.tsx', import.meta.url), 'utf8');
|
||||
const panel = readFileSync(new URL('../AIChatPanelContent.tsx', import.meta.url), 'utf8');
|
||||
const sidePanel = readFileSync(new URL('../AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /onClick=\{onContinue\}/);
|
||||
assert.match(panel, /onContinue=\{handleContinue\}/);
|
||||
assert.match(panel, /handleContinue = React.useCallback\(\(\) => handleSend\(t\('ai.chat.continue'\)\)/);
|
||||
assert.match(sidePanel, /const handleSend = useCallback\(async \(textOverride\?: string\)/);
|
||||
assert.match(sidePanel, /const trimmed = \(textOverride \?\? draft\?\.text \?\? ''\)\.trim\(\)/);
|
||||
});
|
||||
|
||||
test('iteration limit Continue button invokes the provided send callback', async t => {
|
||||
const previousWindow = globalThis.window;
|
||||
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousActEnvironment = actGlobal.IS_REACT_ACT_ENVIRONMENT;
|
||||
globalThis.window = { setTimeout, clearTimeout } as unknown as Window & typeof globalThis;
|
||||
actGlobal.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
t.after(() => {
|
||||
globalThis.window = previousWindow;
|
||||
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
});
|
||||
let continued = 0;
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<I18nProvider locale="en">
|
||||
<TooltipProvider>
|
||||
<ChatMessageList
|
||||
messages={[{ id: 'limit', role: 'assistant', content: '', timestamp: 1, iterationLimit: 20 }]}
|
||||
onContinue={() => { continued += 1; }}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
const button = renderer!.root.findAllByType('button').find(node => node.children.includes('Continue'));
|
||||
assert.ok(button);
|
||||
assert.equal(button.props.disabled, false);
|
||||
await act(async () => { button.props.onClick(); });
|
||||
assert.equal(continued, 1);
|
||||
await act(async () => { renderer!.unmount(); });
|
||||
});
|
||||
@@ -90,6 +90,7 @@ import ChatJumpNav from './ChatJumpNav';
|
||||
interface ChatMessageListProps {
|
||||
messages: ChatMessage[];
|
||||
isStreaming?: boolean;
|
||||
onContinue?: () => void;
|
||||
/** Active chat session ID — used to filter standalone MCP approval blocks */
|
||||
activeSessionId?: string | null;
|
||||
activeCompaction?: ActiveCompactionUi | null;
|
||||
@@ -185,6 +186,7 @@ export function pruneResolvedApprovals(
|
||||
const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
messages,
|
||||
isStreaming,
|
||||
onContinue,
|
||||
activeSessionId,
|
||||
activeCompaction = null,
|
||||
contextCompaction,
|
||||
@@ -813,6 +815,18 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.iterationLimit != null && (
|
||||
<div role="status" className="rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-sm">
|
||||
<p>{t('ai.chat.iterationLimitReached', { limit: message.iterationLimit })}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
disabled={!!isStreaming || !onContinue}
|
||||
className="mt-2 rounded-md border border-border px-2 py-1 text-xs font-medium hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>{t('ai.chat.continue')}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error info */}
|
||||
{message.errorInfo && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-destructive/10 border border-destructive/20 text-sm">
|
||||
@@ -1079,6 +1093,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
|
||||
function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps): boolean {
|
||||
if (prev.isStreaming !== next.isStreaming) return false;
|
||||
if (prev.onContinue !== next.onContinue) return false;
|
||||
if (prev.activeSessionId !== next.activeSessionId) return false;
|
||||
if (prev.notes !== next.notes) return false;
|
||||
if (prev.hosts !== next.hosts) return false;
|
||||
@@ -1107,6 +1122,7 @@ function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps
|
||||
p.statusText !== n.statusText ||
|
||||
p.executionStatus !== n.executionStatus ||
|
||||
p.errorInfo !== n.errorInfo ||
|
||||
p.iterationLimit !== n.iterationLimit ||
|
||||
p.toolCalls !== n.toolCalls ||
|
||||
p.toolResults !== n.toolResults ||
|
||||
p.agentActivities !== n.agentActivities ||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { jsonSchema, tool } from 'ai';
|
||||
import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';
|
||||
import { processCattyStream, shouldEmitAgentEventsForStreamChunk } from './turnDrivers/cattyStreamProcessor';
|
||||
import type { CattyToolsBundle } from './capabilityTools';
|
||||
import { createInitialCattyRuntimeContext } from './cattyRuntimeContext';
|
||||
import type { ChatMessage } from '../types';
|
||||
|
||||
@@ -111,3 +113,100 @@ describe('processCattyStream reasoning continuation', () => {
|
||||
assert.match(continuation?.reasoningParts?.map(part => part.text).join('') ?? '', /thinking/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processCattyStream iteration limit', () => {
|
||||
const usage = {
|
||||
inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined },
|
||||
outputTokens: { total: 1, text: 1, reasoning: undefined },
|
||||
};
|
||||
|
||||
async function run(t: import('node:test').TestContext, replies: ('poll' | 'summary')[], maxIterations: number) {
|
||||
const originalRaf = globalThis.requestAnimationFrame;
|
||||
const originalCancelRaf = globalThis.cancelAnimationFrame;
|
||||
t.after(() => {
|
||||
globalThis.requestAnimationFrame = originalRaf;
|
||||
globalThis.cancelAnimationFrame = originalCancelRaf;
|
||||
});
|
||||
globalThis.requestAnimationFrame = callback => setTimeout(() => callback(0), 0) as unknown as number;
|
||||
globalThis.cancelAnimationFrame = id => clearTimeout(id);
|
||||
|
||||
const messages: ChatMessage[] = [{ id: 'assistant-1', role: 'assistant', content: '', timestamp: 0 }];
|
||||
let calls = 0;
|
||||
const model = new MockLanguageModelV4({
|
||||
doStream: async () => {
|
||||
const reply = replies[calls++];
|
||||
assert.ok(reply, 'unexpected extra model step');
|
||||
return {
|
||||
stream: simulateReadableStream({ chunks: [
|
||||
{ type: 'stream-start', warnings: [] },
|
||||
...(reply === 'poll' ? [{
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: `poll-${calls}`,
|
||||
toolName: 'terminal_poll',
|
||||
input: '{"jobId":"job-1"}',
|
||||
}] : [
|
||||
{ type: 'text-start' as const, id: 'summary' },
|
||||
{ type: 'text-delta' as const, id: 'summary', delta: 'The job completed.' },
|
||||
{ type: 'text-end' as const, id: 'summary' },
|
||||
]),
|
||||
{ type: 'finish', finishReason: { unified: reply === 'poll' ? 'tool-calls' : 'stop', raw: undefined }, usage },
|
||||
] }),
|
||||
};
|
||||
},
|
||||
});
|
||||
await processCattyStream({
|
||||
streamSessionId: 'session-1', model, systemPrompt: 'test',
|
||||
toolsBundle: {
|
||||
tools: {
|
||||
terminal_poll: tool({
|
||||
inputSchema: jsonSchema<{ jobId: string }>({
|
||||
type: 'object', properties: { jobId: { type: 'string' } }, required: ['jobId'],
|
||||
}),
|
||||
execute: async ({ jobId }) => ({ jobId, status: 'running', output: 'buffered output' }),
|
||||
}),
|
||||
} as unknown as CattyToolsBundle['tools'],
|
||||
toolsContext: {},
|
||||
},
|
||||
sdkMessages: [{ role: 'user', content: 'Check the job' }],
|
||||
signal: new AbortController().signal,
|
||||
currentAssistantMsgId: 'assistant-1', maxIterations,
|
||||
runtimeContext: createInitialCattyRuntimeContext({
|
||||
chatSessionId: 'session-1', turnId: 'turn-1', permissionMode: 'auto', scopeType: 'terminal',
|
||||
}),
|
||||
ui: {
|
||||
addMessageToSession: (sessionId, message) => {
|
||||
assert.equal(sessionId, 'session-1');
|
||||
messages.push(message);
|
||||
},
|
||||
updateMessageById: (_sessionId, id, updater) => {
|
||||
const index = messages.findIndex(message => message.id === id);
|
||||
if (index >= 0) messages[index] = updater(messages[index]);
|
||||
},
|
||||
},
|
||||
});
|
||||
return { messages, calls };
|
||||
}
|
||||
|
||||
it('adds a non-error assistant notice after terminal_poll exhausts the step cap', async t => {
|
||||
const { messages, calls } = await run(t, ['poll', 'poll'], 2);
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(messages.at(-2)?.role, 'tool');
|
||||
assert.match(messages.at(-2)?.toolResults?.[0].content ?? '', /job-1/);
|
||||
const notice = messages.at(-1)!;
|
||||
assert.equal(notice.role, 'assistant');
|
||||
assert.equal(notice.iterationLimit, 2);
|
||||
assert.equal(notice.errorInfo, undefined);
|
||||
});
|
||||
|
||||
for (const [name, replies, maxIterations] of [
|
||||
['natural stop before the cap', ['summary'], 2],
|
||||
['natural stop on the last allowed step', ['summary'], 1],
|
||||
['post-tool summary on the last allowed step', ['poll', 'summary'], 2],
|
||||
] as const) {
|
||||
it(`does not add a limit notice for ${name}`, async t => {
|
||||
const { messages } = await run(t, [...replies], maxIterations);
|
||||
assert.equal(messages.at(-1)?.content, 'The job completed.');
|
||||
assert.ok(messages.every(message => message.iterationLimit === undefined));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -141,6 +141,8 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
|
||||
: undefined;
|
||||
const promptCache = createCattyPromptCache(systemPrompt, model.provider, endpoint);
|
||||
|
||||
let reachedIterationLimit = false;
|
||||
const stopAtIterationLimit = isStepCount(maxIterations);
|
||||
const result = streamText({
|
||||
model,
|
||||
messages: promptCache.prepareMessages(sdkMessages),
|
||||
@@ -152,7 +154,12 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
|
||||
permissionMode: runtimeContext.permissionMode,
|
||||
chatSessionId: runtimeContext.chatSessionId,
|
||||
}),
|
||||
stopWhen: isStepCount(maxIterations),
|
||||
// The SDK evaluates stopWhen only when tool work would allow another
|
||||
// step. A natural final reply (even on the last allowed step) bypasses it.
|
||||
stopWhen: async (options) => {
|
||||
reachedIterationLimit = await stopAtIterationLimit(options);
|
||||
return reachedIterationLimit;
|
||||
},
|
||||
abortSignal: signal,
|
||||
include: { rawChunks: true },
|
||||
timeout: buildCattyStreamTimeouts({
|
||||
@@ -580,6 +587,16 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
|
||||
const usage = await result.usage;
|
||||
const finalStep = await result.finalStep;
|
||||
const performance = finalStep?.performance;
|
||||
if (reachedIterationLimit && !signal.aborted) {
|
||||
clearCompactionStatusFromAssistant(activeMsgId);
|
||||
ui.addMessageToSession(streamSessionId, {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
iterationLimit: maxIterations,
|
||||
});
|
||||
}
|
||||
// onEnd can run before this consumer flushes its final text and creates the
|
||||
// post-tool assistant message. Attach turn totals only after that flush.
|
||||
if (usage) {
|
||||
|
||||
@@ -171,6 +171,8 @@ export interface ChatMessage {
|
||||
timestamp: number;
|
||||
model?: string;
|
||||
providerId?: AIProviderId;
|
||||
/** Catty stopped at its tool-loop cap; rendered as a non-error notice. */
|
||||
iterationLimit?: number;
|
||||
errorInfo?: {
|
||||
type: 'network' | 'auth' | 'timeout' | 'provider' | 'agent' | 'unknown';
|
||||
message: string;
|
||||
|
||||
Reference in new issue
Block a user