fix(web): unlock chat send when orphan streaming_status remains
Composer stop/send was gated only on message status; after squad terminal, stale streaming bubbles kept 停止当前生成. Gate on live turn/task-run runtime and settle orphans on explicit stop.
This commit is contained in:
@@ -392,20 +392,20 @@ async function waitForSquadCompletion(
|
|||||||
if (verdict.action === 'reject') {
|
if (verdict.action === 'reject') {
|
||||||
fail(`squad observation rejected: ${verdict.reason}`);
|
fail(`squad observation rejected: ${verdict.reason}`);
|
||||||
}
|
}
|
||||||
|
if (verdict.action === 'accept') {
|
||||||
|
return Date.now() - startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(assistant).toHaveAttribute('data-chat-status', ANY_TERMINAL_STATUS_RE, {
|
||||||
|
timeout: timeoutMs,
|
||||||
|
});
|
||||||
|
verdict = await observe();
|
||||||
|
if (verdict.action === 'reject') {
|
||||||
|
fail(`squad observation rejected: ${verdict.reason}`);
|
||||||
|
}
|
||||||
if (verdict.action !== 'accept') {
|
if (verdict.action !== 'accept') {
|
||||||
await expect(assistant).toHaveAttribute('data-chat-status', ANY_TERMINAL_STATUS_RE, {
|
fail(`squad terminal not accepted: ${verdict.reason}`);
|
||||||
timeout: timeoutMs,
|
|
||||||
});
|
|
||||||
verdict = await observe();
|
|
||||||
if (verdict.action === 'reject') {
|
|
||||||
fail(`squad observation rejected: ${verdict.reason}`);
|
|
||||||
}
|
|
||||||
if (verdict.action !== 'accept') {
|
|
||||||
fail(`squad terminal not accepted: ${verdict.reason}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Terminal assistant status can race ahead of InputArea isStreaming; drain stop/send.
|
|
||||||
await ensureChatComposerIdle(page, Math.min(60_000, Math.max(5_000, timeoutMs)));
|
|
||||||
return Date.now() - startedAt;
|
return Date.now() - startedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,25 +521,15 @@ async function readReviewerIntakeDuty(page: Page, docId?: string): Promise<strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ensureChatComposerIdle(page: Page, timeoutMs = 60_000) {
|
async function ensureChatComposerIdle(page: Page, timeoutMs = 60_000) {
|
||||||
// Squad terminal can land while InputArea isStreaming is still true; the
|
// Product fix: orphan streaming_status no longer locks send once runtime is idle.
|
||||||
// composer then swaps "发送 (Enter)" for "停止当前生成" and blocks the next turn.
|
// Still click stop if present, then wait for the send affordance.
|
||||||
const stopBtn = page.getByRole('button', { name: '停止当前生成' });
|
const stopBtn = page.getByRole('button', { name: '停止当前生成' });
|
||||||
await expect
|
if (await stopBtn.isVisible().catch(() => false)) {
|
||||||
.poll(
|
await stopBtn.click();
|
||||||
async () => {
|
}
|
||||||
if (await stopBtn.isVisible().catch(() => false)) {
|
await expect(page.getByRole('button', { name: /发送 \(Enter\)/ })).toBeVisible({
|
||||||
await stopBtn.click();
|
timeout: timeoutMs,
|
||||||
}
|
});
|
||||||
const stopLeft = await stopBtn.count();
|
|
||||||
const streamingHint = await page
|
|
||||||
.getByText('生成中,点击停止按钮可中止当前回复')
|
|
||||||
.count();
|
|
||||||
const sendReady = await page.getByRole('button', { name: /发送 \(Enter\)/ }).count();
|
|
||||||
return stopLeft === 0 && streamingHint === 0 && sendReady > 0;
|
|
||||||
},
|
|
||||||
{ timeout: timeoutMs, intervals: [200, 500, 1000] },
|
|
||||||
)
|
|
||||||
.toBe(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendChat(page: Page, text: string) {
|
async function sendChat(page: Page, text: string) {
|
||||||
|
|||||||
+29
-9
@@ -11,6 +11,10 @@ import {
|
|||||||
} from './lib/createProjectSubmitGuard';
|
} from './lib/createProjectSubmitGuard';
|
||||||
import { mapApiMembersToProjectMembers } from './lib/projectMemberAdapter';
|
import { mapApiMembersToProjectMembers } from './lib/projectMemberAdapter';
|
||||||
import { resolveCurrentDocRole } from './lib/currentDocRole';
|
import { resolveCurrentDocRole } from './lib/currentDocRole';
|
||||||
|
import {
|
||||||
|
COMPOSER_INFLIGHT_STATUSES,
|
||||||
|
resolveChatComposerStreaming,
|
||||||
|
} from './lib/chatComposerStreaming';
|
||||||
import {
|
import {
|
||||||
decideModificationProposal,
|
decideModificationProposal,
|
||||||
} from './api/docgov';
|
} from './api/docgov';
|
||||||
@@ -113,6 +117,7 @@ import { fetchChatCategories, fetchChatDates } from './api/chatWindow';
|
|||||||
import { ChatCategoryStat, ChatDateStat, QuotedMessage } from './types/workbench';
|
import { ChatCategoryStat, ChatDateStat, QuotedMessage } from './types/workbench';
|
||||||
import { saveFile } from './data/fileStore';
|
import { saveFile } from './data/fileStore';
|
||||||
import { cancelTaskRun, getTaskRun } from './api/taskRuns';
|
import { cancelTaskRun, getTaskRun } from './api/taskRuns';
|
||||||
|
import { cancelInflightAssistantMessages } from './lib/squadCompletionObservation';
|
||||||
import { useRecoveredTaskPhases } from './hooks/useRecoveredTaskPhases';
|
import { useRecoveredTaskPhases } from './hooks/useRecoveredTaskPhases';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -1150,14 +1155,21 @@ export default function App() {
|
|||||||
setActiveQuotes((prev) => prev.filter((_, i) => i !== index));
|
setActiveQuotes((prev) => prev.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 当前会话是否存在正在等待/流式/自动重试中的助手气泡 (驱动 InputArea 停止态)
|
// 当前会话是否存在正在等待/流式/自动重试中的助手气泡 (驱动 InputArea 停止态)。
|
||||||
const isStreamingActive = useMemo(() => {
|
// 孤儿 streaming_status(任务已终态但气泡未愈合)不得继续锁住发送。
|
||||||
return currentSessionMessages.some(
|
const isStreamingActive = useMemo(
|
||||||
(m) =>
|
() =>
|
||||||
m.role === 'assistant' &&
|
resolveChatComposerStreaming(currentSessionMessages, {
|
||||||
(m.status === 'pending' || m.status === 'streaming_status' || m.status === 'auto_retrying')
|
turnLive: turnRuntime.isThinking,
|
||||||
);
|
taskRunLive: taskRunStream.isThinking || Boolean(taskRunStream.activeTaskRunId),
|
||||||
}, [currentSessionMessages]);
|
}),
|
||||||
|
[
|
||||||
|
currentSessionMessages,
|
||||||
|
turnRuntime.isThinking,
|
||||||
|
taskRunStream.isThinking,
|
||||||
|
taskRunStream.activeTaskRunId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// 处理交互卡片动作流转 (驳回门禁确认、撤回、取消与终审联动)
|
// 处理交互卡片动作流转 (驳回门禁确认、撤回、取消与终审联动)
|
||||||
const handleCardAction = useCardActions({
|
const handleCardAction = useCardActions({
|
||||||
@@ -1203,7 +1215,7 @@ export default function App() {
|
|||||||
applyControlledInvalidationPlan,
|
applyControlledInvalidationPlan,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 用户主动停止:仅中止当前 session 的活跃 turn
|
// 用户主动停止:仅中止当前 session 的活跃 turn,并结算仍挂在 inflight 的孤儿气泡。
|
||||||
const handleStopStreaming = () => {
|
const handleStopStreaming = () => {
|
||||||
turnRuntime.abortActiveSessionTurns();
|
turnRuntime.abortActiveSessionTurns();
|
||||||
taskRunStream.abortSession(activeSessionId);
|
taskRunStream.abortSession(activeSessionId);
|
||||||
@@ -1212,6 +1224,14 @@ export default function App() {
|
|||||||
void cancelTaskRun(runId).catch(() => undefined);
|
void cancelTaskRun(runId).catch(() => undefined);
|
||||||
taskRunStream.setRunId(activeSessionId, null);
|
taskRunStream.setRunId(activeSessionId, null);
|
||||||
}
|
}
|
||||||
|
const inflight = COMPOSER_INFLIGHT_STATUSES as readonly string[];
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.role === 'assistant' && m.status && inflight.includes(m.status)
|
||||||
|
? { ...m, status: 'cancelled' as const }
|
||||||
|
: m,
|
||||||
|
),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import { resolveChatComposerStreaming } from './chatComposerStreaming';
|
||||||
|
|
||||||
|
describe('resolveChatComposerStreaming', () => {
|
||||||
|
it('is false when no inflight assistant bubbles', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveChatComposerStreaming(
|
||||||
|
[
|
||||||
|
{ role: 'user', status: 'completed' },
|
||||||
|
{ role: 'assistant', status: 'completed' },
|
||||||
|
],
|
||||||
|
{ turnLive: true, taskRunLive: true },
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true when inflight bubble and live turn', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveChatComposerStreaming(
|
||||||
|
[{ role: 'assistant', status: 'streaming_status' }],
|
||||||
|
{ turnLive: true, taskRunLive: false },
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true when inflight bubble and live task run', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveChatComposerStreaming(
|
||||||
|
[{ role: 'assistant', status: 'pending' }],
|
||||||
|
{ turnLive: false, taskRunLive: true },
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false for orphan streaming_status after runtime cleared', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveChatComposerStreaming(
|
||||||
|
[
|
||||||
|
{ role: 'assistant', status: 'streaming_status' },
|
||||||
|
{ role: 'user', status: 'completed' },
|
||||||
|
{ role: 'assistant', status: 'completed' },
|
||||||
|
],
|
||||||
|
{ turnLive: false, taskRunLive: false },
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/** Message statuses that historically locked the chat composer send button. */
|
||||||
|
export const COMPOSER_INFLIGHT_STATUSES = [
|
||||||
|
'pending',
|
||||||
|
'streaming_status',
|
||||||
|
'auto_retrying',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ComposerInflightStatus = (typeof COMPOSER_INFLIGHT_STATUSES)[number];
|
||||||
|
|
||||||
|
export type ComposerStreamingMessage = {
|
||||||
|
role?: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComposerStreamingRuntime = {
|
||||||
|
/** True when a turn AbortController / thinking flag is live for this session. */
|
||||||
|
turnLive: boolean;
|
||||||
|
/** True when a task-run stream is live (thinking or retained run id). */
|
||||||
|
taskRunLive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether InputArea should show stop / block send.
|
||||||
|
*
|
||||||
|
* Orphan assistant bubbles can stay in streaming_status after the squad run
|
||||||
|
* already finished (live runtime cleared). Those must not keep locking send.
|
||||||
|
*/
|
||||||
|
export function resolveChatComposerStreaming(
|
||||||
|
messages: readonly ComposerStreamingMessage[],
|
||||||
|
runtime: ComposerStreamingRuntime,
|
||||||
|
): boolean {
|
||||||
|
const hasInflightBubble = messages.some(
|
||||||
|
(m) =>
|
||||||
|
m.role === 'assistant' &&
|
||||||
|
typeof m.status === 'string' &&
|
||||||
|
(COMPOSER_INFLIGHT_STATUSES as readonly string[]).includes(m.status),
|
||||||
|
);
|
||||||
|
if (!hasInflightBubble) return false;
|
||||||
|
return runtime.turnLive || runtime.taskRunLive;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user