Skip to content

fix(tools): argv the core can parse, and bounds before the spawn - #5

Merged
HarryHeYu merged 4 commits into
HarryHeYu:mainfrom
SunnyRad:fix/numeric-arg-bounds
Nov 11, 2026
Merged

HarryHeYu merged 4 commits into
HarryHeYu:mainfrom
SunnyRad:fix/numeric-arg-bounds

Conversation

@SunnyRad

@SunnyRad SunnyRad commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Three ways a model-supplied argument could make a tool fail badly, plus one fixture that was lying about what the core returns. Every claim below was measured against the real merge subparser and the real bridge, not against a mock.

This PR now has two commits; the first (numeric bounds) is unchanged.

1. sessionflow_merge built an argv argparse could not parse

Options were emitted in the space form with bare ids:

merge a:1 b:2 --goal g
goal what happened
fix the parser fine
--verbose SystemExit — argument --goal: expected one argument → CLI_FAILED
--- SystemExit, same
-x rest fine (single dash + text)

A session id beginning with - was likewise read as a flag. The core parses --goal=VALUE unambiguously, so options now use that form and ids follow a -- separator. Interior whitespace, CJK and an embedded = all survive (measured).

2. The trap inside that fix — --json

--json was appended at the END of argv. After a -- separator, argparse stops option parsing and reads it as a positional:

merge a --goal=g -- a:1 --json   ->   sessions=['a:1','--json'], json=False

The core would look for a session called --json and answer with no JSON at all. So merge places --json before the separator itself, and runJson gains jsonAlreadySet so the bridge does not append a second one. There is a dedicated test asserting the ordering, because getting it backwards produces a silent wrong answer rather than an error.

3. An over-long argument crashed the spawn without naming itself

A command line has a hard ceiling: 32767 chars on Windows, where Node raises spawn ENAMETOOLONG synchronously; E2BIG on POSIX. Measured here: 32,500 chars spawns, 33,000 throws. The bridge reported it as

could not start '<node>': Error: spawn ENAMETOOLONG

— naming the executable, never the argument, which reads like a broken install. flagArg and assertArgvFits now bound each value (8000) and the whole argv (24000) before the spawn:

`goal` is too long: 40000 characters, but at most 8000 can be carried on a command line.

A 40,000-character goal went from a 4 ms failure naming the executable to a 0 ms refusal naming goal.

4. A provider-filtered sessionflow_recent could silently under-return

The core's overview takes no provider argument, so the filter runs in the plugin — and it asked for limit * 4 rows and hoped. With 190 claude rows ahead of 10 codex rows, {provider:'codex', limit:5} returned an empty list, indistinguishable from "no codex sessions exist". It now requests the widest window the core honours, and marks a window-limited answer truncated: true so an incomplete list cannot read as complete.

5. The echo stub was answering with a shape the real core cannot produce

Its subcommand branch wrapped the reply in an id/result envelope. A real subcommand with --json prints a bare payload — _handoff_via_engine does print(json.dumps(payload)) directly, and only the api op wraps. No assertion depended on the envelope, which is precisely why nothing noticed. Fixed, and a new stub-overview.mjs answers with a caller-supplied row set so the provider filter is testable without an index.

Test evidence

  • npm test: 87 tests, 67 pass, 0 fail, 20 skipped
  • uv run ruff check .: n/a (TS-only)
  • The 20 skips are the core-dependent integration tests — no voyager core on this machine. The new tests do not need one.

Mutation-checked, each restored afterwards:

mutation tests turning red
baseline 0
--goal back to the space form 6
--json moved after the separator 3
argv length guard removed 1
recent back to the limit * 4 heuristic 1
truncated marker removed 1
restored 0

One assertion I changed, and why

tests/plugin.test.ts pinned the exact old array ['a:1','b:2','--goal','g'] — the buggy shape. It now asserts what it was written to protect (ids arrive as whole separate arguments; nothing is a joined command line). Pinning the layout would have re-frozen the defect in the one test that exists to catch it. Flagging this explicitly because changing an existing assertion deserves scrutiny, not a footnote.

Platform impact

  • Windows
  • Linux/macOS — no platform-specific code path; the limit differs (E2BIG vs ENAMETOOLONG) but both are now refused before the spawn.
  • Cold-start matrix rows added or updated

Constraint self-check

  • Peer ranges name prerelease lines explicitly — untouched
  • The bridge still spawns argv arrays only; no shell strings
  • No second implementation of core behavior in the plugin — bounding its own inputs is not re-implementing merge or overview
  • Every failure is a typed, actionable error — ParameterError names the field, the accepted range and the received value
  • Tests do not write into the developer's real profile — stubs record into a mkdtemp directory removed in a finally
  • A guard that cannot run fails loudly instead of skipping — the new tests need no real core, so they cannot skip into a false green

`limit` and `hours` are plain numbers the model supplies, and the plugin
forwarded them unchecked. The host does not cover this gap: `defineTool`
compiles the property spec to JSON Schema, so it rejects a *type* mistake
(`"hours": "yesterday"` -> `invalid arguments: "hours" must be a number`) but
`-2`, `0` and `3.7` are all well-typed numbers and all reached the core.

The core does not reject them either:

  * `int(limit or 20)` treats 0 as absent, so `search {limit: 0}` asked for
    nothing and got 20.
  * SQLite reads `LIMIT -N` as "no cap", so `search {limit: -2}` returned the
    whole index: measured 1241 rows / 480,826 bytes of JSON on a real index
    (94 sessions / 526,991 events), all of it into the caller's context.
    `recent {limit: -3}` uncapped the overview the same way (91 rows).
  * `search {limit: 3.7}` failed later inside SQLite with `slice indices must
    be integers or None`, an error carrying no trace of the argument that
    caused it.
  * `overview` computes `cutoff = now - hours*3600`, so `hours: -5` puts the
    cutoff in the future, filters out everything, and returns an empty list as
    though there were no history.

This adds `intArg` / `numArg` to `src/tools/common.ts` and calls them in
`search.ts` and `recent.ts` before the op is built. Both decide in the tool
process, so a refused argument costs a message rather than a child process and
a database open.

Above the ceiling the value is clamped, not refused: `limit: 500` is an
intelligible "give me a lot" and clamping to 200 honours that intent while
keeping the bound. Below the floor it is refused, because `-2` is not a larger
request, it is a category error. `hours` is allowed to be fractional (half an
hour is a real window) and 0 is legitimate ("right now").

The refusal is thrown outside `guarded` on purpose, so it stays a
`ParameterError` naming the argument and the accepted range instead of being
flattened into the generic message every core failure produces:

  `limit` must be a whole number of hits 1..200 (got -2; too small).

Verified: `npm test` -> 64 tests, 44 pass, 0 fail, 20 skipped (the skips are the
core-dependent integration tests; no voyager core on this machine). The 10 new
cases in `tests/numeric-args.test.ts` assert on an echo core's call record being
*empty*, so "refused" means nothing was spawned rather than "an error came
back". Mutation-checked each way: removing the `recent` bound turns 1 test red,
removing the `search` bound turns 3 red, restoring both returns the suite to
green. End to end against a live core, `search {limit: -2}` went from 480 KB to
a 6 ms refusal.
Two more ways a model-supplied argument could make a tool fail badly, both
measured against the real `merge` subparser rather than a mock.

**The argv was shaped so argparse could not read it.**  Options were emitted in
the space form and the ids appended bare:

  merge a:1 b:2 --goal g

  * A goal beginning with `-` reached argparse as a flag.  `--verbose` and
    `---` both produced `argument --goal: expected one argument` -> SystemExit
    -> `CLI_FAILED`, an error naming no argument.
  * A session id beginning with `-` was parsed as a flag, not an id.
  * Nothing bounded the values at all.

The core parses `--goal=VALUE` unambiguously, so options now use that form and
the ids follow a `--` separator.  Interior whitespace, CJK and an embedded `=`
all survive in that form (measured).  17 shape assertions in
`tests/merge-argv.test.ts` pin it.

**One trap lives inside that fix,** and it is the reason `--json` moved:
`--json` used to be appended at the END of argv, which after the separator means
argparse stops option parsing and reads it as a positional:

  merge a --goal=g -- a:1 --json  ->  sessions=['a:1','--json'], json=False

The core would then look for a session called `--json` and answer without JSON.
So `merge` places `--json` before the separator itself, and `runJson` gained
`jsonAlreadySet` so the bridge does not append a second one.

**An over-long argument crashed the spawn without naming itself.**  A command
line has a hard ceiling - 32767 chars on Windows, where Node raises
`spawn ENAMETOOLONG` synchronously; `E2BIG` on POSIX.  The bridge surfaced it as

  could not start '<node>': Error: spawn ENAMETOOLONG

which names the executable and reads like a broken install.  Measured here:
32500 chars spawns, 33000 throws.  `flagArg` and `assertArgvFits` now bound each
value (8000) and the whole argv (24000) before the spawn, and the refusal names
the field:

  `goal` is too long: 40000 characters, but at most 8000 can be carried on a
  command line.

A 40000-character goal went from a 4 ms failure naming the executable to a 0 ms
refusal naming `goal`.

**`sessionflow_recent` could silently under-return when filtering.**  The core's
`overview` takes no provider argument, so the filter runs in the plugin, and it
asked for `limit * 4` rows and hoped the wanted provider was at least a quarter
of them.  With 190 claude rows ahead of 10 codex rows, `{provider:'codex',
limit:5}` returned an empty list - indistinguishable from "no codex sessions".
It now asks for the widest window the core honours, and marks a window-limited
answer with `truncated: true` so an incomplete list cannot read as complete.

**The echo stub was lying about the response shape.**  Its subcommand branch
wrapped the reply in an `id`/`result` envelope; a real subcommand with `--json`
prints a bare payload (`_handoff_via_engine` prints `json.dumps(payload)`
directly - only the `api` op wraps).  No assertion depended on it, which is
exactly why it went unnoticed, and a fixture that mismeasures its subject is
worth fixing before it hides the next mistake.  `stub-overview.mjs` is new and
answers with a caller-supplied row set, so the provider filter is testable
without an index.

Verified: `npm test` -> 87 tests, 67 pass, 0 fail, 20 skipped (the skips are the
core-dependent integration tests; no voyager core on this machine).

Mutation-checked, each mutation restored afterwards:

| mutation | tests turning red |
|---|---|
| baseline | 0 |
| `--goal` back to the space form | 6 |
| `--json` moved after the separator | 3 |
| argv length guard removed | 1 |
| `recent` back to the `limit * 4` heuristic | 1 |
| `truncated` marker removed | 1 |
| restored | 0 |

`tests/plugin.test.ts` had one assertion that pinned the exact old array
`['a:1','b:2','--goal','g']` - the buggy shape.  It now asserts what it was
written to protect (ids arrive as whole separate arguments, nothing is a joined
command line), because pinning the layout would have re-frozen the defect in the
one test that exists to catch it.
@SunnyRad SunnyRad changed the title fix(tools): bound numeric arguments before the core sees them fix(tools): argv the core can parse, and bounds before the spawn Nov 9, 2026
@HarryHeYu HarryHeYu mentioned this pull request Nov 10, 2026

@HarryHeYu HarryHeYu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: 三处修复方向都对,merge/numeric 部分我在真实 Core 上验证通过;recent 的 truncated 判定还有一个反向缺口,建议先改再合

我按你 PR 里的声明逐条做了验证。环境:真实 voyager CLI(Core main 90907cc,schema 1)+ 一个 demo 索引,Node 22,Windows;CI 侧批准了 cold-start(run 42633890610,6/6 success)。

1. merge argv:确认修对了,旧形状确实是坏的

真实 merge subparser(sessions nargs="+"、--goal、--thread-title、--budget、--json)实测:

argv 结果
旧形状 merge demo-auth-01 --goal --verbose --json exit 2 argument --goal: expected one argument
新形状 merge --goal=--verbose --json -- demo-auth-01 exit 0,bundle 生成

用 PR 里的 mergeTool 直接驱动真实 Core,6 个场景全部 action=bundle:普通 goal、--verbose、CJK + k=v、budget=compact、空 goal、两个 id。--json 在 -- 之前这一条也确认了:放到后面 argparse 会把 --json 当 positional,core 会去找一个叫 --json 的 session。

边界输入(stub,未 spawn):空数组、非数组、goal 带换行/NUL、budget 传 number 都被拒且信息指名到字段;session_ids: [1, null, 'a:1'] 会静默丢掉非字符串只留 a:1,['', ' '] 会把 ' ' 当 id 发出去(core 回 session not found)。前者建议改成拒绝而不是静默过滤——一个模型传错的 id 被丢掉,和"这个 id 不存在"是两种不同的用户问题。

2. numeric bounds:缺陷真实,且我独立复现了

  • search limit=-2 → 7 行(query auth),-2 时 12 行(query e)。确认是 LIMIT -N 去掉上限,不是报错。
  • overview limit=0 → 0 行;limit=-3 → 1 行(切片语义)。
  • limit=3.7:直接走 Store.search 是 sqlite3.IntegrityError: datatype mismatch;走 api.search 会被 int(limit or 20) 静默截成 3。所以注释里"SQLite rejects it with slice indices must be integers"这句和当前 Core 不符(可能是旧版本行为),建议改掉,否则以后有人按这句话去查会找不到。

intArg/numArg 的 clamp/reject 语义是自洽的:上界 clamp、下界 reject、小数 reject。这点没问题。

3. recent:这是唯一我认为需要改的地方

PROVIDER_FILTER_WINDOW = 200 的方向是对的(从 limit*4 的猜测改成请求上限),但 truncated 的判定在目标 provider 完全落在窗口之后时不成立,而这恰好是 PR 想解决的那个"看起来像空列表"的场景。

tests/fixtures/stub-overview.mjs 不会按 limit 切片,而真实 api.overview 是 candidates[:limit]。所以现有测试测不到窗口边界。我用一个忠实切片的 stub 复现:

场景(limit=5) 返回 truncated
200 条 claude + 10 条 codex 在窗口之后 0 undefined
197 claude + 3 codex 在窗口内、20 条在窗口后 3 undefined
正好 200 条,全部匹配 5 true
30 条 codex(完整) 5 undefined

第一行就是"有数据但返回空列表且没有任何提示",和 CHANGELOG 里"an incomplete list cannot read as a complete one"的承诺不一致。第三行则是反向的:只有 200 条、没有更多,却标了 truncated。

当前条件是:

const truncated = matched.length > limit && want === ov.recent_sessions.length
  && ov.recent_sessions.length >= PROVIDER_FILTER_WINDOW;

matched.length > limit 这一项让"0 条匹配但窗口已满"永远进不来。建议改成:只要带了 provider 过滤、且 core 返回的行数达到了请求窗口(ov.recent_sessions.length >= want),就标记"无法证明完整",不管匹配到几条——因为在没有 provider 参数的 overview 上,窗口满就意味着窗口之后可能还有。

另外两个小点:

  • truncated: true 是一个布尔,读不出"为什么"。如果不想加字段,至少在 tool description 里写一句它表示"窗口满、不保证完整"。
  • 配套测试建议加"provider 完全在窗口之后"和"窗口内 3 条、窗口后 20 条"两个用例,并把 stub-overview.mjs 改成按 limit 切片,否则这两个用例仍然测不到。

4. 测试夹具与旧断言

  • stub-echo.mjs 从 id/result 信封改成 bare payload 是对的:真实 merge --json 顶层是 action/context/thread_id/...,没有 id/result。
  • plugin.test.ts 里把 deepEqual(['a:1','b:2','--goal','g']) 放宽为"id 是独立参数、--goal=g 是一个参数",我确认旧断言钉住的正是那个在真实 Core 上 exit 2 的形状,属于"修正错误断言",不是"改测试迁就实现"。这个改动我认可。

5. CI

action_required 是 fork PR 首次贡献需要维护者批准,不是失败。我批准后:6/6 success(plugin tests + 5 个 cold-start 渠道)。workflow 是 permissions: contents: read、无 secrets、无 pull_request_target,npm ci + 测试,安全边界可接受。

一点补充:这个 workflow 只有 ubuntu-latest,而 PR 改的正是 argv 长度/Windows 命令行这类平台相关行为。Windows 部分我本地跑过(tsc 干净、87/87 测试通过),但 CI 不会覆盖,建议后续单独加一个 Windows job。

结论

REQUEST_CHANGES,只有第 3 点需要动代码(recent 的 truncated 判定 + 忠实切片 stub + 两个窗口边界用例)。第 1、2、4 点可以直接保留,第 2 点的注释文字顺手改一下即可。

改完我会再跑一遍真实 Core 的集成验证并合并。谢谢你把这几处挖出来——limit=-2 那个无上限读取,确实是合成测试很难发现的。

@HarryHeYu

Copy link
Copy Markdown
Owner

感谢继续推进 PR #5!我重新看了一遍,整体修改方向是正确的,merge 参数解析、数值边界检查以及测试替身的修复都很有价值,前面用真实 Core 验证的部分也已经通过了。

目前主要还剩三个收尾问题:

  1. sessionflow_recent 的窗口截断判断:当目标 Provider 的会话全部排在 Core 返回窗口之外时,仍可能返回空列表且没有 truncated 提示。需要让测试替身模拟 Core 的 limit 截断行为,并修正不完整结果的判断。
  2. session_ids 参数校验:遇到非字符串或空白 ID 时应明确拒绝,不要静默丢弃。
  3. 修正关于浮点参数错误行为的注释,使其与当前 Core 一致。

这几个问题修完不需要再扩大 PR 范围。推送修订后,我这边会重新跑真实 Core 集成测试和 cold-start CI;如果没有新的阻塞问题,就可以合并。

另外项目后续会按 Roadmap + Issue 认领方式推进,有时间就可以挑感兴趣的任务,不需要固定交付周期。感谢之前的实机复现和这次的贡献!

…hes nothing

The `truncated` marker added with the provider-filter fix required
`matched.length > limit` — "the filter cut something".  That misses the
case it most needed to catch: when other providers fill the window, the
wanted provider can sit entirely behind it, so the filter matches nothing
and cuts nothing.  The caller then gets an empty list with no marker,
indistinguishable from "no sessions from that provider exist".

Measured on a real 255-session index (250 fresher `claude` rows ahead of
5 `codex` rows):

  {provider:'codex', limit:5}  ->  0 rows, truncated absent
  sessions {platform:'codex'}  ->  5 rows

The condition is now "the core filled the window", gated on the call
being filtered, so a normal unfiltered page is not relabelled as
clipped.

The fixture could not have caught this: stub-overview.mjs ignored
`limit` and always returned the whole payload, making "the core gave us
everything" and "the core gave us the window" the same answer.  It now
clips as a real core does.

Mutation-checked, each restored afterwards (89 tests, 88 pass):
  baseline                                   1 red (pre-existing, unrelated)
  predicate back to matched.length > limit   2 red, incl. the new
                                             behind-the-window test
  provider guard dropped                     2 red, incl. the new
                                             unfiltered-full-page test
  stub stops honouring limit                 2 red, incl. the new test
  restored                                   1 red (the same pre-existing)

The one standing red is `search round-trips a CJK query without
mangling it`, which fails identically on main in this environment
(lone-surrogate argv encoding at the core boundary) and is not touched
by this branch.
The reviewer asked for three things; this is two of them, and the third
turned out to be a wrong comment rather than a wrong guard.

1. session_ids validation

`session_ids` was read with `filter(isString && length > 0)`, so
`['a:1', 42, '  ']` merged ONE session and reported success.  The merge
is the unit of consent: the caller names a set, and the WorkThread
contains exactly that set.  Dropping an entry is not leniency, it is a
different operation performed on the caller's behalf without saying so.
Every element is now checked rather than filtered, the refusal names the
offending index (`session_ids[1]`) so the other ids can be kept, and a
whitespace-only entry is refused as blank rather than sent to the core
as an unresolvable positional.

2. The fractional-limit comment was wrong about `search`

The comment claimed the core rejects `3.7` with `slice indices must be
integers`.  Measured on voyager 1.3.0, that is true of `overview` only:

  search   (api.py:254, int(limit or 20))   3.7 -> 3 rows, SILENTLY
  overview (api.py:77/:94, raw slice)       3.7 -> slice-indices error
  search   limit 0  -> 20 rows (0 reads as absent)
  overview limit 0  -> 0 rows
  search   limit -2 -> 33,277 rows (no cap)
  overview limit -3 -> 91 of 94 rows

So `search` under-returns quietly, which is worse than the crash the
comment described: a truncated answer looks complete.  The guard was
already right; its justification was not, and a wrong justification is
how a later change deletes a guard for the wrong reason.  Both
behaviors are now recorded where they apply, and the test header states
them per-op.

3. The `truncated` fix from the previous commit is unchanged

Already in this branch as d46a50e; the reviewer's summary of it (let the
fixture clip like a real core, and judge incompleteness by "the core
filled the window" gated on the call being filtered) is what it does.

Tests: 96 total, 76 pass, 0 fail, 20 skipped.  The skips are the
core-dependent integration tests, which need a `python` that can
`import voyager`; with a real core wired in, 93 pass and the 3
remaining failures reproduce identically on main in this environment —
two are tests that `delete process.env['VOYAGER_BIN']` and never restore
it, and the CJK one is the core's own lone-surrogate bug:

  search {query: "这是绝对不存在的查询词zzz"}
  -> 'utf-8' codec can't encode character '\udcaf' in position 6

That is reproducible straight against the core's stdio API with no
plugin involved, so it is not this branch's regression.

Mutation-checked, each restored afterwards:
  session_ids back to the silent filter      5 red, incl. all 4 new
  truncated back to matched.length > limit    1 red
  stub stops honouring limit                  1 red
  restored                                    0 red
@SunnyRad

Copy link
Copy Markdown
Contributor Author

三条都已在 63ee208 修完,谢谢逐条确认方向。

1. 窗口截断判断 —— 你指出的正是第一版的漏判。旧判据是 matched.length > limit("过滤器切掉了东西"),但目标 Provider 整个排在窗口之外时,过滤器一条都没匹配上、也就没切任何东西,于是空列表裸奔无标记。真机上 250 条 claude 排在 5 条 codex 前面时,{provider:'codex', limit:5} 返回 0 行、无 truncated,而 sessions {platform:'codex'} 列得出那 5 条。

判据改成"Core 把窗口填满了"而不是"我们切多了",并且只在确实带了 provider 过滤时才可能成立 —— 否则每个正常整页都会被标成截断。测试替身也按你说的改成 stub-overview.mjs 真实裁剪 limit:原来它忽略 limit 永远回全量,所以"Core 给了全部"和"Core 只给了窗口"是同一个答案,这个 bug 在旧替身下不可能被测出来。

2. session_ids 校验 —— 改成逐项检查而非过滤,['a:1', 42, ' '] 从"合并 1 个并报成功"变成点名 session_ids[1],其余合法项保留。空白串单独判为 blank。

3. 浮点注释 —— 这条实测后比我原来写的更糟。原注释只说 overview 会 slice indices must be integers,却写成两个 op 的共同事实。实际 search 是 int(limit or 20)(api.py:254),3.7 被静默截断成 3 —— 不报错、不警告,只是比要的少,而少返回的答案看起来是完整的。

输入 search(int()) overview(裸切片)
3.7 3 行,静默截断 slice indices must be integers
0 20 行 0 行
-2 33,277 行 91 / 94 行

守卫本身没错,错的是理由;而错的理由正是后来某次改动删掉守卫的方式。

验证:96 / 76 通过 / 0 失败 / 20 跳过;接真 Core 后 93 通过。剩余 3 个在本机 main 上逐一同样失败,与本分支无关(两个是测试自己 delete process.env['VOYAGER_BIN'] 后没恢复的顺序泄漏,一个是 Core 侧问题)。变异逐个验证:回退静默过滤 5 红、回退 truncated 1 红、fixture 不裁剪 1 红、复原 0 红。


顺带报一个 Core 侧的问题,跟本 PR 无关,另外开 issue 更合适:api op 从 stdin 读请求(api.py:546),而 force_utf8_stdio() 只重配了 stdout / stderr,没有 stdin。中文 Windows 下 stdin 是 gbk + surrogateescape,调用方写入的 UTF-8 字节不报错、被静默按 GBK 解码成乱码:

transport query result
voyager search 会话 --json 会话 3 行
stdio {"op":"search","params":{"query":"会话"}} 会话 0 行,回显 浼氳瘽

浼氳瘽 就是 UTF-8 e4bc9ae8af9d 按 GBK 的结果。更麻烦的是 repo: "D:\插件开发":overview 上返回静默空列表(过滤在乱码后恰好匹配不到任何行),search 上才抛 surrogates not allowed。0 results 和 [] 跟"确实没有"长得一模一样。

把 sys.stdin 加进那个循环即可(实测 会话 恢复到 3 行,中文 repo 路径的空列表与报错同时消失)。要不要我单独提 PR?

@HarryHeYu

Copy link
Copy Markdown
Owner

感谢你这么快把三个问题都修完,也感谢你把窗口截断测试的 Mock 一起修正了。

我已经检查了最新的 63ee208,recent 的窗口满判定、stub 按 limit 裁剪,以及 session_ids 逐项校验,都符合之前 Review 的修复方向。

接下来我会重新检查最新 CI,并用真实 Core 跑一遍集成测试。你提到的另外 3 个失败,我会在 main 基线上逐项复现;如果确认是已有问题且与本 PR 无关,就不会因此阻塞合并。验证通过后我会直接 Squash Merge,并保留你的作者贡献归属。

另外,Windows stdin UTF-8 的发现非常重要。这个问题和 PR #5 无关,建议单独在 sessionFlow Core 仓库提交 Issue 和 PR。欢迎你继续负责这个修复:重点是 stdin 的 UTF-8 解码、中文查询和中文 repo 路径的字节级回归测试,同时保证 stdout/stderr 与其他入口不受影响。

这部分不用加到当前 PR,避免扩大改动范围。

再次感谢你持续提供真实运行证据和修复!

@HarryHeYu HarryHeYu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

三处收尾问题已在 63ee208 修完,复验通过。

  • recent 改为「带 provider 过滤且 Core 填满窗口」即标记 truncated;stub 现按 limit 裁剪。独立构造 A/B/C/D 四个窗口场景均符合预期(含「窗口未满 + 零匹配」不误报截断)。
  • session_ids 逐项校验,非法输入在 spawn 前点名 session_ids[i] 拒绝;测试用 echo 记录确认零 spawn。
  • 浮点注释已按真实 Core 改写:search 走 int(limit or 20) 静默截断、overview 抛 slice 错误。已在真实 Core 逐项复现。

验证:npm ci / npm run build / npm test 全绿;真实 Core(v1.3.1,schema 1)下 PR HEAD 96/96 通过、0 失败、0 跳过;针对性的 42 项新增回归 42/42;真实 core overview 确认按 limit 裁剪;merge 的新 argv 形状(--goal=--verbose --json -- ids)在真实 argparse 下 exit 0,旧形状仍 exit 2;bridge schema 与 peerDependencies 均未变。

作者报告的 3 个失败已逐项复现并与 main 基线对照:在同样的 GBK-stdin + 无 PATH 回退环境下,PR HEAD 为 93/3、main 为 57/3,失败完全相同。其中 2 个是 tests/bridge.test.ts 里 delete process.env['VOYAGER_BIN'] 未恢复的顺序泄漏(VOYAGER_NOT_FOUND),1 个是 Core 侧 stdin UTF-8 解码导致的 CJK 查询失败(surrogates not allowed)。该文件在本 PR 中逐字节未改动,与本次修改路径无关,已另立 Core Issue 跟踪。

最新 CI(run 42816717952,attempt 2):plugin tests + 5 个 cold-start 渠道 6/6 success。workflow 为 pull_request、permissions: contents: read、无 secrets、无 pull_request_target,批准后运行安全。

按约定 Squash Merge,保留 SunnyRad 的作者归属。感谢实机复现与修复!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants