@hyzyn/dsh-tty 0.22.3 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,6 +41,9 @@
41
41
  * B34. 退出后的只读保留(D77):命令跑完仍可读、写被拒、tty_close 释放
42
42
  * B35. `tty_open command=` 执行整段 shell 代码(D78):`a; b` / `cd x && cmd` /
43
43
  * `for` 不被 exec 截断,退出码取自最后一条
44
+ * B36. agent 单会话闭环(0.23.0):tty_list 的 running / lastExitCode(真 shell 集成
45
+ * 的 A/B/D 标记)、tty_send 的具名 keys(Enter 提交 / C-c 打断 / 未知名报错)、
46
+ * tty_run 一条命令一次调用(输出+退出码+默认关会话 / 超时不杀 / keep 保留)
44
47
  *
45
48
  * 用法:pnpm --filter @hyzyn/dsh-tty integration
46
49
  * 退出码:0 = 全部 PASS,1 = 任一 FAIL。
@@ -1582,8 +1585,9 @@ async function run() {
1582
1585
  if (on.status === 200 && on.body.config?.enabled === true) pass('B29h 重新启用生效')
1583
1586
  else fail('B29h 重新启用生效', `status=${String(on.status)}`)
1584
1587
  await sleep(100)
1585
- if (toolDefs.length === 16) pass('B29i 重新启用后 16 个 agent 工具回归')
1586
- else fail('B29i 重新启用后 16 个 agent 工具回归', `当前 ${String(toolDefs.length)}`)
1588
+ // 0.23.0:tty_run 加入后是 17 个
1589
+ if (toolDefs.length === 17) pass('B29i 重新启用后 17 个 agent 工具回归')
1590
+ else fail('B29i 重新启用后 17 个 agent 工具回归', `当前 ${String(toolDefs.length)}`)
1587
1591
  if (promptParts.length === 2) pass('B29j 重新启用后公告与动态快照恢复')
1588
1592
  else fail('B29j 重新启用后公告与动态快照恢复', `当前 ${String(promptParts.length)}`)
1589
1593
  const again = openSession(port)
@@ -2027,6 +2031,160 @@ async function run() {
2027
2031
  }
2028
2032
  }
2029
2033
 
2034
+ // B36: agent 单会话闭环(0.23.0)—— tty_list 的 running / lastExitCode、tty_send 的
2035
+ // 具名 keys、tty_run 的一条命令一次调用。
2036
+ //
2037
+ // 这一节要的是**真 shell 集成**:running 的判据建立在 OSC 133 标记上,假 PTY 测不出
2038
+ // 「标记真的会到、命令真的会结束」。这里用真 PTY 里跑的 zsh/bash 走一遍。
2039
+ console.log('\n[34] agent 单会话闭环(0.23.0)')
2040
+ {
2041
+ const open36 = toolDefs.find((d) => d.name === 'tty_open')
2042
+ const close36 = toolDefs.find((d) => d.name === 'tty_close')
2043
+ const list36 = toolDefs.find((d) => d.name === 'tty_list')
2044
+ const capture36 = toolDefs.find((d) => d.name === 'tty_capture')
2045
+ const send36 = toolDefs.find((d) => d.name === 'tty_send')
2046
+ const run36 = toolDefs.find((d) => d.name === 'tty_run')
2047
+ if (open36 === undefined || close36 === undefined || list36 === undefined || capture36 === undefined || send36 === undefined || run36 === undefined) {
2048
+ fail('B36 0.23.0 工具集', '缺工具: ' + toolDefs.map((d) => d.name).join(','))
2049
+ } else {
2050
+ const opened = []
2051
+ const entryOf = async (sid) => ((await list36.execute({})).sessions ?? []).find((x) => x.sid === sid) ?? null
2052
+ /** 轮询到谓词为真(工具侧的效果随输出流异步到达)。 */
2053
+ const until = async (fn, ms = 10000) => {
2054
+ const deadline = Date.now() + ms
2055
+ for (;;) {
2056
+ let value = null
2057
+ try {
2058
+ value = await fn()
2059
+ } catch {
2060
+ value = null
2061
+ }
2062
+ if (value) return value
2063
+ if (Date.now() > deadline) return null
2064
+ await sleep(150)
2065
+ }
2066
+ }
2067
+ try {
2068
+ // ① 交互会话:提示符上 running=false(A 标记到了)→ 发命令 running=true →
2069
+ // 跑完 running=false 且 lastExitCode/lastExitAt 就位
2070
+ const interactive = await open36.execute({ cwd: '/tmp' })
2071
+ opened.push(interactive.sid)
2072
+ const atPrompt = await until(async () => {
2073
+ const e = await entryOf(interactive.sid)
2074
+ return e !== null && e.running === false ? e : null
2075
+ })
2076
+ if (atPrompt !== null) pass('B36a 交互会话在提示符上 running=false(真 shell 集成标记到了)')
2077
+ else fail('B36a 交互会话在提示符上 running=false', JSON.stringify(await entryOf(interactive.sid)))
2078
+
2079
+ await send36.execute({ sid: interactive.sid, data: 'sleep 2; echo LOOPDONE\n' })
2080
+ const busy = await until(async () => {
2081
+ const e = await entryOf(interactive.sid)
2082
+ return e !== null && e.running === true ? e : null
2083
+ })
2084
+ if (busy !== null) pass('B36b 命令在跑时 running=true(B 标记之后、D 之前)')
2085
+ else fail('B36b 命令在跑时 running=true', JSON.stringify(await entryOf(interactive.sid)))
2086
+
2087
+ const settled = await until(async () => {
2088
+ const e = await entryOf(interactive.sid)
2089
+ return e !== null && e.running === false && e.lastExitCode === 0 ? e : null
2090
+ }, 15000)
2091
+ if (settled !== null && typeof settled.lastExitAt === 'number') pass('B36c 命令跑完 running=false 且 lastExitCode/lastExitAt 就位')
2092
+ else fail('B36c 命令跑完 running=false 且 lastExitCode/lastExitAt 就位', JSON.stringify(await entryOf(interactive.sid)))
2093
+
2094
+ // ② 命令型会话(不注入钩子):活着就是在跑——判据不能只依赖 shell 集成
2095
+ const commandSession = await open36.execute({ cwd: '/tmp', command: 'sleep 10' })
2096
+ opened.push(commandSession.sid)
2097
+ const cmdEntry = await until(async () => {
2098
+ const e = await entryOf(commandSession.sid)
2099
+ return e !== null && e.running === true ? e : null
2100
+ })
2101
+ if (cmdEntry !== null) pass('B36d 命令型会话活着即 running=true(不依赖 shell 集成)')
2102
+ else fail('B36d 命令型会话活着即 running=true', JSON.stringify(await entryOf(commandSession.sid)))
2103
+
2104
+ // ③ keys:Enter 提交命令;C-c 打断正在跑的命令;未知名报错
2105
+ await send36.execute({ sid: interactive.sid, data: "printf 'KEYS_%s\\n' OK", keys: ['Enter'] })
2106
+ const keyTail = await until(async () => {
2107
+ const out = await capture36.execute({ sid: interactive.sid, lines: 60 })
2108
+ return String(out.tail ?? '').includes('KEYS_OK') ? out : null
2109
+ })
2110
+ if (keyTail !== null) pass('B36e keys 里的 Enter 真的提交了命令行(printf + keys:[Enter])')
2111
+ else fail('B36e keys 里的 Enter 提交命令行', JSON.stringify(await capture36.execute({ sid: interactive.sid, lines: 40 })).slice(0, 160))
2112
+
2113
+ await send36.execute({ sid: interactive.sid, data: 'sleep 30\n' })
2114
+ await until(async () => (await entryOf(interactive.sid))?.running === true, 8000)
2115
+ await send36.execute({ sid: interactive.sid, keys: ['C-c'] })
2116
+ const interrupted = await until(async () => {
2117
+ const e = await entryOf(interactive.sid)
2118
+ return e !== null && e.running === false ? e : null
2119
+ }, 10000)
2120
+ if (interrupted !== null) pass('B36f keys 里的 C-c 打断了正在跑的命令(提示符回来了)')
2121
+ else fail('B36f keys 里的 C-c 打断命令', JSON.stringify(await entryOf(interactive.sid)))
2122
+
2123
+ let keyRefused = false
2124
+ try {
2125
+ await send36.execute({ sid: interactive.sid, keys: ['DownArrow'] })
2126
+ } catch {
2127
+ keyRefused = true
2128
+ }
2129
+ if (keyRefused) pass('B36g 未知按键名明确报错(不静默当字面量发出去)')
2130
+ else fail('B36g 未知按键名明确报错', '居然发出去了')
2131
+
2132
+ // ④ tty_run:一条命令一次调用
2133
+ const ran = await run36.execute({ command: "printf 'RUN_%s\\n' OK; exit 7", cwd: '/tmp', timeoutSec: 30 })
2134
+ const runGone = !((await list36.execute({})).sessions ?? []).some((x) => x.sid === ran.sid)
2135
+ if (ran.running === false && ran.exitCode === 7 && ran.closed === true && String(ran.tail).includes('RUN_OK') && runGone) {
2136
+ pass('B36h tty_run 一次调用拿回输出+退出码并关掉会话')
2137
+ } else {
2138
+ fail('B36h tty_run 一次调用拿回输出+退出码并关掉会话', JSON.stringify({ ...ran, tail: String(ran.tail).slice(0, 80) }))
2139
+ }
2140
+
2141
+ const timedOut = await run36.execute({ command: 'sleep 30', cwd: '/tmp', timeoutSec: 1 })
2142
+ const stillThere = await entryOf(timedOut.sid)
2143
+ if (timedOut.running === true && timedOut.closed === false && stillThere !== null && stillThere.exited === undefined) {
2144
+ pass('B36i tty_run 超时回 running:true 且不杀会话')
2145
+ opened.push(timedOut.sid)
2146
+ } else {
2147
+ fail('B36i tty_run 超时回 running:true 且不杀会话', JSON.stringify({ timedOut, stillThere }))
2148
+ }
2149
+
2150
+ const kept = await run36.execute({ command: "printf 'KEEP_%s\\n' OK", cwd: '/tmp', timeoutSec: 30, keep: true })
2151
+ opened.push(kept.sid)
2152
+ const keptTail = await until(async () => {
2153
+ const out = await capture36.execute({ sid: kept.sid, lines: 40 })
2154
+ return String(out.tail ?? '').includes('KEEP_OK') ? out : null
2155
+ })
2156
+ if (kept.running === false && kept.closed === false && keptTail !== null && keptTail.exited === true) {
2157
+ pass('B36j tty_run keep:true 留在只读保留态且输出仍可读')
2158
+ } else {
2159
+ fail('B36j tty_run keep:true 留在只读保留态且输出仍可读', JSON.stringify({ kept, tail: String(keptTail?.tail ?? '').slice(0, 80) }))
2160
+ }
2161
+
2162
+ // ⑤ D87:命令型会话没有「上一条命令」——报错要指向 lines,别再把人引向
2163
+ // 「shell 不受支持 / 集成被配置关闭」(用户验收现场就是这么撞上的)
2164
+ let lastRefused = ''
2165
+ try {
2166
+ await capture36.execute({ sid: kept.sid, last: true })
2167
+ } catch (error) {
2168
+ lastRefused = String(error?.message ?? error)
2169
+ }
2170
+ if (lastRefused.includes('命令型会话') && lastRefused.includes('lines') && !lastRefused.includes('shell 不受支持或被配置关闭')) {
2171
+ pass('B36k 命令型会话的 last:true 报错指向 lines(D87)')
2172
+ } else {
2173
+ fail('B36k 命令型会话的 last:true 报错指向 lines(D87)', JSON.stringify(lastRefused).slice(0, 200))
2174
+ }
2175
+ } catch (error) {
2176
+ fail('B36 agent 单会话闭环', error.message)
2177
+ }
2178
+ for (const sid of opened) {
2179
+ try {
2180
+ await close36.execute({ sid })
2181
+ } catch {
2182
+ /* 已释放 */
2183
+ }
2184
+ }
2185
+ }
2186
+ }
2187
+
2030
2188
  // B33: 输出契约(所有已调用工具的真实返回值都符合各自声明的 output.schema)
2031
2189
  //
2032
2190
  // 这一条是 §B12e 的补充:B12e 只证明 schema 形状受支持,证明不了返回值合法。
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 测试夹具:把「代理命令已退出」与「它的 stderr 到手」**必然**拆成两拍。
4
+ *
5
+ * 为什么需要它:真机上这两件事的先后是**竞态**(负载下 stderr 的数据事件可能排在 exit/close
6
+ * 之后),而竞态在单测里没法用「跑得快一点」来复现——只会偶发红(tty D95 就是这么来的)。
7
+ * 这里用**结构**把顺序钉死:
8
+ * 1. 主进程立刻 `exit(1)` —— 父进程马上拿到「已退出(退出码 1)」这个事实;
9
+ * 2. stderr 交给一个 **detached** 的孙进程,120ms 后才写 —— 父进程那一刻手里**必然**没有 stderr。
10
+ *
11
+ * `detached: true` 不是随便加的:父进程在传输关闭时会 `kill(-pid)` 收掉代理命令的**整个进程组**,
12
+ * 孙进程不另起一组就会被一起杀掉,「晚到的 stderr」就永远不来(= 夹具失效、测试恒绿)。
13
+ *
14
+ * 用法:`node proxy-late-evidence.mjs`(不需要参数;它不读 stdin)。
15
+ */
16
+ import { spawn } from 'node:child_process'
17
+
18
+ const WRITE_AFTER_MS = 120
19
+
20
+ const grandchild = spawn(
21
+ process.execPath,
22
+ ['-e', `setTimeout(() => { process.stderr.write('LATE-EVIDENCE-9\\n'); process.exit(0) }, ${String(WRITE_AFTER_MS)})`],
23
+ // stderr 继承 → 写进**父进程的那根管道**(也就是代理命令的传输证据)
24
+ { stdio: ['ignore', 'ignore', 'inherit'], detached: true },
25
+ )
26
+ grandchild.unref()
27
+ process.exit(1)