Merge branch 'master' into worktree/web-session-model-selector
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
# 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;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write
|
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
|
||||||
2026-07-16-persistent-pty-sessions.md: 148d4a2f47689e38a3ec83a7a41e4f75c4b73d95
|
2026-07-16-persistent-pty-sessions.md: 43c87bb159cfe1ab9f8d3a80c2adf25a57ae6e3b
|
||||||
2026-07-16-persistent-pty-sessions.zh.md: 9a9d9cd4b0f61e8abaf011996ecd8739d13851f8
|
2026-07-16-persistent-pty-sessions.zh.md: 8afc2103447cc58b1fcbc1062b9564e8ed643477
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- The repository CI-equivalent sequence owns type, lint, coverage, snapshot, documentation, build, hygiene, demo, and built-entry verification.
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ plugins:
|
|||||||
|
|
||||||
- 每文件覆盖率固定 owner 隔离、并发预留、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。
|
- 每文件覆盖率固定 owner 隔离、并发预留、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。
|
||||||
- Linux 进程 fixture 覆盖非 leader 与非主线程的 stdin 等待、僵尸进程静止性、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。
|
- 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 卡片展示。
|
- Loader 驱动的 `cordis.yml` 测试挂载真实三包组合。ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。
|
||||||
- 包契约、架构图、核心数据结构、生成目录和 website API 描述同一个已发布接口。
|
- 包契约、架构图、核心数据结构、生成目录和 website API 描述同一个已发布接口。
|
||||||
- 仓库 CI 等价序列负责类型、lint、覆盖率、快照、文档、构建、hygiene、demo 和 built-entry 验证。
|
- 仓库 CI 等价序列负责类型、lint、覆盖率、快照、文档、构建、hygiene、demo 和 built-entry 验证。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
# 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;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write
|
# 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: 8e86588f69fdb9d615232252ecf57309d440f1cd
|
2026-07-18-tui-terminal-state-snapshots.md: 18c79bc2d0dabf4d78887354f30a2cdc083899e1
|
||||||
2026-07-18-tui-terminal-state-snapshots.zh.md: b70a46830f44e9da663e30745fcdb7ad281592da
|
2026-07-18-tui-terminal-state-snapshots.zh.md: d1d4a6ca859e94a153e0bf645a17f03c0dac234b
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ The live-model fixtures use `DSH_SNAPSHOT=record`; record mode rewrites their pr
|
|||||||
|
|
||||||
### Semantic terminal projection
|
### 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ TUI 覆盖分为四个互补层次:
|
|||||||
|
|
||||||
### 语义终端投影
|
### 语义终端投影
|
||||||
|
|
||||||
包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。读取状态前,快照代码会等待同步帧稳定,因此每个检查点表示已经完成的画面,而不是依赖计时的写入前缀。
|
包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。读取状态前,快照代码会等待同步帧稳定。流式输出检查点会冻结 loader 的 interval,同时保留跨过一次动画 tick 的真实墙钟等待,从而固定语义状态,而非调度器碰巧渲染出的某个加载动画字形。
|
||||||
|
|
||||||
每份预期输出把终端尺寸、活动缓冲区和视口坐标、生命周期与光标状态、各行、换行标记以及非默认样式区间投影为文本。滚动内容较多的卡片捕获已使用缓冲区;浮层捕获可见视口。文本和样式相互分离,评审人无需解码 ANSI 字节即可区分内容变化与呈现变化。
|
每份预期输出把终端尺寸、活动缓冲区和视口坐标、生命周期与光标状态、各行、换行标记以及非默认样式区间投影为文本。滚动内容较多的卡片捕获已使用缓冲区;浮层捕获可见视口。文本和样式相互分离,评审人无需解码 ANSI 字节即可区分内容变化与呈现变化。
|
||||||
|
|
||||||
|
|||||||
@@ -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-'))
|
const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-'))
|
||||||
roots.push(root)
|
roots.push(root)
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
@@ -51,8 +54,8 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') {
|
|||||||
const fiber = await ctx.plugin(ptyLocal, {
|
const fiber = await ctx.plugin(ptyLocal, {
|
||||||
pollIntervalMs: 10,
|
pollIntervalMs: 10,
|
||||||
exactProbeAfterMs: 20,
|
exactProbeAfterMs: 20,
|
||||||
idleSilenceMs: 250,
|
idleSilenceMs: timing.idleSilenceMs ?? 250,
|
||||||
timeoutMs: 2000,
|
timeoutMs: timing.timeoutMs ?? 2_000,
|
||||||
disposeGraceMs: 500,
|
disposeGraceMs: 500,
|
||||||
scrollbackLines: 100,
|
scrollbackLines: 100,
|
||||||
scrollbackMaxBytes: 32_768,
|
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 }
|
return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForOutput(operation: PtySendOperation, expected: string): Promise<void> {
|
async function waitForOutput(operation: PtySendOperation, expected: string, timeoutMs = 2_000): Promise<void> {
|
||||||
const deadline = Date.now() + 2_000
|
const deadline = Date.now() + timeoutMs
|
||||||
let output = ''
|
let output = ''
|
||||||
while (!output.includes(expected) && Date.now() < deadline) {
|
while (!output.includes(expected) && Date.now() < deadline) {
|
||||||
output += operation.readOutput().delta
|
output += operation.readOutput().delta
|
||||||
@@ -131,20 +134,25 @@ describe('pty-local real shell', () => {
|
|||||||
expect(() => process.kill(pid, 0)).toThrow()
|
expect(() => process.kill(pid, 0)).toThrow()
|
||||||
}, 10_000)
|
}, 10_000)
|
||||||
|
|
||||||
it('cancels a raw-mode foreground process with a real SIGINT', async () => {
|
it('cancels a slow-starting raw-mode foreground process with a real SIGINT', async () => {
|
||||||
const { ctx, agent } = await harness('danger-full-access')
|
const { ctx, agent } = await harness('danger-full-access', {
|
||||||
|
idleSilenceMs: 10_000,
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
})
|
||||||
const created = await ctx.pty.spawn(agent, { type: 'shell' })
|
const created = await ctx.pty.spawn(agent, { type: 'shell' })
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const ready = 'RAW_READY'
|
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.
|
// 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)
|
expect(command).not.toContain(ready)
|
||||||
const foreground = ctx.pty.startSend(agent, created.sessionId, {
|
const foreground = ctx.pty.startSend(agent, created.sessionId, {
|
||||||
text: command,
|
text: command,
|
||||||
submit: true,
|
submit: true,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
await waitForOutput(foreground, ready)
|
await waitForOutput(foreground, ready, 15_000)
|
||||||
controller.abort()
|
controller.abort()
|
||||||
const result = await foreground.done
|
const result = await foreground.done
|
||||||
expect(result.waitReason).toBe('stdin_read')
|
expect(result.waitReason).toBe('stdin_read')
|
||||||
@@ -155,5 +163,5 @@ describe('pty-local real shell', () => {
|
|||||||
expect(after.viewport).toContain('AFTER_SIGINT')
|
expect(after.viewport).toContain('AFTER_SIGINT')
|
||||||
expect(after.waitReason).toBe('stdin_read')
|
expect(after.waitReason).toBe('stdin_read')
|
||||||
await ctx.pty.kill(agent, created.sessionId)
|
await ctx.pty.kill(agent, created.sessionId)
|
||||||
}, 10_000)
|
}, 20_000)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -229,33 +229,45 @@ const DISPLAYED_CONTROL_PROBE = String.raw`\x1b]2;snapshot-controlled\x07\x09\x7
|
|||||||
describe('TUI terminal-state snapshots', () => {
|
describe('TUI terminal-state snapshots', () => {
|
||||||
it('pins an in-flight reasoning and Markdown stream', async () => {
|
it('pins an in-flight reasoning and Markdown stream', async () => {
|
||||||
const harness = await setupSnapshot()
|
const harness = await setupSnapshot()
|
||||||
await renderAfter(harness, () => {
|
// Freeze the loader's first animation interval so this semantic snapshot
|
||||||
harness.agent.status = 'running'
|
// cannot select a different spinner frame under scheduler contention.
|
||||||
harness.ctx.emit('agent/status', harness.agent, 'running')
|
const frozenLoaderTimer = setInterval(() => {}, 60_000)
|
||||||
appendUser(harness.session, 'Show the live update.')
|
const intervals = vi.spyOn(globalThis, 'setInterval').mockImplementationOnce(() => frozenLoaderTimer)
|
||||||
harness.session.append('assistant/chunk', {
|
try {
|
||||||
turn: 1,
|
await renderAfter(harness, () => {
|
||||||
step: 1,
|
harness.agent.status = 'running'
|
||||||
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
|
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', {
|
const loaderIntervalMs = intervals.mock.calls[0]?.[1]
|
||||||
turn: 1,
|
if (typeof loaderIntervalMs !== 'number') throw new Error('TUI loader did not register an animation interval')
|
||||||
step: 1,
|
await new Promise(resolve => setTimeout(resolve, loaderIntervalMs + 5))
|
||||||
chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' },
|
await checkpoint('conversation-streaming', harness.terminal)
|
||||||
})
|
} finally {
|
||||||
harness.session.append('assistant/chunk', {
|
intervals.mockRestore()
|
||||||
turn: 1,
|
clearInterval(frozenLoaderTimer)
|
||||||
step: 1,
|
await disposeSnapshot(harness)
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pins failed-stream retraction, scheduled retry, and eventual success', async () => {
|
it('pins failed-stream retraction, scheduled retry, and eventual success', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user