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:
@@ -23,6 +23,7 @@ import {
|
||||
type ApplicationSettings,
|
||||
} from "../../shared/application-settings-contracts";
|
||||
import type { GlobalShortcutSettingsSnapshot } from "../../shared/shortcut";
|
||||
import { cancelledResponseMarker } from "../../shared/runtime-history";
|
||||
import type { TerminalSnapshot } from "../../shared/terminal-contracts";
|
||||
import type { RuntimeNativeClientApi } from "./RuntimeNativeClientActions";
|
||||
import { loadBrandingPreferences, saveBrandingPreferences } from "./branding";
|
||||
@@ -6041,6 +6042,32 @@ describe("App", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends a cancelled turn as interrupted instead of an unanswered user message", async () => {
|
||||
render(<App />);
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
target: { value: "First question" },
|
||||
});
|
||||
fireEvent.click(await screen.findByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const requestId = run.mock.calls[0]![0].requestId;
|
||||
act(() => {
|
||||
agentListener?.({ requestId, type: "text", delta: "Partial reply" });
|
||||
agentListener?.({ requestId, type: "error", status: "cancelled", message: "请求已取消" });
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
target: { value: "New question" },
|
||||
});
|
||||
await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled());
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledTimes(2));
|
||||
const history = run.mock.calls[1]?.[0].history ?? [];
|
||||
expect(history.slice(-2)).toEqual([
|
||||
{ role: "user", content: "First question" },
|
||||
{ role: "assistant", content: `Partial reply\n\n${cancelledResponseMarker}` },
|
||||
]);
|
||||
expect(run.mock.calls[1]?.[0].historyMessageIds).toHaveLength(history.length);
|
||||
});
|
||||
|
||||
it("keeps a tool failure in details and hides retry after continuing", async () => {
|
||||
render(<App />);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { PendingDocumentImports } from './PendingDocumentImports';
|
||||
import { AttachmentCapabilityNotice } from './AttachmentCapabilityNotice';
|
||||
import { DocumentConversationContext } from './DocumentConversationContext';
|
||||
import { maximumAttachmentsPerMessage } from '../../shared/attachment-limits';
|
||||
import { buildRuntimeHistory } from '../../shared/runtime-history';
|
||||
import {
|
||||
ApplicationAvailability,
|
||||
ApplicationCenter,
|
||||
@@ -7091,9 +7092,7 @@ function App(): React.JSX.Element {
|
||||
8,
|
||||
);
|
||||
const historySnapshot = conversationSnapshot.messages;
|
||||
const retainedHistorySnapshot = historySnapshot.filter(
|
||||
(message) => message.state === "complete" && message.content.trim(),
|
||||
);
|
||||
const retainedHistorySnapshot = buildRuntimeHistory(historySnapshot);
|
||||
const projectIdSnapshot = queuedInput
|
||||
? queuedInput.projectId
|
||||
: activeProjectId || undefined;
|
||||
@@ -7435,9 +7434,7 @@ function App(): React.JSX.Element {
|
||||
notify({ tone: "error", message: t("notices.remoteConversationRefreshFailed") });
|
||||
return;
|
||||
}
|
||||
const history = completeConversation.messages.filter(
|
||||
(message) => message.state === "complete" && message.content.trim(),
|
||||
);
|
||||
const history = buildRuntimeHistory(completeConversation.messages);
|
||||
if (history.length < 2) {
|
||||
notify({
|
||||
tone: "info",
|
||||
|
||||
@@ -1,11 +1 @@
|
||||
import type { ConversationMessage } from '../../shared/assistant-contracts'
|
||||
|
||||
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 请求已取消'
|
||||
}
|
||||
export { isCancelledMessage } from '../../shared/runtime-history'
|
||||
|
||||
Reference in New Issue
Block a user