fix: keep consecutive requests running and clear finished stop state
Wait for OpenCode session cancellation before reusing a conversation, and replace the session after unconfirmed cancellation. Preserve terminal message state when stale streaming snapshots arrive so finished conversations return to Send. Validated with full production Electron UI and the configured real text model: queued requests complete, cancellation followed by immediate resend completes, and Stop clears. Focused tests: 362 passed; typecheck and targeted lint passed. Live dispatch counting limitations are recorded in the feature evidence. Release note: Consecutive OpenCode requests no longer inherit a previous cancellation, and finished conversations no longer retain the Stop button after stale updates.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"date": "2026-09-17",
|
||||
"status": "passed-with-counting-limitation",
|
||||
"sourceBase": "3639566e1b52296c6a9bc409bcfcecb86c905e30",
|
||||
"source": "Working tree including consecutive-request cancellation and terminal snapshot fixes",
|
||||
"environment": {
|
||||
"platform": "Windows x64",
|
||||
"runtime": "OpenCode",
|
||||
"mode": "ask",
|
||||
"model": "Existing configured default text model",
|
||||
"upstreamProtocol": "http",
|
||||
"fullProductionApp": true,
|
||||
"isolatedProfile": true,
|
||||
"nativeInput": true
|
||||
},
|
||||
"checks": {
|
||||
"twoQueuedRequestsCompleteAndPersist": true,
|
||||
"cancelAfterExternalDispatch": true,
|
||||
"cancelToResendMs": 25,
|
||||
"resendCompletesAndPersists": true,
|
||||
"cancellationPersists": true,
|
||||
"stopAbsentAfterBothScenarios": true,
|
||||
"sendEnabledWithUnsentProbe": true,
|
||||
"terminalScreenshotsReviewed": true
|
||||
},
|
||||
"requests": {
|
||||
"instrumentedExternalDispatches": 4,
|
||||
"http200": 3,
|
||||
"cancelledBeforeResponseHeaders": 1,
|
||||
"observedRetries": 0,
|
||||
"toolExecutions": 0,
|
||||
"initialUninstrumentedCompletedGenerations": 2,
|
||||
"totalUiSubmissions": 6,
|
||||
"totalExternalDispatches": null
|
||||
},
|
||||
"limitations": [
|
||||
"Initial Main counting hook missed child-process HTTP traffic; total dispatches across attempts are unknown.",
|
||||
"Successful rerun used a transparent Main relay to the unchanged configured HTTP upstream for dispatch counting.",
|
||||
"Seven tool definitions remained exposed by production Ask, with zero observed tool executions.",
|
||||
"Upstream proxy retries and cancelled request outcome at the upstream are not observable.",
|
||||
"No deterministic stale-event ordering, remote Host, or restart verification in this live run."
|
||||
],
|
||||
"validation": {
|
||||
"focusedTestsPassed": 362,
|
||||
"typecheck": "passed",
|
||||
"productionBundle": "passed"
|
||||
},
|
||||
"cleanup": {
|
||||
"ownedProcessesStopped": true,
|
||||
"isolatedProfileRemoved": true,
|
||||
"temporaryBuildRemoved": true,
|
||||
"sensitiveCopiesRemoved": true,
|
||||
"originalSettingsUnchanged": true,
|
||||
"targetSourceHashesUnchanged": true
|
||||
},
|
||||
"localArtifacts": "consecutive-ui-0917/{final-evidence.json,scenarios.json,report.json,normal-terminal.png,cancel-resend-terminal.png} under the approved temporary directory"
|
||||
}
|
||||
@@ -1,5 +1,47 @@
|
||||
# 工作栏实现与验证进度
|
||||
|
||||
## 2026-09-17 连续请求取消与停止状态
|
||||
|
||||
- 复现并修复 OpenCode 迟到的会话取消中断下一条请求:同一次运行复用一个有界 abort,
|
||||
等待取消结束后释放会话锁;取消失败时移除对应绑定。规则见
|
||||
[本机 OpenCode](./runtime-process-reuse-technical-design.md#4-本机-opencode)。
|
||||
- 复现并修复旧 streaming 快照覆盖本地终态后停止按钮残留。完成、取消两种回归在
|
||||
修复前失败,修复后通过;连续两条消息结束后按钮恢复。规则见
|
||||
[消息底部请求状态](./runtime-interactions.md#消息底部请求状态)。
|
||||
- App 257 项测试通过;最终 OpenCode Runtime、权限与真实 lifecycle 105 项通过。
|
||||
lifecycle 使用真实 OpenCode 二进制与 SDK、本地模型 fixture,验证延迟取消后同会话
|
||||
下一条正常完成及 SSE 回收。该轮外部真实模型调用 0 次;后续完整 App 实测见下文。
|
||||
- `npm test`:4454 项通过、67 项跳过、2 项失败;失败均为 heartbeat 迁移的
|
||||
`duplicate column name: pinned`。`npm run typecheck`、修改文件定向 ESLint、
|
||||
`git diff --check` 通过;全库 lint 因范围外 `docs/features/story-graph/demo.js`
|
||||
的 22 项 `document` 未定义错误失败。
|
||||
- 托管 SSH 经 `ManagedRemoteAcpRuntime` / `AgentOwnedAcpPrompt` 使用独立 ACP
|
||||
取消流程,不复用此 HTTP abort 实现;本次未改 Agent 或远程协议,未做共享 Host 实测。
|
||||
|
||||
### Real-model follow-up
|
||||
|
||||
Current-source production Main, Preload, and Renderer were built into dedicated
|
||||
temporary output and launched in an isolated Electron profile on Windows. Native
|
||||
input submitted two queued OpenCode Ask requests, then cancelled another request
|
||||
after external HTTP dispatch and clicked resend within 25 ms. Both queued answers
|
||||
and the resend completed and were persisted. The cancellation was also persisted.
|
||||
After each scenario, the real DOM had no Stop button; Send was enabled after
|
||||
entering an unsent readiness probe. Both terminal screenshots were reviewed.
|
||||
|
||||
The instrumented run dispatched exactly four external text-model HTTP requests:
|
||||
three returned HTTP 200 and one was cancelled before response headers. There were
|
||||
no observed retries or tool executions. A transparent Main relay forwarded to the
|
||||
unchanged configured HTTP upstream to count child-process requests; model answers
|
||||
and events were not simulated. An initial driver attempt completed two additional
|
||||
real generations before its counting hook was found not to cover child traffic.
|
||||
The total external dispatch count across both attempts is therefore unknown.
|
||||
|
||||
This verifies the observed full-App flows, not deterministic stale-event ordering,
|
||||
remote Hosts, or restart behavior. Isolated credentials, profile, build, and owned
|
||||
processes were cleaned up; original settings and target source hashes were unchanged.
|
||||
The follow-up focused suite passed all 362 tests, and `npm run typecheck` passed.
|
||||
Redacted evidence: [real-model report](./consecutive-requests-live-2026-09-17.json).
|
||||
|
||||
## 2026-09-15 Managed browser popups
|
||||
|
||||
Implemented [page-created tabs](./browser-tabs-technical-design.md#62-page-created-tabs): native
|
||||
|
||||
@@ -92,6 +92,8 @@ DeepSeek Harness 按调用 ID 保留名称、输入和摘要,结束事件省
|
||||
正文、推理或工具进展会清除旧状态;子任务更新不覆盖父请求的重试提示。
|
||||
- 完成、失败、取消后保留真实终态,迟到的状态事件不能把请求恢复成处理中。底部状态点
|
||||
为静态,不凭请求尚未结束暗示仍有进展;会话列表闪点和发送、停止按钮保持原有行为。
|
||||
持久化快照中的同一条消息仍为生成中时,也不能覆盖本地已收到的终态,即使快照的
|
||||
会话更新时间更晚;否则连续发送或刷新会让已结束的请求重新显示停止按钮。
|
||||
- Continue、DeepSeek Harness 和远端 ACP 保留其已接入的处理与工具事件,不伪造未接入
|
||||
的原生重试状态。直连模型的重试等待与请求开始文案见
|
||||
[直连模型 UI 设计](../direct-model-agent/ui-design.md#6-错误与通知);本次不改变任何
|
||||
|
||||
@@ -159,6 +159,10 @@ SelectedRuntimeManager
|
||||
|
||||
会话绑定记录规范化绝对目录及原生 sessionId。同一 conversation 的请求继续串行,
|
||||
不同 conversation 并行,不引入覆盖整个 Server 的 Prompt 队列。
|
||||
取消或失败时,每次请求只发送一次会话级 abort,并在该调用结束后才释放会话锁,避免
|
||||
迟到的 abort 中断下一条请求。abort 使用既有控制请求超时;失败或超时后移除对应绑定,
|
||||
下一条请求创建新 Session。这也适用于配置外部 OpenCode HTTP Server 的路径;托管 SSH
|
||||
使用独立的 ACP 取消路径。
|
||||
现有 `snapshot: false` 必须保留。
|
||||
|
||||
`mcpMutationTail` 目前属于 Runtime 对象;共享后同一原生目录的变更必须由同一 owner
|
||||
|
||||
@@ -49,6 +49,9 @@ it.skipIf(!existsSync(binaryPath))(
|
||||
const gateway = new KnowledgeMcpGateway({} as never)
|
||||
await gateway.start()
|
||||
let openEventBodies = 0
|
||||
let delayedAbort: Promise<void> | undefined
|
||||
let abortStarted: (() => void) | undefined
|
||||
let promptCount = 0
|
||||
const runtime = new OpenCodeRuntime({
|
||||
embedded: true, binaryPath: '', bundledBinaryPath: binaryPath,
|
||||
configPath: '', defaultWorkspace: workspace, knowledgeGateway: gateway,
|
||||
@@ -61,8 +64,14 @@ it.skipIf(!existsSync(binaryPath))(
|
||||
createClient: (options) => createOpencodeClient({
|
||||
...options,
|
||||
fetch: async (request, init) => {
|
||||
const response = await fetch(request, init)
|
||||
const url = request instanceof Request ? request.url : request
|
||||
const pathname = new URL(url).pathname
|
||||
if (pathname.endsWith('/abort') && delayedAbort) {
|
||||
abortStarted?.()
|
||||
await delayedAbort
|
||||
}
|
||||
if (pathname.endsWith('/prompt_async')) promptCount++
|
||||
const response = await fetch(request, init)
|
||||
if (new URL(url).pathname !== '/event' || !response.body) return response
|
||||
openEventBodies++
|
||||
const reader = response.body.getReader()
|
||||
@@ -130,6 +139,30 @@ it.skipIf(!existsSync(binaryPath))(
|
||||
expect(run()).resolves.toBe('LIFECYCLE_OK')
|
||||
])
|
||||
await expect.poll(() => openEventBodies).toBe(0)
|
||||
let releaseAbort!: () => void
|
||||
delayedAbort = new Promise<void>((resolve) => { releaseAbort = resolve })
|
||||
const abortPending = new Promise<void>((resolve) => { abortStarted = resolve })
|
||||
const reusedConversation = crypto.randomUUID()
|
||||
cancelledController = new AbortController()
|
||||
const first = run(cancelledController, true, reusedConversation).catch((error: unknown) => error)
|
||||
let second: Promise<string> | undefined
|
||||
try {
|
||||
await abortPending
|
||||
const beforeResend = promptCount
|
||||
second = run(new AbortController(), false, reusedConversation)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
const beforeAbortCompletes = promptCount
|
||||
releaseAbort()
|
||||
expect(await first).toMatchObject({ name: 'AbortError' })
|
||||
await expect(second).resolves.toBe('LIFECYCLE_OK')
|
||||
expect(beforeAbortCompletes).toBe(beforeResend)
|
||||
expect(promptCount).toBe(beforeResend + 1)
|
||||
await expect.poll(() => openEventBodies).toBe(0)
|
||||
} finally {
|
||||
releaseAbort()
|
||||
await Promise.allSettled([first, second])
|
||||
delayedAbort = undefined
|
||||
}
|
||||
const conversationId = crypto.randomUUID()
|
||||
await expect(run(new AbortController(), false, conversationId)).resolves.toBe('LIFECYCLE_OK')
|
||||
await expect(runtime.compactConversation({
|
||||
|
||||
@@ -1924,6 +1924,137 @@ describe("OpenCodeRuntime embedded launcher", () => {
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
it.each(["cancel", "stream-error"])("waits for delayed session abort before reusing a conversation after %s", async (outcome) => {
|
||||
const setup = runClient([]);
|
||||
const controller = new AbortController();
|
||||
let releaseAbort!: () => void;
|
||||
const abortGate = new Promise<void>((resolve) => { releaseAbort = resolve; });
|
||||
let endFirst!: () => void;
|
||||
const firstGate = new Promise<void>((resolve) => { endFirst = resolve; });
|
||||
let secondActive = false;
|
||||
let secondInterrupted = false;
|
||||
let subscriptions = 0;
|
||||
vi.mocked(setup.session.abort).mockImplementation(async () => {
|
||||
await abortGate;
|
||||
if (secondActive) secondInterrupted = true;
|
||||
return { data: true, error: undefined } as never;
|
||||
});
|
||||
vi.mocked(setup.event.subscribe).mockImplementation(async () => {
|
||||
const first = ++subscriptions === 1;
|
||||
return {
|
||||
stream: (async function* () {
|
||||
if (first) {
|
||||
await firstGate;
|
||||
if (outcome === "stream-error") throw new Error("stream failed");
|
||||
return;
|
||||
}
|
||||
secondActive = true;
|
||||
await abortGate;
|
||||
// Let every pending abort reach the server before B completes.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
if (secondInterrupted) throw new Error("B interrupted by A's late abort");
|
||||
yield { type: "session.idle", properties: { sessionID: "session-1" } };
|
||||
secondActive = false;
|
||||
})(),
|
||||
} as never;
|
||||
});
|
||||
const runtime = embeddedRuntime(setup.client, {
|
||||
embedded: false, baseUrl: "http://127.0.0.1:4096",
|
||||
});
|
||||
const collect = async (requestId: string, signal: AbortSignal) => {
|
||||
const events: RuntimeEvent[] = [];
|
||||
for await (const event of runtime.run({
|
||||
requestId, conversationId: "shared-conversation", prompt: requestId,
|
||||
workMode: "execute",
|
||||
}, signal)) events.push(event);
|
||||
return events;
|
||||
};
|
||||
const first = collect("A", controller.signal).catch((error: unknown) => error);
|
||||
let second: Promise<unknown> | undefined;
|
||||
try {
|
||||
await vi.waitFor(() => expect(setup.session.promptAsync).toHaveBeenCalledTimes(1));
|
||||
if (outcome === "cancel") controller.abort(new Error("A cancelled"));
|
||||
endFirst();
|
||||
second = collect("B", new AbortController().signal).catch((error: unknown) => error);
|
||||
await vi.waitFor(() => expect(setup.session.abort).toHaveBeenCalled());
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
||||
const promptsBeforeAbort = vi.mocked(setup.session.promptAsync).mock.calls.length;
|
||||
releaseAbort();
|
||||
expect(await first).toMatchObject({ message: outcome === "cancel" ? "A cancelled" : "stream failed" });
|
||||
expect(await second).toEqual(expect.arrayContaining([expect.objectContaining({ type: "done" })]));
|
||||
expect(promptsBeforeAbort).toBe(1);
|
||||
expect(setup.session.abort).toHaveBeenCalledOnce();
|
||||
expect(setup.session.create).toHaveBeenCalledOnce();
|
||||
expect(setup.session.promptAsync).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
endFirst();
|
||||
releaseAbort();
|
||||
await Promise.all([first, second]);
|
||||
await runtime.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["never-returns", "rejects", "error", "false"])("replaces the session after abort %s and lets the next request finish", async (outcome) => {
|
||||
const setup = runClient([]);
|
||||
const controller = new AbortController();
|
||||
let abortSignal: AbortSignal | undefined;
|
||||
let abortedAtStart: boolean | undefined;
|
||||
vi.mocked(setup.session.abort).mockImplementation(async (_input, options) => {
|
||||
abortSignal = options?.signal ?? undefined;
|
||||
abortedAtStart = abortSignal?.aborted;
|
||||
if (outcome === "never-returns") return new Promise<never>(() => undefined);
|
||||
if (outcome === "rejects") throw new Error("abort failed");
|
||||
return { data: false, error: outcome === "error" ? { message: "abort failed" } : undefined } as never;
|
||||
});
|
||||
vi.mocked(setup.session.create)
|
||||
.mockResolvedValueOnce({ data: { id: "session-1" } } as never)
|
||||
.mockResolvedValueOnce({ data: { id: "session-2" } } as never);
|
||||
let subscriptions = 0;
|
||||
vi.mocked(setup.event.subscribe).mockImplementation(async () => {
|
||||
const first = ++subscriptions === 1;
|
||||
return {
|
||||
stream: (async function* () {
|
||||
if (first) {
|
||||
controller.abort(new Error("A cancelled"));
|
||||
return;
|
||||
}
|
||||
yield { type: "session.idle", properties: { sessionID: "session-2" } };
|
||||
})(),
|
||||
} as never;
|
||||
});
|
||||
const runtime = embeddedRuntime(setup.client, {
|
||||
embedded: false, baseUrl: "http://127.0.0.1:4096",
|
||||
}, { controlRequestTimeoutMs: 20 });
|
||||
const collect = async (requestId: string, signal: AbortSignal) => {
|
||||
const events: RuntimeEvent[] = [];
|
||||
for await (const event of runtime.run({
|
||||
requestId, conversationId: "shared-conversation", prompt: requestId,
|
||||
workMode: "execute",
|
||||
}, signal)) events.push(event);
|
||||
return events;
|
||||
};
|
||||
try {
|
||||
let settled = false;
|
||||
const first = collect("A", controller.signal).catch((error: unknown) => error)
|
||||
.finally(() => { settled = true; });
|
||||
await vi.waitFor(() => expect(settled).toBe(true));
|
||||
expect(await first).toMatchObject({ message: "A cancelled" });
|
||||
expect(abortedAtStart).toBe(false);
|
||||
expect(abortSignal?.aborted).toBe(outcome === "never-returns");
|
||||
expect(setup.session.abort).toHaveBeenCalledOnce();
|
||||
await expect(collect("B", new AbortController().signal)).resolves.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ type: "done", sessionId: "session-2" })]),
|
||||
);
|
||||
expect(setup.session.create).toHaveBeenCalledTimes(2);
|
||||
expect(setup.session.promptAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ sessionID: "session-2" }),
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
} finally {
|
||||
await runtime.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("loads assigned Skills before prompting", async () => {
|
||||
const child = fakeChild();
|
||||
const promptAsync = vi.fn().mockResolvedValue({ error: undefined });
|
||||
@@ -4046,10 +4177,10 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
await expect(collectRun(runtime, "execute")).rejects.toThrow(
|
||||
"OpenCode 权限回复失败",
|
||||
);
|
||||
expect(session.abort).toHaveBeenCalledWith({
|
||||
sessionID: "session-1",
|
||||
directory: process.cwd(),
|
||||
});
|
||||
expect(session.abort).toHaveBeenCalledWith(
|
||||
{ sessionID: "session-1", directory: process.cwd() },
|
||||
{ signal: expect.any(AbortSignal) },
|
||||
);
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
|
||||
@@ -2378,13 +2378,26 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
AbortSignal.any([signal, subscriptionController.signal]),
|
||||
);
|
||||
|
||||
let sessionAbort: Promise<void> | undefined;
|
||||
const abortSession = (): void => {
|
||||
void client.session
|
||||
.abort({
|
||||
sessionID: sessionId,
|
||||
directory,
|
||||
sessionAbort ??= this.controlRequest(
|
||||
"取消会话",
|
||||
(controlSignal) => client.session.abort(
|
||||
{ sessionID: sessionId, directory },
|
||||
{ signal: controlSignal },
|
||||
),
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.error || response.data !== true) {
|
||||
throw new Error("OpenCode 会话取消失败");
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
.catch(() => {
|
||||
// An unconfirmed abort must never target a reused session.
|
||||
if (this.sessions.get(request.conversationId)?.id === sessionId) {
|
||||
this.sessions.delete(request.conversationId);
|
||||
}
|
||||
});
|
||||
};
|
||||
signal.addEventListener("abort", abortSession, { once: true });
|
||||
|
||||
@@ -2994,6 +3007,8 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
throw error;
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abortSession);
|
||||
// Keep the conversation locked until its session-wide abort settles.
|
||||
await sessionAbort;
|
||||
for (const questionId of reportedQuestionIds.values()) {
|
||||
this.pendingQuestions.delete(questionId);
|
||||
}
|
||||
|
||||
@@ -6053,6 +6053,11 @@ describe("App", () => {
|
||||
queueItemId: secondItem.id,
|
||||
prompt: secondItem.label,
|
||||
});
|
||||
act(() => {
|
||||
agentListener?.({ requestId: run.mock.calls[1]![0].requestId, type: "done" });
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByLabelText("停止生成")).not.toBeInTheDocument());
|
||||
expect(screen.getByLabelText("发送")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores a queued message when Agent preflight rejects it", async () => {
|
||||
@@ -7779,6 +7784,27 @@ describe("App", () => {
|
||||
],
|
||||
});
|
||||
|
||||
it.each(["complete", "error"] as const)("does not revive a locally %s message from a newer conversation's stale streaming snapshot", async (state) => {
|
||||
const started = await startStreamingRun();
|
||||
act(() => {
|
||||
agentListener?.({ requestId: started.requestId, type: "text", delta: "Retained terminal output" });
|
||||
agentListener?.(state === "complete"
|
||||
? { requestId: started.requestId, type: "done" }
|
||||
: { requestId: started.requestId, type: "error", status: "cancelled", message: "Cancelled response" });
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByLabelText("停止生成")).not.toBeInTheDocument());
|
||||
const snapshot = persistedSnapshot(started, { state: "streaming", content: "Stale partial output" });
|
||||
snapshot.updatedAt = Date.now() + 10_000;
|
||||
vi.mocked(api.conversations.list).mockResolvedValue([snapshot]);
|
||||
const callsBeforeRefresh = vi.mocked(api.conversations.list).mock.calls.length;
|
||||
await act(async () => { conversationsChangedListener?.(); });
|
||||
await waitFor(() => expect(api.conversations.list).toHaveBeenCalledTimes(callsBeforeRefresh + 1));
|
||||
expect(screen.queryByLabelText("停止生成")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Retained terminal output")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Stale partial output")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("发送")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("converges a streaming message onto the persisted completed state after reconnecting", async () => {
|
||||
const started = await startStreamingRun();
|
||||
expect(conversationsChangedListener).toBeDefined();
|
||||
|
||||
@@ -1546,6 +1546,11 @@ function mergePersistedConversations(
|
||||
...conversation.messages.map((persistedMessage) => {
|
||||
const localMessage = localMessageById.get(persistedMessage.id);
|
||||
const message = mergeMessageImageState(persistedMessage, persistedMessage, localMessage);
|
||||
// A newer conversation timestamp can still contain an older message
|
||||
// snapshot while a terminal event is being persisted.
|
||||
if (localMessage && localMessage.state !== "streaming" && message.state === "streaming") {
|
||||
return mergeMessageImageState(localMessage, persistedMessage, localMessage);
|
||||
}
|
||||
if (!conversation.activeRequest && local.activeRequest?.messageId === message.id) {
|
||||
return { ...message, pendingQuestions: undefined };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user