mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(wsl-host): a chat holds Send while its WSL distro is stopped, until Start (#35864)
* fix(wsl-host): a chat holds Send while its WSL distro is stopped, until Start * test(wsl-host): find the held Send by the reason it waits
This commit is contained in:
@@ -24,11 +24,12 @@ function writeState(state: 'running' | 'stopped'): void {
|
||||
writeFileSync(distrosFile, JSON.stringify([{ name: 'Ubuntu', state, version: 2 }]))
|
||||
}
|
||||
|
||||
function connector(onChange = vi.fn()) {
|
||||
const fake = createFakeWslHostConnector({ distros: [], distrosFile, pairingCode }, onChange, {
|
||||
isKnownDistro: () => true,
|
||||
runtime
|
||||
})
|
||||
function connector(onChange = vi.fn(), stopSilently = false) {
|
||||
const fake = createFakeWslHostConnector(
|
||||
{ distros: [], distrosFile, pairingCode, stopSilently },
|
||||
onChange,
|
||||
{ isKnownDistro: () => true, runtime }
|
||||
)
|
||||
return { fake, onChange }
|
||||
}
|
||||
|
||||
@@ -58,6 +59,14 @@ it('drops the bridge of a distro the test stops and refuses a no-boot reconnect'
|
||||
await expect(fake.connect('Ubuntu', { noBoot: true })).rejects.toThrow('Ubuntu is not running.')
|
||||
})
|
||||
|
||||
it('leaves a silent stop for a runtime call to report, as when liveness saw only a quick reboot', async () => {
|
||||
const { fake, onChange } = connector(vi.fn(), true)
|
||||
await fake.connect('Ubuntu')
|
||||
writeState('stopped')
|
||||
expect(await fake.reachability('Ubuntu', 'wsl:ubuntu')).toBe('stopped')
|
||||
expect(onChange).toHaveBeenLastCalledWith('Ubuntu', null, { state: 'closed' })
|
||||
})
|
||||
|
||||
it('still reports a plain close for an explicit disconnect of a running distro', async () => {
|
||||
const { fake, onChange } = connector()
|
||||
await fake.connect('Ubuntu')
|
||||
|
||||
@@ -29,7 +29,9 @@ const FakeWslHostsSchema = z.object({
|
||||
/** Each distro the fake is asked to start, one per line, so e2e can prove nothing respawns. */
|
||||
connectLogFile: z.string().optional(),
|
||||
/** `missing` stands in for a build shipped without the orcad template. */
|
||||
serverFiles: z.enum(['present', 'missing']).optional()
|
||||
serverFiles: z.enum(['present', 'missing']).optional(),
|
||||
/** A stop the fake does not report, as when liveness sees only a quick reboot: a runtime call must. */
|
||||
stopSilently: z.boolean().optional()
|
||||
})
|
||||
|
||||
export function readFakeWslHostsConfig(
|
||||
@@ -123,8 +125,13 @@ export function createFakeWslHostConnector(
|
||||
// A running fake distro's missing server is reachable by a reconnect; a stopped one's bridge is gone.
|
||||
reachability: async (distro) => {
|
||||
if (isStopped(distro)) {
|
||||
// As production liveness reports a stopped distro's lost bridge.
|
||||
dropBridge(distro, { state: 'distro-stopped', terminals: 'exited', recovering: false })
|
||||
// As production liveness reports a stopped distro's lost bridge, unless told to stay quiet.
|
||||
dropBridge(
|
||||
distro,
|
||||
config.stopSilently
|
||||
? { state: 'closed' }
|
||||
: { state: 'distro-stopped', terminals: 'exited', recovering: false }
|
||||
)
|
||||
return 'stopped'
|
||||
}
|
||||
return active.has(key(distro)) ? 'available' : 'unreachable'
|
||||
|
||||
@@ -58,7 +58,7 @@ import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent
|
||||
import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection'
|
||||
import { useNativeChatHostOutage } from './use-native-chat-host-outage'
|
||||
import { useNativeChatHostOutageNotice } from './use-native-chat-host-outage-notice'
|
||||
import { useNativeChatHostPermissionGate } from './use-native-chat-host-permission-gate'
|
||||
import { useNativeChatHostSendGate } from './use-native-chat-host-send-gate'
|
||||
import { useNativeChatAvailabilityNotice } from './use-native-chat-availability-notice'
|
||||
import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts'
|
||||
import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject'
|
||||
@@ -123,8 +123,8 @@ export function NativeChatStructuredSession(
|
||||
hostOutage === null
|
||||
)
|
||||
const agentLabel = structuredAgentLabel(props.agent)
|
||||
const hostPermission = useNativeChatHostPermissionGate(props.target, props.agent, agentLabel)
|
||||
const hostHeld = hostPermission.sendBlockedReason !== null
|
||||
const hostSendGate = useNativeChatHostSendGate(props.target, props.agent, hostOutage)
|
||||
const hostHeld = hostSendGate.sendBlockedReason !== null
|
||||
const submits = useStructuredNativeChatSubmitReveal(controller, provisionalLaunch.retry, hostHeld)
|
||||
const promptResponse = useStructuredPromptResponseHold(submits.respond)
|
||||
const needsFailureFacts =
|
||||
@@ -217,7 +217,7 @@ export function NativeChatStructuredSession(
|
||||
setOptionPickerRequest,
|
||||
onError: reportComposerError,
|
||||
onSubmitted: submits.revealLatest,
|
||||
sendBlockedReason: hostPermission.sendBlockedReason ?? install.sendBlockedReason
|
||||
sendBlockedReason: hostSendGate.sendBlockedReason ?? install.sendBlockedReason
|
||||
})
|
||||
const asyncQuestion = { paneKey, session, send: transport.send }
|
||||
const continuation = useNativeChatInterruptedContinuation({
|
||||
@@ -258,7 +258,7 @@ export function NativeChatStructuredSession(
|
||||
composerError: composerError ?? continuation.continueError,
|
||||
claudeSignIn,
|
||||
availability,
|
||||
hostPermission: hostPermission.notice
|
||||
hostPermission: hostSendGate.notice
|
||||
})
|
||||
if (hostNotice) {
|
||||
notices.push(hostNotice)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatHostPermissionGate } from './use-native-chat-host-permission-gate'
|
||||
import type { NativeChatHostOutage } from './use-native-chat-host-outage'
|
||||
|
||||
const permission = vi.hoisted(() => {
|
||||
const gate: NativeChatHostPermissionGate = { sendBlockedReason: null, notice: null }
|
||||
return { gate }
|
||||
})
|
||||
vi.mock('./use-native-chat-host-permission-gate', () => ({
|
||||
useNativeChatHostPermissionGate: () => permission.gate
|
||||
}))
|
||||
|
||||
import { useNativeChatHostSendGate } from './use-native-chat-host-send-gate'
|
||||
|
||||
const target = { kind: 'environment', environmentId: 'wsl:ubuntu' } as const
|
||||
|
||||
function outage(kind: NativeChatHostOutage['kind']): NativeChatHostOutage {
|
||||
return { kind, environmentId: 'wsl:ubuntu', hostLabel: 'Ubuntu', canReconnect: true }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
permission.gate = { sendBlockedReason: null, notice: null }
|
||||
})
|
||||
|
||||
it('holds Send on a stopped WSL distro, saying Start comes first', () => {
|
||||
const gate = renderHook(() => useNativeChatHostSendGate(target, 'claude', outage('stopped')))
|
||||
expect(gate.result.current.sendBlockedReason).toBe('Start Ubuntu to send.')
|
||||
})
|
||||
|
||||
it.each(['offline', 'reconnecting'] as const)('leaves Send to the host while it is %s', (kind) => {
|
||||
const gate = renderHook(() => useNativeChatHostSendGate(target, 'claude', outage(kind)))
|
||||
expect(gate.result.current.sendBlockedReason).toBeNull()
|
||||
})
|
||||
|
||||
it("keeps the host permission check's own reason and notice first", () => {
|
||||
permission.gate = { sendBlockedReason: "Checking this host's chat permissions…", notice: null }
|
||||
const gate = renderHook(() => useNativeChatHostSendGate(target, 'claude', outage('stopped')))
|
||||
expect(gate.result.current).toBe(permission.gate)
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label'
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
|
||||
import {
|
||||
useNativeChatHostPermissionGate,
|
||||
type NativeChatHostPermissionGate
|
||||
} from './use-native-chat-host-permission-gate'
|
||||
import type { NativeChatHostOutage } from './use-native-chat-host-outage'
|
||||
|
||||
/** What holds Send on the chat's host: its permission check, or a stopped WSL distro only Start may boot. */
|
||||
export function useNativeChatHostSendGate(
|
||||
target: RuntimeClientTarget,
|
||||
agent: AgentType,
|
||||
outage: NativeChatHostOutage | null
|
||||
): NativeChatHostPermissionGate {
|
||||
const permission = useNativeChatHostPermissionGate(target, agent, structuredAgentLabel(agent))
|
||||
if (permission.sendBlockedReason !== null || outage?.kind !== 'stopped') {
|
||||
return permission
|
||||
}
|
||||
return {
|
||||
...permission,
|
||||
sendBlockedReason: translate(
|
||||
'components.native-chat.hostOutage.startToSend',
|
||||
'Start {{distro}} to send.',
|
||||
{
|
||||
distro: outage.hostLabel
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -19307,7 +19307,8 @@
|
||||
"reconnect": "Erneut verbinden",
|
||||
"stopped": "{{distro}} ist gestoppt",
|
||||
"start": "Starten",
|
||||
"unverifiable": "Orca konnte nicht prüfen, ob {{distro}} läuft"
|
||||
"unverifiable": "Orca konnte nicht prüfen, ob {{distro}} läuft",
|
||||
"startToSend": "Starte {{distro}}, um zu senden."
|
||||
},
|
||||
"interruptedContinue": {
|
||||
"continue": "Fortfahren",
|
||||
|
||||
@@ -19733,7 +19733,8 @@
|
||||
"reconnect": "Reconnect",
|
||||
"stopped": "{{distro}} is stopped",
|
||||
"start": "Start",
|
||||
"unverifiable": "Orca couldn't check whether {{distro}} is running"
|
||||
"unverifiable": "Orca couldn't check whether {{distro}} is running",
|
||||
"startToSend": "Start {{distro}} to send."
|
||||
},
|
||||
"interruptedContinue": {
|
||||
"continue": "Continue",
|
||||
|
||||
@@ -19439,7 +19439,8 @@
|
||||
"reconnect": "Reconectar",
|
||||
"stopped": "{{distro}} está detenido",
|
||||
"start": "Iniciar",
|
||||
"unverifiable": "Orca no pudo comprobar si {{distro}} se está ejecutando"
|
||||
"unverifiable": "Orca no pudo comprobar si {{distro}} se está ejecutando",
|
||||
"startToSend": "Inicie {{distro}} para enviar."
|
||||
},
|
||||
"interruptedContinue": {
|
||||
"continue": "Continuar",
|
||||
|
||||
@@ -19659,7 +19659,8 @@
|
||||
"reconnect": "Reconnecter",
|
||||
"stopped": "{{distro}} est arrêté",
|
||||
"start": "Démarrer",
|
||||
"unverifiable": "Orca n'a pas pu vérifier si {{distro}} est en cours d'exécution"
|
||||
"unverifiable": "Orca n'a pas pu vérifier si {{distro}} est en cours d'exécution",
|
||||
"startToSend": "Démarrez {{distro}} pour envoyer."
|
||||
},
|
||||
"interruptedContinue": {
|
||||
"continue": "Continuer",
|
||||
|
||||
@@ -18920,7 +18920,8 @@
|
||||
"reconnect": "再接続",
|
||||
"stopped": "{{distro}} は停止しています",
|
||||
"start": "起動",
|
||||
"unverifiable": "Orca は {{distro}} が実行中かどうかを確認できませんでした"
|
||||
"unverifiable": "Orca は {{distro}} が実行中かどうかを確認できませんでした",
|
||||
"startToSend": "送信するには {{distro}} を開始してください。"
|
||||
},
|
||||
"interruptedContinue": {
|
||||
"continue": "続行",
|
||||
|
||||
@@ -18920,7 +18920,8 @@
|
||||
"reconnect": "다시 연결",
|
||||
"stopped": "{{distro}}이(가) 중지되었습니다",
|
||||
"start": "시작",
|
||||
"unverifiable": "Orca가 {{distro}}이(가) 실행 중인지 확인하지 못했습니다"
|
||||
"unverifiable": "Orca가 {{distro}}이(가) 실행 중인지 확인하지 못했습니다",
|
||||
"startToSend": "보내려면 {{distro}}을(를) 시작하세요."
|
||||
},
|
||||
"interruptedContinue": {
|
||||
"continue": "계속",
|
||||
|
||||
@@ -20297,7 +20297,8 @@
|
||||
"reconnect": "Reconectar",
|
||||
"stopped": "{{distro}} está parado",
|
||||
"start": "Iniciar",
|
||||
"unverifiable": "O Orca não conseguiu verificar se {{distro}} está em execução"
|
||||
"unverifiable": "O Orca não conseguiu verificar se {{distro}} está em execução",
|
||||
"startToSend": "Inicie {{distro}} para enviar."
|
||||
},
|
||||
"interruptedContinue": {
|
||||
"continue": "Continuar",
|
||||
|
||||
@@ -19907,7 +19907,8 @@
|
||||
"reconnect": "Подключиться снова",
|
||||
"stopped": "{{distro}} остановлен",
|
||||
"start": "Запустить",
|
||||
"unverifiable": "Orca не удалось проверить, запущен ли {{distro}}"
|
||||
"unverifiable": "Orca не удалось проверить, запущен ли {{distro}}",
|
||||
"startToSend": "Запустите {{distro}}, чтобы отправить."
|
||||
},
|
||||
"interruptedContinue": {
|
||||
"continue": "Продолжить",
|
||||
|
||||
@@ -19291,7 +19291,8 @@
|
||||
"reconnect": "Yeniden bağlan",
|
||||
"stopped": "{{distro}} durduruldu",
|
||||
"start": "Başlat",
|
||||
"unverifiable": "Orca, {{distro}} öğesinin çalışıp çalışmadığını denetleyemedi"
|
||||
"unverifiable": "Orca, {{distro}} öğesinin çalışıp çalışmadığını denetleyemedi",
|
||||
"startToSend": "Göndermek için {{distro}} dağıtımını başlatın."
|
||||
},
|
||||
"interruptedContinue": {
|
||||
"continue": "Devam et",
|
||||
|
||||
@@ -19032,7 +19032,8 @@
|
||||
"reconnect": "重新連線",
|
||||
"stopped": "{{distro}} 已停止",
|
||||
"start": "啟動",
|
||||
"unverifiable": "Orca 無法確認 {{distro}} 是否正在執行"
|
||||
"unverifiable": "Orca 無法確認 {{distro}} 是否正在執行",
|
||||
"startToSend": "請啟動 {{distro}} 後再傳送。"
|
||||
},
|
||||
"interruptedContinue": {
|
||||
"continue": "繼續",
|
||||
|
||||
@@ -18890,7 +18890,8 @@
|
||||
"reconnect": "重新连接",
|
||||
"stopped": "{{distro}} 已停止",
|
||||
"start": "启动",
|
||||
"unverifiable": "Orca 无法确认 {{distro}} 是否正在运行"
|
||||
"unverifiable": "Orca 无法确认 {{distro}} 是否正在运行",
|
||||
"startToSend": "请启动 {{distro}} 后再发送。"
|
||||
},
|
||||
"interruptedContinue": {
|
||||
"continue": "继续",
|
||||
|
||||
@@ -255,4 +255,122 @@ test.describe('native chat on a WSL host', () => {
|
||||
rmSync(projectPath, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('holds Send and offers Start when a runtime call, not liveness, finds the distro stopped', async ({
|
||||
orcaPage: hub,
|
||||
claudeStub
|
||||
}, testInfo) => {
|
||||
// Two Electron apps and two chat turns around one stop.
|
||||
test.setTimeout(240_000)
|
||||
await waitForSessionReady(hub)
|
||||
await hub.evaluate(async (command) => {
|
||||
await window.__store?.getState().updateSettings({
|
||||
experimentalNativeChat: true,
|
||||
agentCmdOverrides: { claude: command }
|
||||
})
|
||||
}, buildFakeAgentCommandOverride(claudeStub.command))
|
||||
const offer = await createRuntimeDesktopPairingOffer(hub)
|
||||
const fakeDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-wsl-chat-parked-'))
|
||||
const distrosFile = path.join(fakeDir, 'distros.json')
|
||||
const connectLogFile = path.join(fakeDir, 'connects.log')
|
||||
writeDistros(distrosFile, RUNNING)
|
||||
// The fake reports no stop of its own, so only the runtime call's park can say it.
|
||||
const desktop = await launchDesktopWithFakeWsl(
|
||||
{
|
||||
distros: RUNNING,
|
||||
distrosFile,
|
||||
connectLogFile,
|
||||
pairingCode: offer.pairingUrl,
|
||||
stopSilently: true
|
||||
},
|
||||
testInfo
|
||||
)
|
||||
const projectPath = createGitProject()
|
||||
try {
|
||||
const { page } = desktop
|
||||
await page.evaluate(async () => {
|
||||
const settings = await window.api.settings.set({ experimentalWslHosts: true })
|
||||
window.__store?.setState({ settings })
|
||||
await window.__store?.getState().updateSettings({
|
||||
experimentalNativeChat: true,
|
||||
defaultTuiAgent: 'claude'
|
||||
})
|
||||
})
|
||||
const added = await page.evaluate(() =>
|
||||
window.api.runtimeEnvironments.wslHosts?.add({ distro: 'Ubuntu' })
|
||||
)
|
||||
expect(added, JSON.stringify(added)).toMatchObject({ ok: true })
|
||||
await callEnvironment(page, WSL_ENVIRONMENT_ID, 'repo.add', {
|
||||
path: projectPath,
|
||||
kind: 'git'
|
||||
})
|
||||
await expect.poll(() => focusProject(page, projectPath), { timeout: 60_000 }).toBeTruthy()
|
||||
await page.getByRole('button', { name: 'New tab', exact: true }).first().click()
|
||||
await page
|
||||
.getByRole('menu')
|
||||
.getByText('Claude', { exact: true })
|
||||
.first()
|
||||
.click({ timeout: 60_000 })
|
||||
const chat = page.locator('[data-native-chat-root="true"]')
|
||||
const composer = chat.getByRole('textbox')
|
||||
const sendButton = chat.getByRole('button', { name: 'Send', exact: true })
|
||||
const type = async (text: string): Promise<void> => {
|
||||
await expect(composer).toBeEditable({ timeout: 60_000 })
|
||||
await composer.click()
|
||||
await page.keyboard.type(text)
|
||||
await expect(composer).toContainText(text)
|
||||
}
|
||||
const replies = chat.getByText('Stub reply.')
|
||||
await type('hello')
|
||||
await sendButton.click()
|
||||
await expect(replies.first()).toBeVisible({ timeout: 120_000 })
|
||||
|
||||
// The distro stops; a runtime call (as a poll would) finds it stopped and parks the host.
|
||||
const connectsBeforeStop = fakeWslConnects(connectLogFile).length
|
||||
writeDistros(distrosFile, STOPPED)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
callEnvironment(page, WSL_ENVIRONMENT_ID, 'agentSession.createSupport', {
|
||||
worktree: `path:${projectPath}`,
|
||||
agent: 'claude'
|
||||
}).then(
|
||||
() => 'answered',
|
||||
(error: unknown) => String(error)
|
||||
),
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toContain('Ubuntu was stopped')
|
||||
const stoppedNotice = chat.getByText('Ubuntu is stopped', { exact: true })
|
||||
await expect(stoppedNotice).toBeVisible({ timeout: 30_000 })
|
||||
// Send waits for Start rather than failing against a stopped distro; while held, the button is
|
||||
// named by why it waits, as for any host hold.
|
||||
await type('while stopped')
|
||||
await expect(
|
||||
chat.getByRole('button', { name: 'Start Ubuntu to send.', exact: true })
|
||||
).toBeDisabled()
|
||||
await page.screenshot({ path: testInfo.outputPath('wsl-host-chat-parked.png') })
|
||||
expect(fakeWslConnects(connectLogFile)).toHaveLength(connectsBeforeStop)
|
||||
expect(readDistroState(distrosFile)).toBe('stopped')
|
||||
|
||||
await chat.getByRole('button', { name: 'Start', exact: true }).click()
|
||||
await expect
|
||||
.poll(() => fakeWslConnects(connectLogFile).length, { timeout: 60_000 })
|
||||
.toBeGreaterThan(connectsBeforeStop)
|
||||
await expect(stoppedNotice).toBeHidden({ timeout: 60_000 })
|
||||
await expect(sendButton).toBeEnabled({ timeout: 60_000 })
|
||||
await sendButton.click()
|
||||
// The held message goes out after Start and gets its own reply.
|
||||
const held = chat
|
||||
.locator('[data-native-chat-scroll]')
|
||||
.getByText('while stopped', { exact: true })
|
||||
await expect(
|
||||
held.last().locator('xpath=following::*[text()="Stub reply."]').first()
|
||||
).toBeVisible({ timeout: 120_000 })
|
||||
} finally {
|
||||
await desktop.dispose()
|
||||
rmSync(fakeDir, { recursive: true, force: true })
|
||||
rmSync(projectPath, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user