fix: keep cancelled turns as interrupted instead of merging into the next request
- Build runtime history per turn: a cancelled or failed reply keeps its partial text plus an interruption marker (or a marker-only placeholder), so its user message is never left unanswered and merged into the next request. - Use the same history for send, manual compaction and Main compact validation. - OpenCode: keep a cancelled prompt in the reused native session and prefix the next turn with a one-time synthetic interruption notice; failed prompts are still removed. - Add an opt-in DeepSeek live test covering direct model and OpenCode.
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildRuntimeHistory,
|
||||
cancelledResponseMarker,
|
||||
failedResponseMarker
|
||||
} from './runtime-history'
|
||||
|
||||
const id = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`
|
||||
|
||||
describe('buildRuntimeHistory', () => {
|
||||
it('keeps a cancelled turn answered by its partial reply and an interruption marker', () => {
|
||||
expect(buildRuntimeHistory([
|
||||
{ id: id(1), role: 'user', content: 'First question', state: 'complete' },
|
||||
{ id: id(2), role: 'assistant', content: 'Partial answer', state: 'error', terminalStatus: 'cancelled' },
|
||||
{ id: id(3), role: 'user', content: 'continue', state: 'complete' },
|
||||
{ id: id(4), role: 'assistant', content: '', state: 'error', terminalStatus: 'cancelled' },
|
||||
{ id: id(5), role: 'user', content: 'continue', state: 'complete' },
|
||||
{ id: id(6), role: 'assistant', content: 'Final answer', state: 'complete' }
|
||||
])).toEqual([
|
||||
{ id: id(1), role: 'user', content: 'First question' },
|
||||
{ id: id(2), role: 'assistant', content: `Partial answer\n\n${cancelledResponseMarker}` },
|
||||
{ id: id(3), role: 'user', content: 'continue' },
|
||||
{ id: id(4), role: 'assistant', content: cancelledResponseMarker },
|
||||
{ id: id(5), role: 'user', content: 'continue' },
|
||||
{ id: id(6), role: 'assistant', content: 'Final answer' }
|
||||
])
|
||||
})
|
||||
|
||||
it('marks failed replies and recognizes legacy cancelled labels', () => {
|
||||
expect(buildRuntimeHistory([
|
||||
{ id: id(1), role: 'user', content: 'A', state: 'complete' },
|
||||
{ id: id(2), role: 'assistant', content: '', state: 'error', status: '请求已取消' },
|
||||
{ id: id(3), role: 'user', content: 'B', state: 'complete' },
|
||||
{ id: id(4), role: 'assistant', content: 'half', state: 'error', terminalStatus: 'failed' }
|
||||
]).map((message) => message.content)).toEqual([
|
||||
'A', cancelledResponseMarker, 'B', `half\n\n${failedResponseMarker}`
|
||||
])
|
||||
})
|
||||
|
||||
it('skips streaming, empty and orphaned terminal messages', () => {
|
||||
expect(buildRuntimeHistory([
|
||||
{ id: id(1), role: 'assistant', content: 'orphan', state: 'error', terminalStatus: 'cancelled' },
|
||||
{ id: id(2), role: 'user', content: ' ', state: 'complete' },
|
||||
{ id: id(3), role: 'user', content: 'Q', state: 'complete' },
|
||||
{ id: id(4), role: 'assistant', content: 'A', state: 'complete' },
|
||||
{ id: id(5), role: 'assistant', content: 'late', state: 'error', terminalStatus: 'failed' },
|
||||
{ id: id(6), role: 'user', content: 'Now', state: 'complete' },
|
||||
{ id: id(7), role: 'assistant', content: 'typing', state: 'streaming' }
|
||||
]).map((message) => message.id)).toEqual([id(3), id(4), id(6)])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ConversationMessage } from './assistant-contracts'
|
||||
|
||||
export const cancelledResponseMarker =
|
||||
'[This response was interrupted by the user before it finished.]'
|
||||
export const failedResponseMarker =
|
||||
'[This response failed before it finished.]'
|
||||
|
||||
type HistorySourceMessage = Pick<
|
||||
ConversationMessage,
|
||||
'id' | 'role' | 'content' | 'state' | 'terminalStatus' | 'status'
|
||||
>
|
||||
|
||||
export type RuntimeHistoryMessage = {
|
||||
id: string
|
||||
role: ConversationMessage['role']
|
||||
content: string
|
||||
}
|
||||
|
||||
export function isCancelledMessage(
|
||||
message: Pick<ConversationMessage, 'state' | 'terminalStatus' | 'status'>
|
||||
): boolean {
|
||||
if (message.state !== 'error') return false
|
||||
if (message.terminalStatus !== undefined) return message.terminalStatus === 'cancelled'
|
||||
// Released snapshots lack terminalStatus. Only recognize exact old labels;
|
||||
// every new terminal event supplies the structured value above.
|
||||
return message.status === '请求已取消' || message.status === '已取消' ||
|
||||
message.status === '远端 Runtime 请求已取消'
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the model-visible history from stored messages. A cancelled or failed
|
||||
* reply stays in place with its partial text and an explicit marker, so its
|
||||
* user message is never left unanswered and merged into the next turn.
|
||||
*/
|
||||
export function buildRuntimeHistory(
|
||||
messages: readonly HistorySourceMessage[]
|
||||
): RuntimeHistoryMessage[] {
|
||||
const history: RuntimeHistoryMessage[] = []
|
||||
for (const message of messages) {
|
||||
if (message.state === 'complete') {
|
||||
if (message.content.trim()) {
|
||||
history.push({ id: message.id, role: message.role, content: message.content })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (message.state !== 'error' || message.role !== 'assistant') continue
|
||||
// Only a reply to a retained user turn needs a marker.
|
||||
if (history.at(-1)?.role !== 'user') continue
|
||||
const marker = isCancelledMessage(message)
|
||||
? cancelledResponseMarker
|
||||
: failedResponseMarker
|
||||
history.push({
|
||||
id: message.id,
|
||||
role: 'assistant',
|
||||
content: message.content.trim()
|
||||
? `${message.content.trimEnd()}\n\n${marker}`
|
||||
: marker
|
||||
})
|
||||
}
|
||||
return history
|
||||
}
|
||||
Reference in New Issue
Block a user