feat: add live Codex research chat and reusable deck sources

This commit is contained in:
2026-09-24 17:55:51 +09:00
parent c47f58f065
commit b2db67ea1c
28 changed files with 4450 additions and 15 deletions
@@ -0,0 +1,376 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { Connection } from '../connections/model'
import type { ResearchRun } from './model'
import { ResearchPanel } from './research-panel'
const actions = vi.hoisted(() => ({
status: vi.fn(),
start: vi.fn(),
cancel: vi.fn(),
}))
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn }))
vi.mock('./server-functions', () => ({
loadResearchStatus: actions.status,
startResearch: actions.start,
cancelResearch: actions.cancel,
}))
const connection: Connection = {
id: 'first',
platform: 'twitter',
origin: 'https://relay.invalid',
accountId: null,
displayName: '調査用アカウント',
status: 'connected',
}
const running: ResearchRun = {
id: 'run1',
topic: 'WebMCPの反応',
status: 'running',
deckVersion: 1,
startedAt: 1000,
message: '投稿を調べています',
messages: [{ id: 'u1', role: 'user', text: 'WebMCPの反応' }],
deck: { id: 'plan', title: 'WebMCP調査', columns: [] },
}
let current: { configured: boolean; run: ResearchRun | null }
const clients: QueryClient[] = []
class MockEventSource {
static instances: MockEventSource[] = []
onmessage: ((event: { data: string }) => void) | null = null
onopen: (() => void) | null = null
onerror: (() => void) | null = null
close = vi.fn()
constructor(readonly url: string) {
MockEventSource.instances.push(this)
}
}
beforeEach(() => {
vi.resetAllMocks()
MockEventSource.instances = []
vi.stubGlobal('EventSource', MockEventSource)
current = { configured: true, run: null }
actions.status.mockImplementation(async () => current)
actions.start.mockImplementation(async () => {
current = { configured: true, run: running }
return running
})
})
afterEach(() => {
vi.unstubAllGlobals()
for (const client of clients.splice(0)) client.clear()
})
function show(connections = [connection], ready = true) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
clients.push(client)
const onOpenDeck = vi.fn()
const ui = (
ready = true,
contextDeck = { id: 'manual', title: '手動デッキ', columns: [] },
) => (
<QueryClientProvider client={client}>
<ResearchPanel
ready={ready}
contextDeck={contextDeck}
connections={connections}
onOpenDeck={onOpenDeck}
/>
</QueryClientProvider>
)
return { ...render(ui(ready)), client, ui, onOpenDeck }
}
it('retains input and uses the same request ID when retrying a failed start', async () => {
actions.start.mockRejectedValue(new Error('internal credential detail'))
show()
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled())
fireEvent.change(screen.getByLabelText('メッセージ'), {
target: { value: 'WebMCPの反応' },
})
fireEvent.click(screen.getByRole('button', { name: '送信' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'入力内容は残っています',
)
expect(screen.getByLabelText('メッセージ')).toHaveValue('WebMCPの反応')
expect(screen.queryByText(/internal credential/)).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '送信' }))
await waitFor(() => expect(actions.start).toHaveBeenCalledTimes(2))
expect(actions.start.mock.calls[1]?.[0]).toEqual(
actions.start.mock.calls[0]?.[0],
)
})
it('opens each generated deck version only once for the run started in this browser', async () => {
const view = show()
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled())
fireEvent.change(screen.getByLabelText('メッセージ'), {
target: { value: 'WebMCPの反応' },
})
fireEvent.click(screen.getByRole('button', { name: '送信' }))
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledOnce())
act(() =>
view.client.setQueryData(['research-status'], {
...current,
run: { ...running },
}),
)
expect(view.onOpenDeck).toHaveBeenCalledOnce()
view.rerender(view.ui())
current = {
configured: true,
run: { ...running, deckVersion: 2, message: '追加の検索を実行中' },
}
act(() =>
MockEventSource.instances[0]?.onmessage?.({
data: JSON.stringify(current),
}),
)
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledTimes(2), {
timeout: 2500,
})
expect(screen.getByRole('log', { name: '調査の会話' })).toBeVisible()
})
it('restores the latest plan automatically on reload and permits manual activation', async () => {
current = { configured: true, run: running }
const view = show()
await waitFor(() =>
expect(view.onOpenDeck).toHaveBeenCalledWith(running.deck, false),
)
fireEvent.click(
await screen.findByRole('button', { name: '一時デッキで開く' }),
)
await waitFor(() =>
expect(view.onOpenDeck).toHaveBeenLastCalledWith(running.deck, true),
)
})
it('reports cancellation failure and updates the run after a successful retry', async () => {
current = { configured: true, run: running }
actions.cancel
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValue({ ...running, status: 'cancelled' })
show()
fireEvent.click(await screen.findByRole('button', { name: '調査を中止' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'中止できませんでした',
)
fireEvent.click(screen.getByRole('button', { name: '調査を中止' }))
await waitFor(() =>
expect(screen.queryByRole('button', { name: '調査を中止' })).toBeNull(),
)
expect(actions.cancel).toHaveBeenCalledWith({ data: { id: running.id } })
})
it('limits account selection to six and shows the host report path on completion', async () => {
current = {
configured: true,
run: {
...running,
status: 'complete',
reportPath: '/home/owner/reports/webmcp.md',
},
}
show(
Array.from({ length: 7 }, (_, i) => ({
...connection,
id: `connection${i}`,
displayName: `account${i}`,
})),
)
await screen.findByText('/home/owner/reports/webmcp.md')
fireEvent.click(screen.getByText('接続アカウント · 6件'))
expect(screen.getByLabelText('account6 · Twitter')).toBeDisabled()
fireEvent.click(screen.getByLabelText('account0 · Twitter'))
expect(screen.getByLabelText('account6 · Twitter')).toBeEnabled()
})
it('waits for the workspace before restoring a completed plan from the stream', async () => {
const view = show([connection], false)
await screen.findByLabelText('メッセージ')
act(() =>
MockEventSource.instances[0]?.onmessage?.({
data: JSON.stringify({
configured: true,
run: { ...running, status: 'complete' },
}),
}),
)
await waitFor(() =>
expect(view.client.getQueryData(['research-status'])).toMatchObject({
run: { status: 'complete' },
}),
)
expect(view.onOpenDeck).not.toHaveBeenCalled()
view.rerender(view.ui(true))
await waitFor(() =>
expect(view.onOpenDeck).toHaveBeenCalledWith(running.deck, false),
)
})
it('reconnects the stream without polling or cancelling the host job on unmount', async () => {
current = { configured: true, run: running }
const view = show()
await waitFor(() => expect(actions.status).toHaveBeenCalledOnce())
const stream = MockEventSource.instances[0]
expect(stream?.url).toBe('/api/research/events')
act(() => stream?.onerror?.())
expect(screen.getByText(/配信に再接続/)).toBeVisible()
act(() => stream?.onopen?.())
expect(screen.queryByText(/配信に再接続/)).toBeNull()
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1200))
})
expect(actions.status).toHaveBeenCalledOnce()
view.unmount()
expect(stream?.close).toHaveBeenCalledOnce()
expect(actions.cancel).not.toHaveBeenCalled()
})
it('keeps newer stream state when the start response arrives late', async () => {
let finish!: (run: ResearchRun) => void
actions.start.mockImplementation(
() =>
new Promise<ResearchRun>((resolve) => {
finish = resolve
}),
)
const view = show()
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled())
fireEvent.change(screen.getByLabelText('メッセージ'), {
target: { value: 'WebMCP' },
})
fireEvent.click(screen.getByRole('button', { name: '送信' }))
act(() =>
MockEventSource.instances[0]?.onmessage?.({
data: JSON.stringify({ configured: true, run: running }),
}),
)
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledOnce())
await act(async () =>
finish({ ...running, status: 'starting', deck: undefined, deckVersion: 0 }),
)
expect(view.client.getQueryData(['research-status'])).toEqual({
configured: true,
run: running,
})
expect(view.onOpenDeck).toHaveBeenCalledOnce()
})
it('applies rapid versions in order while an earlier workspace save is pending', async () => {
current = { configured: true, run: running }
const view = show()
let finish!: () => void
view.onOpenDeck.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finish = resolve
}),
)
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledOnce())
const nextDeck = { id: 'plan', title: '追加検索', columns: [] }
act(() =>
MockEventSource.instances[0]?.onmessage?.({
data: JSON.stringify({
configured: true,
run: { ...running, deckVersion: 2, deck: nextDeck },
}),
}),
)
await waitFor(() =>
expect(view.client.getQueryData(['research-status'])).toMatchObject({
run: { deckVersion: 2 },
}),
)
expect(view.onOpenDeck).toHaveBeenCalledOnce()
await act(async () => finish())
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledTimes(2))
expect(view.onOpenDeck).toHaveBeenLastCalledWith(nextDeck, false)
})
it('shows streamed conversation and sends follow-up input with the current deck and same run', async () => {
current = {
configured: true,
run: {
...running,
status: 'complete',
threadId: 'thread1',
messages: [
{ id: 'u1', role: 'user', text: 'WebMCPを調べて' },
{ id: 'a1', role: 'assistant', text: '検索結果を比較しました。' },
{ id: 't1', role: 'tool', text: '検索カラムを追加しました' },
],
},
}
const view = show()
expect(await screen.findByText('検索結果を比較しました。')).toBeVisible()
expect(screen.getByText('検索カラムを追加しました')).toBeVisible()
expect(screen.getByText('あなた')).toBeVisible()
const editedDeck = { id: 'other', title: '切り替えたデッキ', columns: [] }
view.rerender(view.ui(true, editedDeck))
fireEvent.change(screen.getByLabelText('メッセージ'), {
target: { value: 'このデッキを絞って' },
})
fireEvent.click(screen.getByRole('button', { name: '送信' }))
await waitFor(() =>
expect(actions.start).toHaveBeenCalledWith({
data: expect.objectContaining({
runId: running.id,
contextDeck: editedDeck,
topic: 'このデッキを絞って',
}),
}),
)
await waitFor(() =>
expect(screen.getByLabelText('メッセージ')).toHaveValue(''),
)
const updatedDeck = { id: 'plan', title: '絞り込み結果', columns: [] }
act(() =>
MockEventSource.instances[0]?.onmessage?.({
data: JSON.stringify({
configured: true,
run: { ...running, deck: updatedDeck, deckVersion: 2 },
}),
}),
)
await waitFor(() =>
expect(view.onOpenDeck).toHaveBeenLastCalledWith(updatedDeck, true),
)
const laterDeck = { ...updatedDeck, title: '追加の結果' }
act(() =>
MockEventSource.instances[0]?.onmessage?.({
data: JSON.stringify({
configured: true,
run: { ...running, deck: laterDeck, deckVersion: 3 },
}),
}),
)
await waitFor(() =>
expect(view.onOpenDeck).toHaveBeenLastCalledWith(laterDeck, false),
)
})
it('selects newly loaded connected accounts until the user changes their selection', async () => {
const view = show([])
await screen.findByText('接続アカウントを先に追加してください。')
view.rerender(
<QueryClientProvider client={view.client}>
<ResearchPanel
ready
contextDeck={{ id: 'manual', title: '手動', columns: [] }}
connections={[connection]}
onOpenDeck={view.onOpenDeck}
/>
</QueryClientProvider>,
)
fireEvent.change(screen.getByLabelText('メッセージ'), {
target: { value: '調べて' },
})
expect(screen.getByRole('button', { name: '送信' })).toBeEnabled()
})