test(pwsh): verify prompt readiness after scrolling and restart

This commit is contained in:
Tianyi Cui committed 2026-10-06 10:41:16 +08:00
1 parent 9a93c1f0dd
commit 13b4f716e7
4 files changed
+23 -21

No files matched your search

@@ -33,8 +33,8 @@
en: 8d94a7381bca3fa9
zh: f4bd2866fff6fa8d
/deepseek-ai-dsh-tool-pwsh-persistent/understand-the-implementation/design-philosophy:
en: b9b75c3251f29f02
zh: 9114aad4726f6d0c
en: e03cb450010e45db
zh: dddef1a9aad7ffe9
/deepseek-ai-dsh-tool-pwsh-persistent/understand-the-implementation/source-map:
en: 857e036f41c913ac
zh: e9f383f7ee384c59
@@ -76,6 +76,7 @@ This section explains the design decisions behind the tool and points at the cod
- **A deliberate twin of `dsh-tool-bash-persistent`.** The session registry, polling loop, and reset contract mirror the persistent bash tool by design ([pwsh persistent PTY Agent Note](../../../.agents/notes/archived/architecture/2026-08-11-pwsh-persistent-pty.md)).
- **Backend-owned prompt readiness.** The shell's `prompt` function belongs to the backend: it prints a BEL-terminated OSC marker plus the controlled printable prompt. The backend accepts recognized prompt text through its fast path and otherwise uses its own readiness checks or silence tier. The tool neither installs nor matches a prompt of its own.
- **Readiness after scrolling and restart.** The [Loader composition test](tests/loader-composition.spec.ts) checks the complete send-settlement sequence, including a command on the same shell after large output and the first command after `exit`. Its failure timeline distinguishes the bundled OpenConsole used on Windows from the unused system conhost.
- **PSReadLine echo stripped by anchoring.** PowerShell renders submitted input back into the stream; the marker-anchored extraction and a wrapper-source strip remove the echo, and a wrapper that wraps across the terminal width may leave a partial echo in partial-output results.
- **Reset, never repair.** Any uncertain state — an explicit `exit`, a timeout, a send failure, an abort — closes the shell and starts the next call fresh.
@@ -76,6 +76,7 @@ kind: "package-reference"
- **`dsh-tool-bash-persistent` 的刻意孪生。** 会话注册表、轮询循环与重置约定按设计镜像持久 bash 工具([pwsh 持久 PTY Agent Note](../../../.agents/notes/archived/architecture/2026-08-11-pwsh-persistent-pty.md))。
- **后端拥有提示符就绪。** shell 的 `prompt` 函数属于后端:它打印 BEL 结尾的 OSC 标记加受控的可打印提示符。后端通过快路径接受已识别的提示符文本,否则使用其自身的就绪检查或静默层级。工具既不安装也不匹配自己的提示符。
- **滚屏与重启后的就绪。** [Loader 组合测试](tests/loader-composition.spec.ts)检查完整的 send 结算序列,包括大量输出后在同一 shell 上执行的命令,以及 `exit` 后的首条命令。失败时间线会区分 Windows 实际使用的自带 OpenConsole 与未使用的系统 conhost。
- **PSReadLine 回显靠锚定剥离。** PowerShell 会把提交的输入渲染回流中;标记锚定提取与包装源码剥离移除回显,而跨终端宽度换行的包装可能在部分输出结果中留下部分回显。
- **重置,而非修复。** 任何不确定状态——显式 `exit`、超时、发送失败、中止——都会关闭 shell 并让下一次调用从全新状态开始。
@@ -32,12 +32,11 @@ const hasPwsh = spawnSync(
{ encoding: 'utf8' },
).status === 0
// Shell and console-host versions for a failure message: the Windows console host, not this
// process, decides how the prompt marker and its tail reach the session, and its build on the
// self-hosted pool is unknown.
// The system conhost version identifies the host OS; Windows PTYs use node-pty's bundled
// OpenConsole, so that version does not identify the console rendering these sessions.
const HOST_FACTS_COMMAND = [
'"pwsh $($PSVersionTable.PSVersion) PSReadLine $((Get-Module PSReadLine -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1).Version)"',
'if ($env:OS -eq \'Windows_NT\') { "conhost $((Get-Item (Join-Path $env:SystemRoot \'System32\\conhost.exe\')).VersionInfo.FileVersion)" }',
'if ($env:OS -eq \'Windows_NT\') { "system conhost (unused) $((Get-Item (Join-Path $env:SystemRoot \'System32\\conhost.exe\')).VersionInfo.FileVersion)" }',
].join('; ')
function hostFacts(): string {
@@ -51,9 +50,9 @@ function hostFacts(): string {
return [
`host: ${process.platform} ${process.arch} ${release()} (${version()}); node ${process.version}`,
`shell: ${pwshPath}; ${shell}`,
// dsh-subprocess-local spawns node-pty without useConptyDll, so the system console host,
// not the OpenConsole build node-pty bundles, renders every session.
'conpty: system conhost',
process.platform === 'win32'
? 'conpty: node-pty bundled OpenConsole (useConptyDll=true)'
: 'conpty: not applicable (POSIX PTY)',
].join('\n')
}
@@ -248,22 +247,23 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp
expect(large).toContain('<response clipped>')
expect(large).not.toContain('beginning of this command output was dropped')
const afterScroll = text(await execute('after-scroll', 'Write-Output "cwd=$PWD keep=$env:KEEP"'))
expect(afterScroll).toBe(`cwd=${join(root, 'nested')} keep=loader`)
const exited = text(await execute('exit', 'exit'))
expect(exited).toContain('next pwsh call starts from the workspace')
expect(text(await execute('after-exit', 'Write-Output "$PWD"'))).toBe(root)
// Six commands settle on the controlled prompt; no send may fall back to the
// silence tier, which is the 3.3 s-per-call degradation this suite pins. The
// counts alone do not say which tier settled which send, so every reason the
// run recorded and the per-send timeline ride in the failure message (the
// self-hosted Windows lane reported one to two stdin_read settlements per
// run on 2026-09-25..27, every other send at the plain silence bound). The
// message is built only when an assertion is about to fail: formatting runs
// the host-facts shell probe, which a passing run must not pay for.
const stdinReads = settleReasons.filter(reason => reason === 'stdin_read').length
const degraded = stdinReads < 6 || settleReasons.includes('inferred_idle')
// Each ordinary command must settle on the prompt, including the next command on the
// scrolled shell and the first command after restart. Extra sends can conceal a timeout.
// Formatting probes host versions, so only a failed sequence pays that cost.
const expected: TerminalWaitReason[] = [
'stdin_read', 'stdin_read', 'stdin_read', 'stdin_read',
'stdin_read', 'stdin_read', 'session_exit', 'stdin_read',
]
const degraded = settleReasons.length !== expected.length
|| settleReasons.some((reason, index) => reason !== expected[index])
const failure = degraded ? `${JSON.stringify(settleReasons)}\n${timeline.format()}` : ''
expect(stdinReads, failure).toBeGreaterThanOrEqual(6)
expect(settleReasons, failure).not.toContain('inferred_idle')
expect(settleReasons, failure).toEqual(expected)
}, 120_000)
})