From 954b275f10fd447dd9ebd7a2d25f1229c366bd17 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sat, 12 Sep 2026 12:50:01 +0800 Subject: [PATCH] fix(coding): restore sending after queued messages settle --- .../20260912-chat-send-diagnosis-b6580605.md | 50 +++++++++++++++ src/pages/Chat/CodingChatPanel.tsx | 6 +- src/stores/coding-conversations.ts | 13 ++-- tests/e2e/pi-coding-first-chat.spec.ts | 18 +++++- tests/unit/coding-chat-panel.test.tsx | 61 +++++++++++++++++++ .../unit/coding-conversations-store.test.tsx | 22 +++++++ 6 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260912-chat-send-diagnosis-b6580605.md diff --git a/.project-docs/30-worklog/tasks/20260912-chat-send-diagnosis-b6580605.md b/.project-docs/30-worklog/tasks/20260912-chat-send-diagnosis-b6580605.md new file mode 100644 index 0000000..a9bd556 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260912-chat-send-diagnosis-b6580605.md @@ -0,0 +1,50 @@ +# Task: Diagnose unavailable model and stuck queued chat message + +## Identity + +- Task ID: 20260912-chat-send-diagnosis-b6580605 +- Mode: Feature +- Branch: codex/20260912-chat-send-diagnosis-b6580605-chat-send-diagnosis +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260912-chat-send-diagnosis-b6580605 +- Base commit: 2cedc9df300ae0fb4592d078c948cc1f37b8c305 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Diagnose the reported disabled Code send button and lingering accepted-message notice after a queued message. Compare current main and the installed Windows 1.4.3 Renderer with the behavior described in the user's previous repair screenshot. +- Fix only effective Composer prompt mode and successful queued-request bookkeeping; add focused component/store regressions and extend the existing Electron feature flow. + +## Intent And Constraints + +- Concurrent Task Gate and Planning Gate passed. This worktree is exclusively owned; current main and all 180 retained peer worktrees stay read-only. Historical placeholder peer scopes remain unknown coordination state, with no identified semantic dependency on this bounded repair. +- Apply maintain-project-docs and diagnosing-bugs; the user prohibits subagents, so work is serial. Keep Main-owned Pi 0.84.2, authoritative Snapshot/Patch queue state, project isolation, accepted/uncertain non-replay, credentials and user data unchanged. +- Initial title used an unverified model-unavailable interpretation. Source and installed Renderer inspection corrected it: the screenshot says 不可调, which describes thinking-level options and does not gate canSend. +- README already documents supported same-Conversation queueing and the 不可调 label. This fix restores that behavior without changing product architecture or its documented contract. + +## Outcome + +- Reproduced the exact disabled-send state with follow-up/steer followed by completed/aborted idle snapshots. The saved mode remained non-prompt while the idle Composer required prompt; its mode selector was hidden, leaving no way to change it in the idle UI. +- Independently reproduced accepted follow-up/steer records surviving a terminal Snapshot followed by a late successful acknowledgement. These requests have no optimistic message node, while request reconciliation waits for clientRequestId-bearing messages; Pi queue projection may assign its own identity. +- Effective prompt mode now follows the current authoritative run state: idle sends use prompt. Successful follow-up/steer acknowledgements remove only their local request records; the authoritative queue remains intact. Prompt optimistic-node reconciliation and uncertain requests retain existing behavior. +- Read-only installed-artifact evidence: executable D:/Tools/泥土/niancode/Makelore/Makelore.exe reports 1.4.3.0. Its resources/app.asar contains dist/assets/index-Br3sYwTv.js with the old running-vs-stored-mode canSend condition and the accepted-count banner. Current main at the recorded base has the same defects. The repair described in the supplied screenshot is absent from these inspected versions; the location or disposition of that earlier patch is unverified. +- No main merge, installed-client replacement, package build, external Provider call, push, deployment or user-message replay was performed. The fix is a local source handoff and must not be described as installed or released. + +## Verification + +- Pinned pnpm 10.33.4; pnpm install --frozen-lockfile passed. +- Red-capable loop: pnpm exec vitest run tests/unit/coding-chat-panel.test.tsx tests/unit/coding-conversations-store.test.tsx -t 'sends again after|acceptance even when settlement' --reporter=dot. Before production edits: 6 failed (4 disabled-button assertions, 2 residual accepted-request assertions); after fix: 6 passed. +- Full affected component/store files: 57 tests passed. Existing rejection recovery, attachment handling, optimistic identity, reconnect and uncertainty coverage passed together with the new regressions. +- pnpm run typecheck passed. Scoped ESLint for all five changed source/test files passed. +- pnpm run build:vite passed for Renderer, Main, Preload and utility targets; existing chunk-size/dynamic-import/Browserslist warnings remain. +- pnpm exec playwright test tests/e2e/pi-coding-first-chat.spec.ts --grep 'PI feature UI' --reporter=list passed (1/1). The Electron flow now actually posts follow-up, checks that the queue stays visible without a residual accepted notice, aborts, then submits 继续 via Enter as prompt. +- Full-repository unit suite and real Provider execution were not run; validation targets the changed Renderer/store behavior and existing Electron fixture. +- Task documentation drift and final whitespace checks are recorded at handoff. + +## Follow-ups + +- Integrate this committed source fix and include it in a verified replacement installer before claiming the user's installed application is fixed. Preserve the current live session; no automatic retries or reset of user data are needed. + +## Promotion Candidates + +- None. This repair restores the existing Code queue contract and does not change canonical architecture or product decisions. Source-test success and installed-artifact delivery must remain distinct in the final handoff. diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index ce3533e..7dff2cf 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -199,7 +199,6 @@ export function CodingChatPanel({ await abortCodingConversation(conversationId); await loadConversationSnapshot(conversationId, 'silent'); }, [loadConversationSnapshot]); - const promptMode = draftKey ? modesByDraftKey[draftKey] ?? 'prompt' : 'prompt'; const provisionalDraft = provisionalDraftKey ? provisionalDrafts[provisionalDraftKey] ?? '' : ''; const submissionError = draftKey ? submissionErrors[draftKey] ?? null : null; const localAttachments = useMemo(() => ( @@ -225,6 +224,9 @@ export function CodingChatPanel({ : null ), [targetConversationId]); const snapshot = useCodingConversationStore(selectSnapshot); + const runStatus = snapshot?.run.status ?? 'idle'; + const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus); + const promptMode = running && draftKey ? modesByDraftKey[draftKey] ?? 'prompt' : 'prompt'; const entryLoadState = useCodingConversationStore((state) => ( targetConversationId ? state.entriesByConversationId[targetConversationId]?.loadState ?? 'empty' @@ -576,7 +578,6 @@ export function CodingChatPanel({ submitPrompt, ]); - const runStatus = snapshot?.run.status ?? 'idle'; const workerStatus = snapshot?.worker.status ?? 'stopped'; const runtimeError = snapshot?.run.error ?? snapshot?.worker.error ?? null; const preparationError = entryError ?? runtimeError?.message ?? null; @@ -596,7 +597,6 @@ export function CodingChatPanel({ || workerStatus === 'recovering' || runStatus === 'preparing'; const recovering = entryLoadState === 'recovering'; - const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus); const modeMatchesRunState = running ? promptMode !== 'prompt' : promptMode === 'prompt'; const pendingInteractionCount = snapshot?.pendingInteractions.filter((interaction) => ( interaction.status === 'pending' diff --git a/src/stores/coding-conversations.ts b/src/stores/coding-conversations.ts index 55b56e5..8f3301a 100644 --- a/src/stores/coding-conversations.ts +++ b/src/stores/coding-conversations.ts @@ -832,13 +832,18 @@ export function createCodingConversationStore( const requests = state.requestsByConversationId[input.conversationId] ?? {}; const current = requests[clientRequestId]; if (!current) return state; + const nextRequests = { ...requests }; + // Queued messages are projected by the authoritative queue; only a + // prompt has an optimistic node awaiting clientRequestId reconciliation. + if (current.mode === 'prompt') { + nextRequests[clientRequestId] = { ...current, status: 'accepted' }; + } else { + delete nextRequests[clientRequestId]; + } return { requestsByConversationId: { ...state.requestsByConversationId, - [input.conversationId]: { - ...requests, - [clientRequestId]: { ...current, status: 'accepted' }, - }, + [input.conversationId]: nextRequests, }, }; }); diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts index 5537780..bf45922 100644 --- a/tests/e2e/pi-coding-first-chat.spec.ts +++ b/tests/e2e/pi-coding-first-chat.spec.ts @@ -665,7 +665,7 @@ async function installCodingFirstChatHost( conversationId: conversation.id, clientRequestId: body?.clientRequestId, runId: 'run-e2e-1', - mode: 'prompt', + mode: body?.mode ?? 'prompt', }, }, 202); } @@ -1211,6 +1211,13 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model const mode = page.getByRole('combobox', { name: '消息发送方式' }); await mode.selectOption('follow-up'); await expect(mode).toHaveValue('follow-up'); + await page.getByRole('button', { name: '发送', exact: true }).click(); + await expect.poll(async () => (await readState(electronApp)).captured.some((request) => ( + request.path.endsWith('/prompt') && request.body?.mode === 'follow-up' + ))).toBe(true); + await expect(page.getByTestId('coding-file-attachment-input')).toBeEnabled(); + await expect(page.getByText(/条消息已被本地 Agent 接收/)).toHaveCount(0); + await expect(page.getByTestId('coding-message-queue')).toBeVisible(); const composer = page.getByTestId('coding-message-composer'); await expect(composer).not.toContainText('项目内可写'); @@ -1224,6 +1231,15 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model await expect(page.getByRole('button', { name: '中止', exact: true })).toHaveCount(0); await expect(composer.getByRole('button', { name: '中止生成' })).toHaveCount(0); await expect(runtimeSettings).toBeEnabled(); + await expect(page.getByTestId('coding-message-queue')).toHaveCount(0); + await composer.getByRole('textbox').fill('继续'); + await expect(composer.getByRole('button', { name: '发送', exact: true })).toBeEnabled(); + await composer.getByRole('textbox').press('Enter'); + await expect.poll(async () => (await readState(electronApp)).captured.some((request) => ( + request.path.endsWith('/prompt') + && request.body?.mode === 'prompt' + && request.body?.text === '继续' + ))).toBe(true); const builderConversations = page.getByRole('group', { name: 'Builder 的对话' }); await expect(builderConversations).toBeVisible(); diff --git a/tests/unit/coding-chat-panel.test.tsx b/tests/unit/coding-chat-panel.test.tsx index 6fa574a..dcb5e0f 100644 --- a/tests/unit/coding-chat-panel.test.tsx +++ b/tests/unit/coding-chat-panel.test.tsx @@ -1169,6 +1169,67 @@ describe('CodingChatPanel first Conversation', () => { expect(conversationApi.recover).not.toHaveBeenCalled(); }); + it.each([ + ['follow-up', 'completed'], + ['follow-up', 'aborted'], + ['steer', 'completed'], + ['steer', 'aborted'], + ] as const)('sends again after %s and %s settlement', async (mode, terminalReason) => { + projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); + projectApi.config.mockResolvedValue({ project, config }); + projectApi.conversations.mockResolvedValue([conversation]); + conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource); + conversationApi.submit.mockImplementation(async (input) => ({ + accepted: true, + conversationId: input.conversationId, + clientRequestId: input.clientRequestId, + runId: 'run-1', + mode: input.mode, + })); + const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); + const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot'); + const { codingConversationStore } = await import('@/stores/coding-conversations'); + const runningSnapshot: ConversationSnapshot = { + ...createLocalConversationSnapshot(project.id, conversation), + worker: { status: 'ready', generation: 1 }, + run: { status: 'running', runId: 'run-1', mode: 'prompt' }, + cursor: { workerGeneration: 1, seq: 1 }, + }; + conversationApi.snapshot.mockResolvedValue(runningSnapshot); + render(); + const textbox = await screen.findByRole('textbox'); + await screen.findByRole('button', { name: '中止生成' }); + fireEvent.change(textbox, { target: { value: '排队的消息' } }); + fireEvent.change(screen.getByRole('combobox', { name: '消息发送方式' }), { + target: { value: mode }, + }); + fireEvent.click(screen.getByRole('button', { name: '发送' })); + await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce()); + await waitFor(() => expect(screen.getByTestId('coding-file-attachment-input')).toBeEnabled()); + + act(() => codingConversationStore.getState().applySnapshotEvent({ + type: 'snapshot', + conversationId: conversation.id, + workerGeneration: 1, + seq: 2, + snapshot: { + ...runningSnapshot, + cursor: { workerGeneration: 1, seq: 2 }, + run: { status: 'idle', runId: 'run-1', mode: 'prompt', terminalReason, settledAt: 2_000 }, + }, + })); + fireEvent.change(textbox, { target: { value: '继续' } }); + expect(screen.getByRole('button', { name: '发送' })).toBeEnabled(); + expect(screen.queryByText(/条消息已被本地 Agent 接收/)).not.toBeInTheDocument(); + fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter' }); + await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledTimes(2)); + expect(conversationApi.submit).toHaveBeenLastCalledWith(expect.objectContaining({ + conversationId: conversation.id, + mode: 'prompt', + text: '继续', + })); + }); + it('shows the 202 acceptance and preserves Enter versus Shift+Enter behavior', async () => { const { CodingComposer } = await import('@/pages/Chat/CodingComposer'); const onSubmit = vi.fn(); diff --git a/tests/unit/coding-conversations-store.test.tsx b/tests/unit/coding-conversations-store.test.tsx index 0a7fffd..91e5b0c 100644 --- a/tests/unit/coding-conversations-store.test.tsx +++ b/tests/unit/coding-conversations-store.test.tsx @@ -621,6 +621,28 @@ describe('coding Conversation store', () => { expect(store.getState().requestsByConversationId['conversation-a']).toEqual({}); }); + it.each(['steer', 'follow-up'] as const)('clears %s acceptance even when settlement precedes the response', async (mode) => { + const pending = deferred(); + const store = createCodingConversationStore({ + getSnapshot: vi.fn(), + openEvents: vi.fn(), + submitPrompt: vi.fn(() => pending.promise), + createId: ids(), + }); + const running = snapshot('conversation-a'); + running.run = { status: 'running', mode: 'prompt', runId: 'run-1' }; + store.getState().applySnapshotEvent(snapshotEvent(running)); + store.getState().setDraft('conversation-a', '排队的消息'); + const submission = store.getState().submitPrompt({ conversationId: 'conversation-a', mode }); + const settled = snapshot('conversation-a', 1, 1); + settled.run = { status: 'idle', mode: 'prompt', runId: 'run-1', terminalReason: 'completed', settledAt: 1_000 }; + store.getState().applySnapshotEvent(snapshotEvent(settled)); + pending.resolve({ ...acceptance('conversation-a', 'request-1'), mode }); + await submission; + expect(store.getState().requestsByConversationId['conversation-a']).toEqual({}); + expect(store.getState().draftsByConversationId['conversation-a'].text).toBe(''); + }); + it('preserves the optimistic UI id when a reconnect snapshot is already durable', async () => { const store = createCodingConversationStore({ getSnapshot: vi.fn(),