diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index f7e242b78c..4c73d590af 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-16-persistent-pty-sessions.md: 148d4a2f47689e38a3ec83a7a41e4f75c4b73d95 -2026-07-16-persistent-pty-sessions.zh.md: 9a9d9cd4b0f61e8abaf011996ecd8739d13851f8 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +2026-07-16-persistent-pty-sessions.md: 43c87bb159cfe1ab9f8d3a80c2adf25a57ae6e3b +2026-07-16-persistent-pty-sessions.zh.md: 8afc2103447cc58b1fcbc1062b9564e8ed643477 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index 148d4a2f47..43c87bb159 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -154,7 +154,7 @@ The package ships concise tool guidance explaining persistent state, owner isola - Per-file coverage pins owner fencing, concurrent reservations, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents. - Linux process fixtures cover non-leader and non-main-thread stdin waits, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite. -- Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts. +- Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT` after deliberately delayed child readiness under scenario-owned timing bounds, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts. - A Loader-driven `cordis.yml` test mounts the real three-package composition. ACP and headless snapshots pin the six schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation. - Package contracts, the architecture map, core data structures, generated catalogs, and the website API describe the same shipped surface. - The repository CI-equivalent sequence owns type, lint, coverage, snapshot, documentation, build, hygiene, demo, and built-entry verification. diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 9a9d9cd4b0..8afc210344 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -154,7 +154,7 @@ plugins: - 每文件覆盖率固定 owner 隔离、并发预留、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。 - Linux 进程 fixture 覆盖非 leader 与非主线程的 stdin 等待、僵尸进程静止性、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 -- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、raw mode 下的前台 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即静默。 +- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、在由场景掌控的时间界限内先有意延迟子进程就绪,再对 raw mode 前台进程发送 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即完全停稳。 - Loader 驱动的 `cordis.yml` 测试挂载真实三包组合。ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。 - 包契约、架构图、核心数据结构、生成目录和 website API 描述同一个已发布接口。 - 仓库 CI 等价序列负责类型、lint、覆盖率、快照、文档、构建、hygiene、demo 和 built-entry 验证。 diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml index 133198a4d2..d491394db7 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-18-tui-terminal-state-snapshots.md: 8e86588f69fdb9d615232252ecf57309d440f1cd -2026-07-18-tui-terminal-state-snapshots.zh.md: b70a46830f44e9da663e30745fcdb7ad281592da +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md +2026-07-18-tui-terminal-state-snapshots.md: 18c79bc2d0dabf4d78887354f30a2cdc083899e1 +2026-07-18-tui-terminal-state-snapshots.zh.md: d1d4a6ca859e94a153e0bf645a17f03c0dac234b diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md index 8e86588f69..18c79bc2d0 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md @@ -33,7 +33,7 @@ The live-model fixtures use `DSH_SNAPSHOT=record`; record mode rewrites their pr ### Semantic terminal projection -The package-local `HeadlessTerminal` implements the same pi-tui `Terminal` interface as the process terminal and feeds every ANSI write into the pinned `@xterm/headless` parser. Snapshot code waits for synchronized frames to quiesce before reading state, so a checkpoint represents a completed screen rather than a timer-dependent write prefix. +The package-local `HeadlessTerminal` implements the same pi-tui `Terminal` interface as the process terminal and feeds every ANSI write into the pinned `@xterm/headless` parser. Snapshot code waits for synchronized frames to quiesce before reading state. The streaming checkpoint freezes the loader interval while allowing real wall-clock delay across one animation tick, so it pins semantic status rather than whichever spinner glyph the scheduler happened to render. Each expected output projects dimensions, active-buffer and viewport coordinates, lifecycle and cursor state, rows, wrap markers, and non-default style ranges into text. Scroll-heavy cards capture the used buffer; overlays capture the visible viewport. Text and style remain separate so a reviewer can distinguish content changes from presentation changes without decoding ANSI bytes. diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md index b70a46830f..d1d4a6ca85 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md @@ -33,7 +33,7 @@ TUI 覆盖分为四个互补层次: ### 语义终端投影 -包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。读取状态前,快照代码会等待同步帧稳定,因此每个检查点表示已经完成的画面,而不是依赖计时的写入前缀。 +包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。读取状态前,快照代码会等待同步帧稳定。流式输出检查点会冻结 loader 的 interval,同时保留跨过一次动画 tick 的真实墙钟等待,从而固定语义状态,而非调度器碰巧渲染出的某个加载动画字形。 每份预期输出把终端尺寸、活动缓冲区和视口坐标、生命周期与光标状态、各行、换行标记以及非默认样式区间投影为文本。滚动内容较多的卡片捕获已使用缓冲区;浮层捕获可见视口。文本和样式相互分离,评审人无需解码 ANSI 字节即可区分内容变化与呈现变化。 diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 6ba3a95757..8c1c58f319 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -39,7 +39,10 @@ function stubAgent(ctx: Context, rawId: string): Agent { } } -async function harness(mode: 'danger-full-access' | 'workspace-write') { +async function harness( + mode: 'danger-full-access' | 'workspace-write', + timing: { idleSilenceMs?: number; timeoutMs?: number } = {}, +) { const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-')) roots.push(root) const ctx = new Context() @@ -51,8 +54,8 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') { const fiber = await ctx.plugin(ptyLocal, { pollIntervalMs: 10, exactProbeAfterMs: 20, - idleSilenceMs: 250, - timeoutMs: 2000, + idleSilenceMs: timing.idleSilenceMs ?? 250, + timeoutMs: timing.timeoutMs ?? 2_000, disposeGraceMs: 500, scrollbackLines: 100, scrollbackMaxBytes: 32_768, @@ -63,8 +66,8 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') { return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox } } -async function waitForOutput(operation: PtySendOperation, expected: string): Promise { - const deadline = Date.now() + 2_000 +async function waitForOutput(operation: PtySendOperation, expected: string, timeoutMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs let output = '' while (!output.includes(expected) && Date.now() < deadline) { output += operation.readOutput().delta @@ -131,20 +134,25 @@ describe('pty-local real shell', () => { expect(() => process.kill(pid, 0)).toThrow() }, 10_000) - it('cancels a raw-mode foreground process with a real SIGINT', async () => { - const { ctx, agent } = await harness('danger-full-access') + it('cancels a slow-starting raw-mode foreground process with a real SIGINT', async () => { + const { ctx, agent } = await harness('danger-full-access', { + idleSilenceMs: 10_000, + timeoutMs: 15_000, + }) const created = await ctx.pty.spawn(agent, { type: 'shell' }) const controller = new AbortController() const ready = 'RAW_READY' + // Delay readiness beyond the shared harness's short send bound so this + // process test owns enough slack for loaded macOS startup and shell echo. // The interactive shell echoes the command, so only child output may contain the readiness marker. - const command = 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); print("RAW_" + "READY", flush=True); time.sleep(60)\'' + const command = 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); time.sleep(2.1); print("RAW_" + "READY", flush=True); time.sleep(60)\'' expect(command).not.toContain(ready) const foreground = ctx.pty.startSend(agent, created.sessionId, { text: command, submit: true, signal: controller.signal, }) - await waitForOutput(foreground, ready) + await waitForOutput(foreground, ready, 15_000) controller.abort() const result = await foreground.done expect(result.waitReason).toBe('stdin_read') @@ -155,5 +163,5 @@ describe('pty-local real shell', () => { expect(after.viewport).toContain('AFTER_SIGINT') expect(after.waitReason).toBe('stdin_read') await ctx.pty.kill(agent, created.sessionId) - }, 10_000) + }, 20_000) }) diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 1dc6ffa7cd..3bc5adc366 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -228,33 +228,45 @@ const DISPLAYED_CONTROL_PROBE = String.raw`\x1b]2;snapshot-controlled\x07\x09\x7 describe('TUI terminal-state snapshots', () => { it('pins an in-flight reasoning and Markdown stream', async () => { const harness = await setupSnapshot() - await renderAfter(harness, () => { - harness.agent.status = 'running' - harness.ctx.emit('agent/status', harness.agent, 'running') - appendUser(harness.session, 'Show the live update.') - harness.session.append('assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'block-start', index: 0, blockType: 'reasoning' }, + // Freeze the loader's first animation interval so this semantic snapshot + // cannot select a different spinner frame under scheduler contention. + const frozenLoaderTimer = setInterval(() => {}, 60_000) + const intervals = vi.spyOn(globalThis, 'setInterval').mockImplementationOnce(() => frozenLoaderTimer) + try { + await renderAfter(harness, () => { + harness.agent.status = 'running' + harness.ctx.emit('agent/status', harness.agent, 'running') + appendUser(harness.session, 'Show the live update.') + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'block-start', index: 0, blockType: 'reasoning' }, + }) + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' }, + }) + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'block-start', index: 1, blockType: 'text' }, + }) + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' }, + }) }) - harness.session.append('assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' }, - }) - harness.session.append('assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'block-start', index: 1, blockType: 'text' }, - }) - harness.session.append('assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' }, - }) - }) - await checkpoint('conversation-streaming', harness.terminal) - await disposeSnapshot(harness) + const loaderIntervalMs = intervals.mock.calls[0]?.[1] + if (typeof loaderIntervalMs !== 'number') throw new Error('TUI loader did not register an animation interval') + await new Promise(resolve => setTimeout(resolve, loaderIntervalMs + 5)) + await checkpoint('conversation-streaming', harness.terminal) + } finally { + intervals.mockRestore() + clearInterval(frozenLoaderTimer) + await disposeSnapshot(harness) + } }) it('pins failed-stream retraction, scheduled retry, and eventual success', async () => {