fix(ai): persist truncated turns across chat entry points

Preserve partial output and token usage in CLI turns and edited UI rounds while leaving ordinary failures and cancellation unchanged. Use OutputLimitError across agent runners so incomplete turns remain visibly unsuccessful but can be reloaded and continued.

Cover real stream handling, SQLite persistence, reload, CLI continuation, and unsuccessful one-shot JSON output.
This commit is contained in:
digua committed 2026-09-18 10:53:06 +08:00
1 parent a2c4176f89
commit db4ce30852
8 files changed
+330 -18

No files matched your search

+42 -9
View File
@@ -5,6 +5,7 @@ import { join } from 'node:path'
import { test } from 'node:test'
import * as nodeRuntime from '../packages/node-runtime/src/index'
import { runServerAgent } from '../apps/cli/src/ai/agent'
import { runChatTurn } from '../apps/cli/src/ai/chat-command'
import { AIChatManager } from '../packages/node-runtime/src/ai/chats'
import { runCrossChatAgent } from '../packages/node-runtime/src/ai/cross-chat-agent'
import type { AgentStreamChunk } from '../packages/node-runtime/src/ai/agent/event-handler'
@@ -68,7 +69,7 @@ test('session and global agents surface truncated output instead of reporting su
getDefaultAssistantConfig: () => config,
} as LLMConfigStore)
for (const kind of ['session', 'global', 'desktop session'] as const) {
for (const kind of ['session', 'global', 'desktop session', 'CLI command'] as const) {
for (const scenario of [
{ name: 'thinking only', text: '', finishReason: 'length' },
{ name: 'partial answer', text: 'The conclusion is', finishReason: 'length' },
@@ -117,7 +118,26 @@ test('session and global agents surface truncated output instead of reporting su
aiChatManager: manager,
onEvent: (event: AgentStreamChunk) => events.push(event),
}
if (kind === 'global') {
if (kind === 'CLI command') {
await runChatTurn(
{ aiChatId: chat.id, question: common.userMessage, json: true, locale: common.locale },
{
dbManager: { open: () => ({}) } as never,
pathProvider: {} as never,
aiChatManager: manager,
createRunAgentStream: () => async (_params, onEvent) => {
await runServerAgent({
...common,
llmConfig: config,
onEvent: (event) => {
common.onEvent(event)
onEvent(event)
},
})
},
}
)
} else if (kind === 'global') {
await runCrossChatAgent({
...common,
piModel: buildPiModel(config),
@@ -146,7 +166,10 @@ test('session and global agents surface truncated output instead of reporting su
const errors = events.filter((event) => event.type === 'error')
const truncated = scenario.finishReason === 'length'
assert.equal(errors.length, truncated ? 1 : 0)
if (truncated) assert.match((errors[0].error as { message: string }).message, /output limit/i)
if (truncated) {
assert.match((errors[0].error as { message: string }).message, /output limit/i)
assert.equal((errors[0].error as { name: string }).name, 'OutputLimitError')
}
assert.deepEqual(
events
.filter((event) => event.type === 'status')
@@ -165,12 +188,22 @@ test('session and global agents surface truncated output instead of reporting su
cacheWriteTokens: 0,
}
assert.deepEqual(doneUsage, expectedUsage)
manager.addMessagePair(
chat.id,
{ content: common.userMessage },
{ content: scenario.text, tokenUsage: doneUsage }
)
assert.deepEqual(manager.getAIChatTokenUsage(chat.id), expectedUsage)
if (kind === 'CLI command') {
const messages = manager.getMessages(chat.id)
assert.deepEqual(
messages.map((message) => message.content),
[common.userMessage, scenario.text]
)
assert.ok(messages[1].contentBlocks?.some((block) => block.type === 'think'))
assert.equal(
messages[1].contentBlocks?.some((block) => block.type === 'error'),
truncated
)
assert.deepEqual(manager.getAIChatTokenUsage(chat.id), expectedUsage)
if (scenario.text) {
assert.equal(manager.getHistoryForAgent(chat.id).at(-1)?.content, scenario.text)
}
}
})
}
}