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:
@@ -4188,6 +4188,53 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
it("keeps a cancelled prompt and marks the next reused-session turn as following an interruption", async () => {
|
||||
const setup = runClient([]);
|
||||
const controller = new AbortController();
|
||||
let subscriptions = 0;
|
||||
vi.mocked(setup.event.subscribe).mockImplementation(async (_input, init) => {
|
||||
const first = ++subscriptions === 1;
|
||||
const signal = (init as { signal: AbortSignal }).signal;
|
||||
return {
|
||||
stream: (async function* () {
|
||||
if (first) {
|
||||
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
||||
return;
|
||||
}
|
||||
yield { type: "session.idle", properties: { sessionID: "session-1" } };
|
||||
})(),
|
||||
} as never;
|
||||
});
|
||||
const runtime = embeddedRuntime(setup.client);
|
||||
const collect = async (prompt: string, signal: AbortSignal) => {
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(), conversationId: "conversation-1", prompt, workMode: "ask",
|
||||
}, signal)) void event;
|
||||
};
|
||||
try {
|
||||
const first = collect("first question", controller.signal).catch((error: unknown) => error);
|
||||
await vi.waitFor(() => expect(setup.session.promptAsync).toHaveBeenCalledTimes(1));
|
||||
controller.abort(new Error("cancelled"));
|
||||
await expect(first).resolves.toMatchObject({ message: "cancelled" });
|
||||
expect(setup.session.deleteMessage).not.toHaveBeenCalled();
|
||||
|
||||
await collect("new question", new AbortController().signal);
|
||||
await collect("third question", new AbortController().signal);
|
||||
|
||||
const parts = vi.mocked(setup.session.promptAsync).mock.calls.map(
|
||||
([input]) => (input as { parts: { text?: string; synthetic?: boolean }[] }).parts,
|
||||
);
|
||||
expect(parts[1]).toEqual([
|
||||
expect.objectContaining({ synthetic: true, text: expect.stringContaining("interrupted the previous turn") }),
|
||||
{ type: "text", text: "new question" },
|
||||
]);
|
||||
expect(parts[2]).toEqual([{ type: "text", text: "third question" }]);
|
||||
expect(setup.session.create).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
await runtime.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes an ephemeral OpenCode session when released", async () => {
|
||||
const { client, session } = runClient([
|
||||
{
|
||||
|
||||
@@ -72,6 +72,8 @@ import {
|
||||
} from "./opencode-subagent";
|
||||
import { promptWithUntrustedConversationHistory } from "./runtime-conversation-history";
|
||||
|
||||
const opencodeInterruptionNotice =
|
||||
"[The user interrupted the previous turn before it finished. Its request may be incomplete; answer the new message below and do not resume or re-answer the interrupted request unless asked.]";
|
||||
const STARTUP_TIMEOUT_MS = 30_000;
|
||||
const STARTUP_POLL_INTERVAL_MS = 100;
|
||||
const STARTUP_PROBE_TIMEOUT_MS = 500;
|
||||
@@ -732,7 +734,11 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
private clientInitialization?: Promise<OpencodeClient>;
|
||||
private server?: OpenCodeServer;
|
||||
private startingChild?: SpawnedProcess;
|
||||
private readonly sessions = new Map<string, { id: string; directory: string }>();
|
||||
// `interrupted` marks a reused session whose last turn the user cancelled.
|
||||
private readonly sessions = new Map<
|
||||
string,
|
||||
{ id: string; directory: string; interrupted?: boolean }
|
||||
>();
|
||||
private initializationController?: AbortController;
|
||||
private initializationWaiters = 0;
|
||||
private readonly sessionInitializations = new Map<string, Promise<string>>();
|
||||
@@ -2461,6 +2467,14 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
request,
|
||||
session.created,
|
||||
);
|
||||
const sessionState = this.sessions.get(request.conversationId);
|
||||
// The native transcript keeps a cancelled turn; tell the model it was
|
||||
// interrupted so it answers the new message instead of merging both.
|
||||
const interruptionNotice =
|
||||
!session.created && sessionState?.id === sessionId && sessionState.interrupted
|
||||
? opencodeInterruptionNotice
|
||||
: undefined;
|
||||
if (sessionState?.id === sessionId) sessionState.interrupted = false;
|
||||
const imageParts = (request.images ?? []).map((image) => ({
|
||||
type: "file" as const,
|
||||
mime: image.mediaType,
|
||||
@@ -2515,6 +2529,15 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
.join("\n\n") || undefined,
|
||||
...(toolOverrides ? { tools: toolOverrides } : {}),
|
||||
parts: [
|
||||
...(interruptionNotice
|
||||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: interruptionNotice,
|
||||
synthetic: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ type: "text" as const, text: promptText },
|
||||
...imageParts,
|
||||
],
|
||||
@@ -3065,8 +3088,14 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
signal.removeEventListener("abort", abortSession);
|
||||
// Keep the conversation locked until its session-wide abort settles.
|
||||
await sessionAbort;
|
||||
// A reused session would otherwise merge this unanswered prompt into the next turn.
|
||||
if (
|
||||
const currentSession = this.sessions.get(request.conversationId);
|
||||
if (runFailed && signal.aborted && currentSession?.id === sessionId) {
|
||||
// Keep the cancelled turn visible to the model, as it is to the user;
|
||||
// the next prompt carries an interruption notice instead.
|
||||
currentSession.interrupted = true;
|
||||
} else if (
|
||||
// A failed prompt is dropped from a reused session so it is not
|
||||
// merged into the next turn.
|
||||
runFailed &&
|
||||
!selectedCommand &&
|
||||
checklistMessageIds.size === 0 &&
|
||||
|
||||
+2
-5
@@ -1,6 +1,7 @@
|
||||
import { registerKnowledgeIpcHandlers } from './knowledge/knowledge-ipc'
|
||||
import { registerModelSettingsIpcHandlers } from './model-settings-ipc'
|
||||
import { knowledgeReferenceKey, toKnowledgeReference } from '../shared/knowledge-reference'
|
||||
import { buildRuntimeHistory } from '../shared/runtime-history'
|
||||
import { localInferenceService } from './local-inference-service'
|
||||
import { inferenceActionSchema, inferenceCancelSchema } from '../shared/local-inference-contracts'
|
||||
import type { NativeClientCoordinator } from './agent/native-client-coordinator'
|
||||
@@ -4795,11 +4796,7 @@ export function registerIpcHandlers(
|
||||
) {
|
||||
throw new Error('对话 Runtime 或 Project 已更改,请刷新后重试')
|
||||
}
|
||||
const persistedHistory = conversation.messages
|
||||
.filter(
|
||||
(message) =>
|
||||
message.state === 'complete' && message.content.trim()
|
||||
)
|
||||
const persistedHistory = buildRuntimeHistory(conversation.messages)
|
||||
if (
|
||||
persistedHistory.length !== request.history.length ||
|
||||
persistedHistory.some(
|
||||
|
||||
Reference in New Issue
Block a user