fix(coding): restore sending after queued messages settle
This commit is contained in:
1 parent
2cedc9df30
commit
954b275f10
6 files changed
+162
-8
No files matched your search
@@ -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.
|
||||||
@@ -199,7 +199,6 @@ export function CodingChatPanel({
|
|||||||
await abortCodingConversation(conversationId);
|
await abortCodingConversation(conversationId);
|
||||||
await loadConversationSnapshot(conversationId, 'silent');
|
await loadConversationSnapshot(conversationId, 'silent');
|
||||||
}, [loadConversationSnapshot]);
|
}, [loadConversationSnapshot]);
|
||||||
const promptMode = draftKey ? modesByDraftKey[draftKey] ?? 'prompt' : 'prompt';
|
|
||||||
const provisionalDraft = provisionalDraftKey ? provisionalDrafts[provisionalDraftKey] ?? '' : '';
|
const provisionalDraft = provisionalDraftKey ? provisionalDrafts[provisionalDraftKey] ?? '' : '';
|
||||||
const submissionError = draftKey ? submissionErrors[draftKey] ?? null : null;
|
const submissionError = draftKey ? submissionErrors[draftKey] ?? null : null;
|
||||||
const localAttachments = useMemo(() => (
|
const localAttachments = useMemo(() => (
|
||||||
@@ -225,6 +224,9 @@ export function CodingChatPanel({
|
|||||||
: null
|
: null
|
||||||
), [targetConversationId]);
|
), [targetConversationId]);
|
||||||
const snapshot = useCodingConversationStore(selectSnapshot);
|
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) => (
|
const entryLoadState = useCodingConversationStore((state) => (
|
||||||
targetConversationId
|
targetConversationId
|
||||||
? state.entriesByConversationId[targetConversationId]?.loadState ?? 'empty'
|
? state.entriesByConversationId[targetConversationId]?.loadState ?? 'empty'
|
||||||
@@ -576,7 +578,6 @@ export function CodingChatPanel({
|
|||||||
submitPrompt,
|
submitPrompt,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const runStatus = snapshot?.run.status ?? 'idle';
|
|
||||||
const workerStatus = snapshot?.worker.status ?? 'stopped';
|
const workerStatus = snapshot?.worker.status ?? 'stopped';
|
||||||
const runtimeError = snapshot?.run.error ?? snapshot?.worker.error ?? null;
|
const runtimeError = snapshot?.run.error ?? snapshot?.worker.error ?? null;
|
||||||
const preparationError = entryError ?? runtimeError?.message ?? null;
|
const preparationError = entryError ?? runtimeError?.message ?? null;
|
||||||
@@ -596,7 +597,6 @@ export function CodingChatPanel({
|
|||||||
|| workerStatus === 'recovering'
|
|| workerStatus === 'recovering'
|
||||||
|| runStatus === 'preparing';
|
|| runStatus === 'preparing';
|
||||||
const recovering = entryLoadState === 'recovering';
|
const recovering = entryLoadState === 'recovering';
|
||||||
const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus);
|
|
||||||
const modeMatchesRunState = running ? promptMode !== 'prompt' : promptMode === 'prompt';
|
const modeMatchesRunState = running ? promptMode !== 'prompt' : promptMode === 'prompt';
|
||||||
const pendingInteractionCount = snapshot?.pendingInteractions.filter((interaction) => (
|
const pendingInteractionCount = snapshot?.pendingInteractions.filter((interaction) => (
|
||||||
interaction.status === 'pending'
|
interaction.status === 'pending'
|
||||||
|
|||||||
@@ -832,13 +832,18 @@ export function createCodingConversationStore(
|
|||||||
const requests = state.requestsByConversationId[input.conversationId] ?? {};
|
const requests = state.requestsByConversationId[input.conversationId] ?? {};
|
||||||
const current = requests[clientRequestId];
|
const current = requests[clientRequestId];
|
||||||
if (!current) return state;
|
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 {
|
return {
|
||||||
requestsByConversationId: {
|
requestsByConversationId: {
|
||||||
...state.requestsByConversationId,
|
...state.requestsByConversationId,
|
||||||
[input.conversationId]: {
|
[input.conversationId]: nextRequests,
|
||||||
...requests,
|
|
||||||
[clientRequestId]: { ...current, status: 'accepted' },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -665,7 +665,7 @@ async function installCodingFirstChatHost(
|
|||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
clientRequestId: body?.clientRequestId,
|
clientRequestId: body?.clientRequestId,
|
||||||
runId: 'run-e2e-1',
|
runId: 'run-e2e-1',
|
||||||
mode: 'prompt',
|
mode: body?.mode ?? 'prompt',
|
||||||
},
|
},
|
||||||
}, 202);
|
}, 202);
|
||||||
}
|
}
|
||||||
@@ -1211,6 +1211,13 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model
|
|||||||
const mode = page.getByRole('combobox', { name: '消息发送方式' });
|
const mode = page.getByRole('combobox', { name: '消息发送方式' });
|
||||||
await mode.selectOption('follow-up');
|
await mode.selectOption('follow-up');
|
||||||
await expect(mode).toHaveValue('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');
|
const composer = page.getByTestId('coding-message-composer');
|
||||||
await expect(composer).not.toContainText('项目内可写');
|
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(page.getByRole('button', { name: '中止', exact: true })).toHaveCount(0);
|
||||||
await expect(composer.getByRole('button', { name: '中止生成' })).toHaveCount(0);
|
await expect(composer.getByRole('button', { name: '中止生成' })).toHaveCount(0);
|
||||||
await expect(runtimeSettings).toBeEnabled();
|
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 的对话' });
|
const builderConversations = page.getByRole('group', { name: 'Builder 的对话' });
|
||||||
await expect(builderConversations).toBeVisible();
|
await expect(builderConversations).toBeVisible();
|
||||||
|
|||||||
@@ -1169,6 +1169,67 @@ describe('CodingChatPanel first Conversation', () => {
|
|||||||
expect(conversationApi.recover).not.toHaveBeenCalled();
|
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(<CodingChatPanel />);
|
||||||
|
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 () => {
|
it('shows the 202 acceptance and preserves Enter versus Shift+Enter behavior', async () => {
|
||||||
const { CodingComposer } = await import('@/pages/Chat/CodingComposer');
|
const { CodingComposer } = await import('@/pages/Chat/CodingComposer');
|
||||||
const onSubmit = vi.fn();
|
const onSubmit = vi.fn();
|
||||||
|
|||||||
@@ -621,6 +621,28 @@ describe('coding Conversation store', () => {
|
|||||||
expect(store.getState().requestsByConversationId['conversation-a']).toEqual({});
|
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<PromptAcceptance>();
|
||||||
|
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 () => {
|
it('preserves the optimistic UI id when a reconnect snapshot is already durable', async () => {
|
||||||
const store = createCodingConversationStore({
|
const store = createCodingConversationStore({
|
||||||
getSnapshot: vi.fn(),
|
getSnapshot: vi.fn(),
|
||||||
|
|||||||
Reference in new issue
Block a user