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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parseEnv } from 'node:util'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { ModelAgentRuntime } from '../src/main/agent/model-runtime'
|
||||
import { OpenCodeRuntime } from '../src/main/agent/opencode-runtime'
|
||||
import type { AgentRuntime, RuntimeEvent } from '../src/main/agent/runtime'
|
||||
import { buildRuntimeHistory } from '../src/shared/runtime-history'
|
||||
|
||||
// Opt-in: GB_CANCEL_LIVE_ENV=.env.deepseek.local npx vitest run tests/cancelled-turn-live.test.ts
|
||||
const envPath = process.env.GB_CANCEL_LIVE_ENV
|
||||
const opencodePath = resolve('.runtime-resources', process.arch, process.platform === 'win32' ? 'opencode.exe' : 'opencode')
|
||||
|
||||
const firstPrompt = 'List 40 European capital cities, one per line, each with a one-sentence description.'
|
||||
const secondPrompt = 'What is 17 + 25? Reply with only the number and nothing else.'
|
||||
|
||||
type Env = { apiKey: string; baseUrl: string; model: string }
|
||||
|
||||
async function loadEnv(): Promise<Env> {
|
||||
const env = parseEnv(await readFile(resolve(envPath!), 'utf8'))
|
||||
return { apiKey: env.DEEPSEEK_API_KEY!, baseUrl: env.DEEPSEEK_BASE_URL!, model: env.DEEPSEEK_MODEL! }
|
||||
}
|
||||
|
||||
/** Runs until some answer text streams, then cancels like the Stop button. */
|
||||
async function runAndCancel(runtime: AgentRuntime, conversationId: string): Promise<string> {
|
||||
const controller = new AbortController()
|
||||
let partial = ''
|
||||
try {
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(), conversationId, workMode: 'ask', prompt: firstPrompt
|
||||
}, controller.signal)) {
|
||||
if (event.type === 'text') partial += event.delta
|
||||
if (partial.length >= 60 && !controller.signal.aborted) controller.abort(new Error('用户取消了请求'))
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) throw error
|
||||
}
|
||||
expect(controller.signal.aborted).toBe(true)
|
||||
return partial
|
||||
}
|
||||
|
||||
async function runText(
|
||||
runtime: AgentRuntime,
|
||||
request: Parameters<AgentRuntime['run']>[0]
|
||||
): Promise<string> {
|
||||
let output = ''
|
||||
for await (const event of runtime.run(request, new AbortController().signal) as AsyncGenerator<RuntimeEvent>) {
|
||||
if (event.type === 'text') output += event.delta
|
||||
}
|
||||
return output.trim()
|
||||
}
|
||||
|
||||
function expectOnlyNewAnswer(output: string): void {
|
||||
console.info(`SECOND_TURN_OUTPUT=${JSON.stringify(output)}`)
|
||||
expect(output).toMatch(/\b42\b/u)
|
||||
// Merging the cancelled turn would resume the capital list.
|
||||
expect(output).not.toMatch(/Paris|Berlin|Madrid|Vienna|Rome|London/u)
|
||||
expect(output.length).toBeLessThan(40)
|
||||
}
|
||||
|
||||
describe.skipIf(!envPath)('cancelled turn handling with a real model', () => {
|
||||
let env: Env
|
||||
let workspace: string
|
||||
beforeAll(async () => {
|
||||
env = await loadEnv()
|
||||
workspace = await mkdtemp(join(tmpdir(), 'gb-cancel-live-'))
|
||||
})
|
||||
afterAll(async () => {
|
||||
await rm(workspace, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('direct model answers only the new message after a cancelled turn', async () => {
|
||||
const runtime = new ModelAgentRuntime({
|
||||
apiKey: env.apiKey, baseUrl: env.baseUrl, model: env.model,
|
||||
protocol: 'openai-chat-completions', authentication: 'api-key'
|
||||
})
|
||||
try {
|
||||
const conversationId = crypto.randomUUID()
|
||||
const partial = await runAndCancel(runtime, conversationId)
|
||||
console.info(`DIRECT_PARTIAL_CHARS=${partial.length}`)
|
||||
// Same stored shape the renderer keeps after Stop.
|
||||
const history = buildRuntimeHistory([
|
||||
{ id: crypto.randomUUID(), role: 'user', content: firstPrompt, state: 'complete' },
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: partial, state: 'error', terminalStatus: 'cancelled' }
|
||||
])
|
||||
expect(history).toHaveLength(2)
|
||||
expectOnlyNewAnswer(await runText(runtime, {
|
||||
requestId: crypto.randomUUID(), conversationId, workMode: 'ask', prompt: secondPrompt,
|
||||
history: history.map(({ role, content }) => ({ role, content })),
|
||||
historyMessageIds: history.map(({ id }) => id)
|
||||
}))
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
}, 180_000)
|
||||
|
||||
it('direct model answers only the new message when the cancelled turn had no output', async () => {
|
||||
const runtime = new ModelAgentRuntime({
|
||||
apiKey: env.apiKey, baseUrl: env.baseUrl, model: env.model,
|
||||
protocol: 'openai-chat-completions', authentication: 'api-key'
|
||||
})
|
||||
try {
|
||||
const history = buildRuntimeHistory([
|
||||
{ id: crypto.randomUUID(), role: 'user', content: firstPrompt, state: 'complete' },
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: '', state: 'error', terminalStatus: 'cancelled' }
|
||||
])
|
||||
expectOnlyNewAnswer(await runText(runtime, {
|
||||
requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), workMode: 'ask', prompt: secondPrompt,
|
||||
history: history.map(({ role, content }) => ({ role, content })),
|
||||
historyMessageIds: history.map(({ id }) => id)
|
||||
}))
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
}, 180_000)
|
||||
|
||||
it('OpenCode reused session answers only the new message after a cancelled turn', async () => {
|
||||
const runtime = new OpenCodeRuntime({
|
||||
embedded: true, binaryPath: '', bundledBinaryPath: opencodePath, configPath: '',
|
||||
defaultWorkspace: workspace,
|
||||
modelProfile: {
|
||||
id: crypto.randomUUID(), name: 'DeepSeek live', baseUrl: env.baseUrl, modelName: env.model,
|
||||
apiKey: env.apiKey, protocol: 'openai-chat-completions', authentication: 'api-key'
|
||||
}
|
||||
})
|
||||
try {
|
||||
const conversationId = crypto.randomUUID()
|
||||
const partial = await runAndCancel(runtime, conversationId)
|
||||
console.info(`OPENCODE_PARTIAL_CHARS=${partial.length}`)
|
||||
expectOnlyNewAnswer(await runText(runtime, {
|
||||
requestId: crypto.randomUUID(), conversationId, workMode: 'ask', prompt: secondPrompt
|
||||
}))
|
||||
// The cancelled request stays in the native transcript, so the model
|
||||
// still knows what was asked before.
|
||||
const recall = await runText(runtime, {
|
||||
requestId: crypto.randomUUID(), conversationId, workMode: 'ask',
|
||||
prompt: 'In one short phrase, what did I ask for in my very first message of this conversation? Do not list anything.'
|
||||
})
|
||||
console.info(`OPENCODE_RECALL=${JSON.stringify(recall)}`)
|
||||
expect(recall).toMatch(/capital/iu)
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
}, 300_000)
|
||||
})
|
||||
Reference in New Issue
Block a user