fix: enable voice input for fresh agent conversations
This commit is contained in:
1 parent
9fd9a7761d
commit
b6148a5cb4
3 files changed
+169
-1
No files matched your search
@@ -0,0 +1,50 @@
|
|||||||
|
# Task: Fix Code fresh-conversation voice input
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- Task ID: 20260817-voice-input-fix-b4e62d91
|
||||||
|
- Mode: Feature
|
||||||
|
- Branch: codex/20260817-voice-input-fix-b4e62d91-voice-input-fix
|
||||||
|
- Worktree: D:\Datas\OthersProjects\makelore-voice-input-fix-b4e62d91
|
||||||
|
- Base commit: 9fd9a7761d3872f25e853ac1b059e55251c6ae8f
|
||||||
|
- Owner: codex
|
||||||
|
- Status: Completed
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Enable AI Programming voice capture after the user selects an Agent but before the lazy first OpenCode session exists.
|
||||||
|
- Add a component-level regression that exercises the real fresh-contact state and verifies transcription populates the composer draft without creating an empty session.
|
||||||
|
- Preserve existing-session voice behavior and the current busy/transcribing guards.
|
||||||
|
|
||||||
|
## Intent And Constraints
|
||||||
|
|
||||||
|
- Preserve lazy contact selection: choosing an unused Agent or starting voice capture must not create or start an OpenCode session; only message submission may create the first session.
|
||||||
|
- Keep speech capture and transcription on the existing Renderer -> Host API -> Electron Main -> Works Square path.
|
||||||
|
- Do not change Canvas, Learning, Agent templates, provider configuration, or unrelated composer behavior.
|
||||||
|
- Keep the implementation surgical and limited to the ChatPanel predicate and its focused unit coverage.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- Updated the AI Programming composer voice-availability predicate so a selected Agent is sufficient before the lazy first session exists.
|
||||||
|
- Kept the existing loading, transcription, recording, and session-busy guards unchanged.
|
||||||
|
- Added a component regression covering a fresh Agent conversation: voice capture transcribes into the draft while the selected session remains lazy and no session-creation request is sent.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Regression-first evidence: the new fresh-Agent test failed before the predicate change because the voice button was disabled, then passed after the one-line fix.
|
||||||
|
- `pnpm exec eslint src/pages/Chat/OpencodeChatPanel.tsx tests/unit/opencode-chat-panel.test.tsx` passed.
|
||||||
|
- Five focused lazy-session and voice-input tests passed (`5 passed`, `80 skipped`).
|
||||||
|
- The complete `opencode-chat-panel.test.tsx` file passed (`85/85`) during implementation.
|
||||||
|
- `pnpm test` passed (`175` files, `2048` tests).
|
||||||
|
- `pnpm run typecheck` passed.
|
||||||
|
- `pnpm run build:vite` passed for Renderer, Electron Main, and Preload (`3921`, `133`, and `1` modules transformed respectively); only pre-existing chunk-size and mixed static/dynamic import warnings were reported.
|
||||||
|
- Final read-only Sol review: `PASS`, with no blocking findings across spec and standards checks.
|
||||||
|
- Electron microphone E2E was not added because the shared fixture does not provide a deterministic `MediaRecorder`/microphone seam; the component regression exercises Agent selection, recording start/stop, transcription, and lazy-session behavior directly.
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- None.
|
||||||
|
|
||||||
|
## Promotion Candidates
|
||||||
|
|
||||||
|
- None. The change restores the documented lazy-session behavior and does not alter an architectural or product contract.
|
||||||
@@ -1040,7 +1040,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
|||||||
);
|
);
|
||||||
const canStopSession = canUseSessions && Boolean(selectedSessionId) && sessionBusy && composerStopArmed;
|
const canStopSession = canUseSessions && Boolean(selectedSessionId) && sessionBusy && composerStopArmed;
|
||||||
const canUseVoiceInput = canUseSessions
|
const canUseVoiceInput = canUseSessions
|
||||||
&& Boolean(selectedSessionId)
|
&& Boolean(selectedSessionId || selectedAgentId)
|
||||||
&& !loading
|
&& !loading
|
||||||
&& voiceInputState !== 'transcribing'
|
&& voiceInputState !== 'transcribing'
|
||||||
&& (!sessionBusy || voiceInputState === 'recording');
|
&& (!sessionBusy || voiceInputState === 'recording');
|
||||||
|
|||||||
@@ -1010,6 +1010,124 @@ describe('OpencodeChatPanel', () => {
|
|||||||
expect(screen.queryByText('课程槽位')).not.toBeInTheDocument();
|
expect(screen.queryByText('课程槽位')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('records voice into a fresh Agent conversation without eagerly creating a session', async () => {
|
||||||
|
installMockVoiceWavTranscoder();
|
||||||
|
const activeProject = {
|
||||||
|
id: 'prj_fresh_voice',
|
||||||
|
path: 'D:/repo/fresh-voice',
|
||||||
|
name: 'fresh-voice',
|
||||||
|
createdAt: '2026-08-17T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-08-17T00:00:00.000Z',
|
||||||
|
lastOpenedAt: '2026-08-17T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
const config = createConfiguredProjectConfig();
|
||||||
|
const getUserMedia = vi.fn().mockResolvedValue({
|
||||||
|
getTracks: vi.fn(() => [{ stop: vi.fn() }]),
|
||||||
|
});
|
||||||
|
|
||||||
|
class MockMediaRecorder {
|
||||||
|
public ondataavailable: ((event: { data: Blob }) => void) | null = null;
|
||||||
|
public onstop: (() => void) | null = null;
|
||||||
|
public state: RecordingState = 'inactive';
|
||||||
|
|
||||||
|
static isTypeSupported(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
this.state = 'recording';
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.state = 'inactive';
|
||||||
|
this.ondataavailable?.({ data: new Blob(['audio bytes'], { type: 'audio/webm' }) });
|
||||||
|
this.onstop?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(navigator, 'mediaDevices', {
|
||||||
|
configurable: true,
|
||||||
|
value: { getUserMedia },
|
||||||
|
});
|
||||||
|
Object.defineProperty(window, 'MediaRecorder', {
|
||||||
|
configurable: true,
|
||||||
|
value: MockMediaRecorder,
|
||||||
|
});
|
||||||
|
useAuthStore.setState({
|
||||||
|
initialized: true,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
accessToken: 'access-token',
|
||||||
|
refreshToken: null,
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
user: {
|
||||||
|
username: 'student',
|
||||||
|
userId: '42',
|
||||||
|
tenantId: null,
|
||||||
|
deptId: null,
|
||||||
|
authorities: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
useProjectConfigStore.setState({
|
||||||
|
configsByProjectId: { [activeProject.id]: config },
|
||||||
|
});
|
||||||
|
useOpencodeStore.setState({
|
||||||
|
status: { state: 'running', port: 4096, url: 'http://127.0.0.1:4096' },
|
||||||
|
projects: [activeProject],
|
||||||
|
activeProject,
|
||||||
|
sessions: [],
|
||||||
|
selectedSessionId: null,
|
||||||
|
sessionStatuses: {},
|
||||||
|
sessionMessages: [],
|
||||||
|
});
|
||||||
|
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
|
||||||
|
if (path === '/api/works/speech/transcriptions' && init?.method === 'POST') {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
transcription: {
|
||||||
|
text: 'voice before first message',
|
||||||
|
model: 'gpt-4o-mini-transcribe',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (path === '/api/opencode/status') {
|
||||||
|
return { state: 'running', port: 4096, url: 'http://127.0.0.1:4096' };
|
||||||
|
}
|
||||||
|
if (path === '/api/opencode/projects') {
|
||||||
|
return { projects: [activeProject], activeProject };
|
||||||
|
}
|
||||||
|
if (path === '/api/opencode/config-summary') {
|
||||||
|
return { summary: { model: 'openai/gpt-5', smallModel: null, providerIds: ['openai'], providerCount: 1 } };
|
||||||
|
}
|
||||||
|
if (path === '/api/opencode/sessions') return { sessions: [] };
|
||||||
|
if (path === '/api/opencode/sessions/status') return { statuses: {} };
|
||||||
|
throw new Error(`Unexpected path ${path}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OpencodeChatPanel variant="main" />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByTestId('project-agent-chat-game-art'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('project-agent-chat-game-art')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
});
|
||||||
|
expect(useOpencodeStore.getState().selectedSessionId).toBeNull();
|
||||||
|
|
||||||
|
const voiceButton = screen.getByTestId('opencode-voice-input-button');
|
||||||
|
expect(voiceButton).toBeEnabled();
|
||||||
|
fireEvent.click(voiceButton);
|
||||||
|
await waitFor(() => expect(getUserMedia).toHaveBeenCalledWith({ audio: true }));
|
||||||
|
fireEvent.click(voiceButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toHaveValue('voice before first message');
|
||||||
|
});
|
||||||
|
expect(hostApiFetchMock.mock.calls.some(([path, init]) => (
|
||||||
|
path === '/api/opencode/sessions'
|
||||||
|
&& (init as RequestInit | undefined)?.method === 'POST'
|
||||||
|
))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps project-agent actions enabled when a pre-start status response is delayed', async () => {
|
it('keeps project-agent actions enabled when a pre-start status response is delayed', async () => {
|
||||||
const activeProject = { id: 'prj_delayed_status', path: 'D:/repo/delayed-status', name: 'delayed-status', createdAt: '2026-07-12T00:00:00.000Z', updatedAt: '2026-07-12T00:00:00.000Z', lastOpenedAt: '2026-07-12T00:00:00.000Z' };
|
const activeProject = { id: 'prj_delayed_status', path: 'D:/repo/delayed-status', name: 'delayed-status', createdAt: '2026-07-12T00:00:00.000Z', updatedAt: '2026-07-12T00:00:00.000Z', lastOpenedAt: '2026-07-12T00:00:00.000Z' };
|
||||||
const config = createConfiguredProjectConfig();
|
const config = createConfiguredProjectConfig();
|
||||||
|
|||||||
Reference in new issue
Block a user