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:
Neil
2026-10-29 03:46:05 -07:00
committed by GitHub
parent 83909a9b72
commit 557f281780
17 changed files with 241 additions and 24 deletions
@@ -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')
+10 -3
View File
@@ -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
}
)
}
}
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -18920,7 +18920,8 @@
"reconnect": "再接続",
"stopped": "{{distro}} は停止しています",
"start": "起動",
"unverifiable": "Orca は {{distro}} が実行中かどうかを確認できませんでした"
"unverifiable": "Orca は {{distro}} が実行中かどうかを確認できませんでした",
"startToSend": "送信するには {{distro}} を開始してください。"
},
"interruptedContinue": {
"continue": "続行",
+2 -1
View File
@@ -18920,7 +18920,8 @@
"reconnect": "다시 연결",
"stopped": "{{distro}}이(가) 중지되었습니다",
"start": "시작",
"unverifiable": "Orca가 {{distro}}이(가) 실행 중인지 확인하지 못했습니다"
"unverifiable": "Orca가 {{distro}}이(가) 실행 중인지 확인하지 못했습니다",
"startToSend": "보내려면 {{distro}}을(를) 시작하세요."
},
"interruptedContinue": {
"continue": "계속",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -19907,7 +19907,8 @@
"reconnect": "Подключиться снова",
"stopped": "{{distro}} остановлен",
"start": "Запустить",
"unverifiable": "Orca не удалось проверить, запущен ли {{distro}}"
"unverifiable": "Orca не удалось проверить, запущен ли {{distro}}",
"startToSend": "Запустите {{distro}}, чтобы отправить."
},
"interruptedContinue": {
"continue": "Продолжить",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -19032,7 +19032,8 @@
"reconnect": "重新連線",
"stopped": "{{distro}} 已停止",
"start": "啟動",
"unverifiable": "Orca 無法確認 {{distro}} 是否正在執行"
"unverifiable": "Orca 無法確認 {{distro}} 是否正在執行",
"startToSend": "請啟動 {{distro}} 後再傳送。"
},
"interruptedContinue": {
"continue": "繼續",
+2 -1
View File
@@ -18890,7 +18890,8 @@
"reconnect": "重新连接",
"stopped": "{{distro}} 已停止",
"start": "启动",
"unverifiable": "Orca 无法确认 {{distro}} 是否正在运行"
"unverifiable": "Orca 无法确认 {{distro}} 是否正在运行",
"startToSend": "请启动 {{distro}} 后再发送。"
},
"interruptedContinue": {
"continue": "继续",
+118
View File
@@ -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 })
}
})
})