diff --git a/docs/plans/execute-result-recovery.md b/docs/plans/execute-result-recovery.md new file mode 100644 index 000000000000..b868a7510279 --- /dev/null +++ b/docs/plans/execute-result-recovery.md @@ -0,0 +1,176 @@ +# PR #5: minimal-scope hunk audit + +Status: keep open; do not merge. This replaces the previous broad recovery plan. +Audit baseline: `8565ca09986c2eea4ad7610dc1fb393b57990bd4`. +Original reviewed head: `7c9edf882`. +Final squash parent: `f37e94dd26d3e179add3b4adadc5cb4d0d70f4ea` (`dev1-smark` at this revision); existing target-branch fixes are preserved. +A second independent review covered all 24 remaining zero-context hunks. No further behavioral changes were needed; only the read budget comment was synchronized. + +## User requirements used as evidence + +- Q1: “是逻辑或者harness才用修”;“模型的问题不管”;“不需要修的请不要碰”;“不要过度修改请注意着点”。 +- Q2: “按理来说 grid 的 timeout 是应该有的呀”;“就比如说它只搜到了六十四条,OK,那就正常把这六十四条做一个列表传到后面不就 OK 了吗?” +- Q3: “就比如说你这种 grip,你的 json 过长,超过 64KB能算长吗?不算长,不应该报错。” +- Q4: “里面应该有发生过 execute 的相关很多错误,结合相关的错误自己去看一看相关的问题。” +- Q5: “然后包括什么这种并行 execute 失败回执啊,巴拉巴拉,什么把成功调用正文交给模型啊,巴拉巴拉。” +- Q6: “你的修改要适当考虑避免把这些什么这种常量去写在硬编码的这个主逻辑里面”。 +- Q7: “long module output is saved to a readable file” / “它自己又不会保存” / “用户没让你做的事情不得改”。 + +Q1/Q4 authorize investigation and actual harness fixes, not arbitrary cleanup. The +read/shell corrections below are implementation-level consequences of that scope, +not claims that the user explicitly dictated those exact lines. Model-generated +invalid JSON is not repaired or automatically retried. + +## Final production scope + +The original minimal data-path fixes touch three production files (the later authorized JS support is listed separately below): + +1. `core/src/ripgrep.ts`: accept a valid record above 64 KiB. Restore all other + search budgets and preview limits. Removing MAX_RECORD_BYTES removes its + rejection rule; the number is not embedded elsewhere. +2. `core/src/tool/read-lines.ts`: allow the first complete line despite output + budget, so read pagination progresses rather than returning empty data. + This is a deliberate first-line exception, not a strict per-read byte bound; + subsequent-line and auxiliary-read budgets remain unchanged. +3. `opencode/src/tool/shell.ts`: separate actual process text from display-only + execution notices in the programmatic content field. + +Grep's 64-result cap, default 10-second timeout, maximum 120-second timeout, +5,000-file cap, native 1,000-result default, 100-submatch cap and 2,000-character +preview are restored. The altered grep description is fully restored, including +removal of the new file-storage promise. No claim is made that grep itself writes +that file. No new error is introduced merely because search is partial. + +`MAX_VALUE_DEPTH`, `MAX_CONSOLE_DEPTH`, and the existing 64 KiB console log budget +are restored, not replaced by literals. All codemode interpreter, console, +serialization, diagnostics, and error-media changes are withdrawn. + +The questioned “77 lines” were aggregate additions in +`packages/codemode/test/codemode.test.ts`, not one necessary test. All additions +in that file are withdrawn because their production changes are withdrawn. +Only tests for the three remaining fixes and a small failure-output regression assertion survive. + +## Latest failure-output correction + +The user clarified: “它失败了,那就是既有的输出的这个完整的缓冲区的一些内容直接去返回给模型不就 OK 了吗?然后顺带附上一个结果错误的一个 error 消息就 OK 了。” and “本身如果模型没有让它去显示,没有去 display 的东西,应该也不用去 display 啊。” + +This supersedes Q5: the automatic completed-child text/path insertion is withdrawn in full. The original session failure implementation is restored. Existing output-buffer/error handling and its storage budget remain unchanged. The parallel recovery test and its budget/abort assertion changes are withdrawn. One existing error-output test now also reads an undisplayed file and verifies that its body is absent while the displayed log and error remain recoverable. This correction itself does not change JS language features; subsequent explicit authorization is recorded below. + +## Every original hunk + +Rows refer to the original PR diff, not the rollback commit diff. Restored rows +have no net change from the baseline. Test restoration includes snapshot/schema +changes paired with restored production behavior. + +| # | Original file and range | Decision and necessity | +|---|---|---| +| 1 | `docs/plans/execute-result-recovery.md` `-0,0 +1,139` | 重写为本逐 hunk 审计;撤回原扩大范围的计划。Q1/Q7。 | +| 2 | `packages/codemode/src/codemode.ts` `-189,12 +189,12` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 3 | `packages/codemode/src/interpreter/runtime.ts` `-56,7 +56,7` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 4 | `packages/codemode/src/interpreter/runtime.ts` `-2090,7 +2090,8` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 5 | `packages/codemode/src/interpreter/runtime.ts` `-2105,16 +2106,16` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 6 | `packages/codemode/src/interpreter/runtime.ts` `-2126,13 +2127,12` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 7 | `packages/codemode/src/interpreter/runtime.ts` `-2140,7 +2140,7` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 8 | `packages/codemode/src/interpreter/runtime.ts` `-2149,10 +2149,10` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 9 | `packages/codemode/src/interpreter/runtime.ts` `-2207,7 +2207,7` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 10 | `packages/codemode/src/interpreter/runtime.ts` `-3187,7 +3187,11` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 11 | `packages/codemode/src/stdlib/console.ts` `-1,4 +1` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 12 | `packages/codemode/src/tool-runtime.ts` `-137,13 +137,6` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 13 | `packages/codemode/src/tool-runtime.ts` `-177,7 +170,7` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 14 | `packages/codemode/src/tool-runtime.ts` `-192,18 +185,10` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 15 | `packages/codemode/src/tool-runtime.ts` `-252,13 +237,13` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 16 | `packages/codemode/src/tool-runtime.ts` `-296,7 +281,7` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 17 | `packages/codemode/src/tool-runtime.ts` `-312,7 +297,7` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 18 | `packages/codemode/test/codemode.test.ts` `-1911,7 +1911,36` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 19 | `packages/codemode/test/codemode.test.ts` `-1919,8 +1948,6` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 20 | `packages/codemode/test/codemode.test.ts` `-2820,3 +2847,50` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 21 | `packages/core/src/ripgrep.ts` `-18,14 +18,6` | 仅删除 MAX_RECORD_BYTES;其他常量恢复。Q3。 | +| 22 | `packages/core/src/ripgrep.ts` `-309,8 +301,8` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 23 | `packages/core/src/ripgrep.ts` `-347,11 +339,8` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 24 | `packages/core/src/ripgrep.ts` `-364,13 +353,11` | 删除合法 JSON 记录的 64 KiB 硬拒绝;保留 JSON/schema 校验。Q3。 | +| 25 | `packages/core/src/ripgrep.ts` `-378,7 +365,6` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 26 | `packages/core/src/ripgrep.ts` `-399,10 +385,7` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 27 | `packages/core/src/tool/grep.ts` `-16,10 +16,8` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 28 | `packages/core/src/tool/grep.ts` `-36,10 +34,10` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 29 | `packages/core/src/tool/grep.ts` `-55,7 +53,7` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 30 | `packages/core/src/tool/grep.ts` `-130,17 +128,17` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 31 | `packages/core/src/tool/read-lines.ts` `-146,8 +146,8` | 首行超过预算仍返回完整首行,避免 nextOffset 不前进。Q1/Q4。 | +| 32 | `packages/core/src/tool/read-lines.ts` `-175,7 +175,7` | 同上,字节预算路径。Q1/Q4。 | +| 33 | `packages/core/test/ripgrep.test.ts` `-24,7 +24,7` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 34 | `packages/core/test/ripgrep.test.ts` `-76,32 +76,32` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 35 | `packages/core/test/ripgrep.test.ts` `-145,6 +145,26` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 36 | `packages/core/test/ripgrep.test.ts` `-176,14 +196,13` | 改成 16 行定向测试:70 KiB 记录前、中、后三个匹配均返回。Q3。 | +| 37 | `packages/core/test/ripgrep.test.ts` `-191,7 +210,9` | 随上述定向测试收缩;不再要求无限子匹配或完整超长预览。Q2/Q3。 | +| 38 | `packages/core/test/ripgrep.test.ts` `-422,8 +443,8` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 39 | `packages/core/test/tool-read-filesystem.test.ts` `-45,10 +45,10` | 验证超长首行实际返回,而非空结果。Q1/Q4。 | +| 40 | `packages/opencode/src/session/message-v2.ts` `-844,7 +844,16` | 撤回失败父回执的媒体投影扩展;成功正文恢复不需要它。Q1/Q5。 | +| 41 | `packages/opencode/src/session/tools.ts` `-199,6 +199,8` | 撤回额外 response/media/无限输出收集及相关测试;超出完成正文恢复。Q1/Q5。 | +| 42 | `packages/opencode/src/session/tools.ts` `-219,10 +221,12` | 撤回额外 response/media/无限输出收集及相关测试;超出完成正文恢复。Q1/Q5。 | +| 43 | `packages/opencode/src/session/tools.ts` `-352,8 +356,14` | 根据用户最新输出/display纠正撤回,恢复基线。原成功child正文恢复要求已被取代;不再注入未显示内容。 | +| 44 | `packages/opencode/src/tool/code-mode.ts` `-236,7 +236,7` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 45 | `packages/opencode/src/tool/code-mode.ts` `-289,7 +289,8` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 46 | `packages/opencode/src/tool/grep.ts` `-11,14 +11,8` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 47 | `packages/opencode/src/tool/grep.ts` `-36,10 +30,10` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 48 | `packages/opencode/src/tool/grep.ts` `-69,7 +63,7` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 49 | `packages/opencode/src/tool/grep.ts` `-107,16 +101,14` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 50 | `packages/opencode/src/tool/grep.ts` `-135,11 +127,10` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 51 | `packages/opencode/src/tool/grep.ts` `-155,7 +146,7` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 52 | `packages/opencode/src/tool/grep.ts` `-173,42 +164,16` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 53 | `packages/opencode/src/tool/grep.ts` `-217,13 +182,6` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 54 | `packages/opencode/src/tool/grep.ts` `-242,22 +200,21` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 55 | `packages/opencode/src/tool/grep.txt` `-1,11 +1,11` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 56 | `packages/opencode/src/tool/shell.ts` `-810,7 +810,7` | program.value.content 使用 end.text;执行状态提示仅留在展示输出。Q1/Q4。 | +| 57 | `packages/opencode/test/session/execute.test.ts` `-883,6 +883,8` | 撤回额外 response/media/无限输出收集及相关测试;超出完成正文恢复。Q1/Q5。 | +| 58 | `packages/opencode/test/session/execute.test.ts` `-1005,6 +1007,35` | 撤回额外 response/media/无限输出收集及相关测试;超出完成正文恢复。Q1/Q5。 | +| 59 | `packages/opencode/test/session/execute.test.ts` `-1035,7 +1066,8` | 根据用户最新输出/display纠正撤回,恢复基线。原成功child正文恢复要求已被取代;不再注入未显示内容。 | +| 60 | `packages/opencode/test/session/execute.test.ts` `-1073,6 +1105,8` | 根据用户最新输出/display纠正撤回,恢复基线。原成功child正文恢复要求已被取代;不再注入未显示内容。 | +| 61 | `packages/opencode/test/session/execute.test.ts` `-1085,6 +1119,7` | 根据用户最新输出/display纠正撤回,恢复基线。原成功child正文恢复要求已被取代;不再注入未显示内容。 | +| 62 | `packages/opencode/test/session/execute.test.ts` `-1097,6 +1132,73` | 根据用户最新输出/display纠正撤回,恢复基线。原成功child正文恢复要求已被取代;不再注入未显示内容。 | +| 63 | `packages/opencode/test/session/execute.test.ts` `-2269,7 +2371,7` | 撤回额外 response/media/无限输出收集及相关测试;超出完成正文恢复。Q1/Q5。 | +| 64 | `packages/opencode/test/session/execute.test.ts` `-2282,7 +2384,7` | 撤回额外 response/media/无限输出收集及相关测试;超出完成正文恢复。Q1/Q5。 | +| 65 | `packages/opencode/test/session/execute.test.ts` `-2339,7 +2441,9` | 根据用户最新输出/display纠正撤回,恢复基线。原成功child正文恢复要求已被取代;不再注入未显示内容。 | +| 66 | `packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap` `-154,9 +154,9` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 67 | `packages/opencode/test/tool/code-mode.test.ts` `-715,6 +715,9` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 68 | `packages/opencode/test/tool/code-mode.test.ts` `-1486,6 +1489,20` | 恢复原行为/测试;无当前故障证据支持深度、日志预算或诊断扩展。Q1/Q6。 | +| 69 | `packages/opencode/test/tool/grep.test.ts` `-4,7 +4,7` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 70 | `packages/opencode/test/tool/grep.test.ts` `-232,10 +232,11` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 71 | `packages/opencode/test/tool/grep.test.ts` `-286,8 +287,9` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 72 | `packages/opencode/test/tool/grep.test.ts` `-309,7 +311,7` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 73 | `packages/opencode/test/tool/grep.test.ts` `-323,7 +325,8` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 74 | `packages/opencode/test/tool/grep.test.ts` `-353,7 +356,7` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 75 | `packages/opencode/test/tool/grep.test.ts` `-411,12 +414,11` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 76 | `packages/opencode/test/tool/grep.test.ts` `-427,11 +429,16` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 77 | `packages/opencode/test/tool/grep.test.ts` `-473,7 +480,7` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 78 | `packages/opencode/test/tool/grep.test.ts` `-560,9 +567,10` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 79 | `packages/opencode/test/tool/parameters.test.ts` `-211,11 +211,15` | 恢复原行为/测试;默认 timeout、结果上限及部分结果已有契约不应改变。Q2/Q6/Q7。 | +| 80 | `packages/opencode/test/tool/read.test.ts` `-433,11 +433,11` | 测试先返回完整超长首行、下一页从下一行继续。Q1/Q4。 | +| 81 | `packages/opencode/test/tool/read.test.ts` `-601,28 +601,25` | 同上,撤回旧的空首行/不前进预期。Q1/Q4。 | +| 82 | `packages/opencode/test/tool/shell.test.ts` `-2651,6 +2651,7` | 验证空 shell 输出不会把状态提示作为程序数据。Q1/Q4。 | +| 83 | `packages/opencode/test/tool/shell.test.ts` `-2696,6 +2697,7` | 验证非零退出状态提示不混入程序 content。Q1/Q4。 | + +## Subsequently authorized JS support + +The user then explicitly requested support instead of unnecessary rejection: “你直接把你自己的东西做支持,做支持好不就 OK 了吗?” and “所以这些东西你都去做吧,OK。” The agreed scope is built-in callbacks, Array construction, and Array.from mapping. + +- Reuse the existing restricted callable dispatcher for supported built-in callbacks, including collection, promise, sort and replacement paths. No arbitrary host functions or new tool-call permissions. Sparse-array skip branches yield through the existing Effect scheduler so timeout/cancellation remains effective, without a new size cap. +- Share Array(...) and new Array(...) construction, including sparse numeric-length arrays. Existing array operations skip/preserve holes where JS requires it. +- Run Array.from mappers through that same interpreter callback path, with value/index arguments. No new thisArg binding framework. +- Math fixed-arity methods ignore callback index/array extras and use the existing safe numeric coercion. +- Update the existing supported-syntax statement and remove outdated mapper/arrow-only rejection messages; add no new prompt constraints. +- Three orthogonal tests cover the supported combinations; one small parameterized test verifies existing timeouts still interrupt sparse-array scans; remove the obsolete assertion requiring map(Math.abs) to fail while retaining sleep-property isolation coverage. + +These are additional authorized capability changes, not a revival of the withdrawn output/log/depth/grep-budget changes. + +## Requested execution limits and CI reconciliation + +The user subsequently requested a tool-call limit of 1024 and an appropriate loop-iteration limit. Execute now uses named shared policy constants for 1,024 tool calls and 100,000 total explicit loop-body entries per execution. Nested loops, function calls and async frames share each root budget; a later Execute gets a fresh budget. The library option remains optional, existing concurrent tool admission and timeout ownership remain unchanged, and permission preflight uses the same policy with its own execution counter. + +The failed CI run tested merge base `8b6f0f3`. The updated target already fixes the compaction interruption, daemon maintenance fixtures, SSE overflow fixture and message-pagination failures in `9f911f0`. Reapplying this PR to the current target preserves those fixes rather than duplicating them. All 12 previously failing targeted cases pass locally, and the full Cookie-response file passes 63 tests (5 skipped); its earlier extra-request failure did not reproduce, so neither product logic nor assertion semantics were changed speculatively. + +### Follow-up CI failures on `33501508` + +The user requested investigation and repair of the remaining PR failures. That head completed 28 checks successfully, including typecheck; three platform jobs failed. No Execute budget/callback test failed. + +- README: target commit `f37e94dd` intentionally restored the dev README layout, while its existing test still required obsolete alert markers before the screenshot. Update only that test to check the current migration/branch notices before installation, including the database filename and release identity. Do not revert the target README or alter its content. +- Repository-tool contract test: importing through the repository `.opencode` directory allowed unrelated background dependency installation to race with loading Zod. Isolate the fixture's dependency resolution while still executing the real repository tool code and retaining its business-contract assertions. +- Voice Cookie fixture: the process-global endpoint-status monitor continues background origin HEAD probes for its existing active window. A Linux log captured an unrelated loopback origin alongside the two expected transcription requests. Isolate the voice fixture's asynchronous request chain rather than filtering by destination or changing production retry/cache behavior; calls made inside the tested chain must still be recorded regardless of URL. diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index ff0b3053fcf0..3081c255b110 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -18,6 +18,8 @@ export type ExecutionLimits = { readonly timeoutMs?: number /** Maximum number of tool calls admitted by the runtime. No default: absent means unlimited. */ readonly maxToolCalls?: number + /** Total explicit loop-body entries across this execution. Absent means unlimited. */ + readonly maxLoopIterations?: number /** Maximum UTF-8 bytes of model-facing output. No default: absent means no truncation. */ readonly maxOutputBytes?: number /** 日志按 UTF-8 字节限制留存;业务返回值仍受独立的 maxOutputBytes 控制。 */ @@ -39,6 +41,7 @@ type ToolTree = { export type ResolvedExecutionLimits = { readonly timeoutMs: number | undefined readonly maxToolCalls: number | undefined + readonly maxLoopIterations: number | undefined readonly maxOutputBytes: number | undefined readonly maxLogBytes: number | undefined } @@ -89,6 +92,7 @@ export const DiagnosticKind = Schema.Literals([ "InvalidToolOutput", "InvalidDataValue", "ToolCallLimitExceeded", + "LoopIterationLimitExceeded", "TimeoutExceeded", "ToolFailure", "ExecutionFailure", @@ -156,6 +160,7 @@ const resolveExecutionLimits = (limits?: ExecutionLimits): ResolvedExecutionLimi // Effect 的计时器只支持有符号32位毫秒;超界不能悄悄变成无期限执行。 timeoutMs: validateLimit("timeoutMs", limits?.timeoutMs, 1, 2_147_483_647), maxToolCalls: validateLimit("maxToolCalls", limits?.maxToolCalls, 0), + maxLoopIterations: validateLimit("maxLoopIterations", limits?.maxLoopIterations, 0), maxOutputBytes: validateLimit("maxOutputBytes", limits?.maxOutputBytes, 0), // 零预算表示只记录省略数;不能按真值把它当成无限额。 maxLogBytes: validateLimit("maxLogBytes", limits?.maxLogBytes, 0), diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index 1dc55c89b471..d3e6db1e3682 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -131,6 +131,7 @@ export type DiagnosticKind = | "InvalidToolOutput" | "InvalidDataValue" | "ToolCallLimitExceeded" + | "LoopIterationLimitExceeded" | "TimeoutExceeded" | "ToolFailure" | "ExecutionFailure" @@ -139,7 +140,7 @@ export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-sho // 诊断与实际调用面一致,不能再把已支持的链式方法说成禁用。 export const supportedSyntaxMessage = - "Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (Promise .then/.catch/.finally accept collection callbacks and adopt returned promises)." + "Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, supported builtins as callbacks, spread, try/catch, Array construction and Array.from mapping, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (Promise .then/.catch/.finally accept collection callbacks and adopt returned promises)." export class InterpreterRuntimeError extends Error { readonly node?: AstNode diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 7ce693b5d3df..12dfa5118ab4 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -608,14 +608,6 @@ const invokeArrayStatic = (name: string, args: Array, node: AstNode): u case "of": return [...args] case "from": { - if (args.length > 1) { - throw new InterpreterRuntimeError( - "Array.from(...) does not support a map function in CodeMode; call .map() on the result instead.", - node, - "UnsupportedSyntax", - [supportedSyntaxMessage], - ) - } // Map/Set materialize directly (the data checkpoint would serialize them to {}). if (args[0] instanceof SandboxMap) return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item]) @@ -623,7 +615,7 @@ const invokeArrayStatic = (name: string, args: Array, node: AstNode): u if (args[0] instanceof SandboxURLSearchParams) { return Array.from(args[0].params.entries(), ([key, value]) => [key, value]) } - const source = boundedData(args[0], "Array.from input") + const source = args[0] if (typeof source === "string") return Array.from(source) if (Array.isArray(source)) return [...source] if ( @@ -697,6 +689,7 @@ type Execution = { invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect toolKeys: (path: ReadonlyArray) => ReadonlyArray log: (message: string) => void + enterLoop: (node: AstNode) => void scope: Scope.Scope // 清单只供终态回执和释放 Fiber 引用使用;真正取消与等待仍由根 Scope 负责。 promises: Array @@ -1036,6 +1029,7 @@ class Interpreter { const self = this return Effect.gen(function* () { while (yield* self.evaluateExpression(testNode)) { + self.execution.enterLoop(node) const result = yield* self.evaluateStatement(bodyNode) if (result.kind === "continue") { @@ -1066,6 +1060,7 @@ class Interpreter { const self = this return Effect.gen(function* () { do { + self.execution.enterLoop(node) const result = yield* self.evaluateStatement(bodyNode) if (result.kind === "continue") { @@ -1112,6 +1107,7 @@ class Interpreter { : [] while (testNode ? yield* self.evaluateExpression(testNode) : true) { + self.execution.enterLoop(node) let iterationScope: Map | undefined if (perIterationBindings.length > 0) { iterationScope = new Map( @@ -1200,6 +1196,7 @@ class Interpreter { // 异步迭代的每次 next(包括空集合与终止)都沿既有 await 调度让出。 if (getBoolean(node, "await")) yield* Effect.yieldNow if (next.done) break + self.execution.enterLoop(node) // 只等待产出值本身;Map 键值对不会递归等待。 const value = getBoolean(node, "await") && next.value instanceof SandboxPromise @@ -1302,6 +1299,7 @@ class Interpreter { } for (const key of keys) { + self.execution.enterLoop(node) if (declaration) { self.pushScope() yield* self.declarePattern(declaration.pattern, key, declaration.mutable, left) @@ -1594,6 +1592,9 @@ class Interpreter { return createErrorValue(name, arg === undefined ? "" : coerceToString(arg)) }) } + if (name === "Array") { + return Effect.map(self.evaluateCallArguments(argNodes), (args) => self.constructArray(args)) + } if (valueConstructors.has(name)) { return Effect.gen(function* () { const args = yield* self.evaluateCallArguments(argNodes) @@ -1616,6 +1617,14 @@ class Interpreter { throw unsupportedSyntax("NewExpression", node) } + private constructArray(args: Array): Array { + if (args.length !== 1 || typeof args[0] !== "number") return [...args] + const length = args[0] + if (!Number.isInteger(length) || length < 0 || length > 0xffffffff) + throw new ProgramThrow(createErrorValue("RangeError", "Invalid array length")) + return new Array(length) + } + private constructDate(args: Array): SandboxDate { if (args.length === 0) return new SandboxDate(Date.now()) if (args.length === 1) { @@ -2018,29 +2027,48 @@ class Interpreter { const args = yield* self.evaluateCallArguments(argNodes) + if (typeofValue(callable) === "function") return yield* self.invokeCallable(callable, args, node) + // 保留首次求值的 receiver;诊断只显示有限键名,不读取值或重复执行表达式。 + const [value, label] = + isRecord(reference) && "target" in reference ? [reference.target, "receiver"] : [callable, "value"] + throw new InterpreterRuntimeError( + `Value is not callable in CodeMode. Actual ${label}: ${describeValueShape(value)}.`, + callee, + ) + }) + } + + private invokeCallable(callable: unknown, args: Array, node: AstNode): Effect.Effect { + return Effect.gen({ self: this }, function* () { if (callable instanceof ToolReference) { - if (callable.path.length === 0) throw new InterpreterRuntimeError("The tools root is not callable.", callee) + if (callable.path.length === 0) throw new InterpreterRuntimeError("The tools root is not callable.", node) // 发现只读取内存目录,直接交付值;仍经过原工具校验、预算和观察hooks。 if (callable.path.length === 2 && callable.path[0] === "$codemode" && callable.path[1] === "search") - return yield* self.execution.invokeTool(callable.path, args) + return yield* this.execution.invokeTool(callable.path, args) // An un-awaited tool call is a first-class promise value; the call itself starts now. - return yield* self.createToolCallPromise(callable.path, args) + return yield* this.createToolCallPromise(callable.path, args) } if (callable instanceof PromiseMethodReference) { - return yield* self.invokePromiseMethod(callable, args, node) + return yield* this.invokePromiseMethod(callable, args, node) } if (callable instanceof CodeModeFunction) { - return yield* self.invokeFunction(callable, args) + return yield* this.invokeFunction(callable, args) } if (callable instanceof IntrinsicReference) { - return yield* self.invokeIntrinsic(callable, args, node) + return yield* this.invokeIntrinsic(callable, args, node) } + if (callable instanceof GlobalNamespace && callable.name === "Array") return this.constructArray(args) if (callable instanceof GlobalMethodReference) { + if (callable.namespace === "Array" && callable.name === "from" && args[1] !== undefined) { + const apply = this.applyCollectionCallback(args[1], "Array.from", node) + const items = invokeArrayStatic("from", args, node) as Array + return yield* Effect.forEach(items, (item, index) => apply([item, index])) + } // 令牌不捕获旧执行器;跨单元别名始终在当前根作用域登记计时器。 - if (callable.namespace === "$builtin") return yield* self.invokeSleep(args, node) - if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node) + if (callable.namespace === "$builtin") return yield* this.invokeSleep(args, node) + if (callable.namespace === "console") return this.invokeConsole(callable.name, args, node) if (callable.namespace === "Object" && args[0] instanceof ToolReference) { - return self.invokeObjectMethodOnTools(callable.name, args[0] as ToolReference, node) + return this.invokeObjectMethodOnTools(callable.name, args[0] as ToolReference, node) } return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`) } @@ -2054,13 +2082,7 @@ class Interpreter { if (callable instanceof ErrorConstructorReference) { return createErrorValue(callable.name, args[0] === undefined ? "" : coerceToString(args[0])) } - // 保留首次求值的 receiver;诊断只显示有限键名,不读取值或重复执行表达式。 - const [value, label] = - isRecord(reference) && "target" in reference ? [reference.target, "receiver"] : [callable, "value"] - throw new InterpreterRuntimeError( - `Value is not callable in CodeMode. Actual ${label}: ${describeValueShape(value)}.`, - callee, - ) + throw new InterpreterRuntimeError("Value is not callable in CodeMode.", node) }) } @@ -2372,7 +2394,7 @@ class Interpreter { if (typeof ref.receiver === "string") { if ( (ref.name === "replace" || ref.name === "replaceAll") && - (args[1] instanceof CodeModeFunction || args[1] instanceof CoercionFunction || args[1] instanceof UriFunction) + typeofValue(args[1]) === "function" ) { return this.invokeStringReplacer(ref.receiver, ref.name, args, node) } @@ -2480,23 +2502,10 @@ class Interpreter { name: string, node: AstNode, ): (args: Array) => Effect.Effect { - if ( - !(callback instanceof CodeModeFunction) && - !(callback instanceof CoercionFunction) && - !(callback instanceof UriFunction) && - // 仅放行 sleep 令牌;其他全局方法继续遵守原来的回调白名单。 - !(callback instanceof GlobalMethodReference && callback.namespace === "$builtin" && callback.name === "sleep") - ) { + if (typeofValue(callback) !== "function" || callback instanceof ToolReference) { throw new InterpreterRuntimeError(`${name} expects a function callback.`, node) } - return (callbackArgs) => - callback instanceof GlobalMethodReference - ? this.invokeSleep(callbackArgs, node) - : callback instanceof CoercionFunction - ? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) - : callback instanceof UriFunction - ? Effect.succeed(invokeUriFunction(callback, callbackArgs, node)) - : this.invokeFunction(callback, callbackArgs) + return (callbackArgs) => this.invokeCallable(callback, callbackArgs, node) } private invokeMapMethod( @@ -2699,8 +2708,9 @@ class Interpreter { case "reverse": return Effect.succeed([...target].reverse()) case "sort": - case "toSorted": return this.sortArray(target, args[0], node) + case "toSorted": + return this.sortArray([...target], args[0], node) case "toReversed": return Effect.succeed([...target].reverse()) case "with": { @@ -2767,18 +2777,26 @@ class Interpreter { return Effect.gen(function* () { // Iterate a snapshot taken at call time so a callback that mutates the array can't // self-extend the loop - matching JS, where elements appended during iteration are not visited. + // 空槽也让出调度:没有回调执行时,仍须响应既有根超时和取消。 const items = target.slice() switch (name) { case "map": { - const values: Array = [] - for (const [index, item] of items.entries()) values.push(yield* apply([item, index, items])) + const values = new Array(items.length) + for (const [index, item] of items.entries()) { + if (index in items) values[index] = yield* apply([item, index, items]) + else yield* Effect.yieldNow + } return values } case "flatMap": { const values: Array = [] for (const [index, item] of items.entries()) { + if (!(index in items)) { + yield* Effect.yieldNow + continue + } const mapped = yield* apply([item, index, items]) - if (Array.isArray(mapped)) values.push(...mapped) + if (Array.isArray(mapped)) values.push(...mapped.filter(() => true)) else values.push(mapped) } return values @@ -2786,6 +2804,10 @@ class Interpreter { case "filter": { const values: Array = [] for (const [index, item] of items.entries()) { + if (!(index in items)) { + yield* Effect.yieldNow + continue + } if (yield* apply([item, index, items])) values.push(item) } return values @@ -2802,16 +2824,27 @@ class Interpreter { return -1 case "some": for (const [index, item] of items.entries()) { + if (!(index in items)) { + yield* Effect.yieldNow + continue + } if (yield* apply([item, index, items])) return true } return false case "every": for (const [index, item] of items.entries()) { + if (!(index in items)) { + yield* Effect.yieldNow + continue + } if (!(yield* apply([item, index, items]))) return false } return true case "forEach": - for (const [index, item] of items.entries()) yield* apply([item, index, items]) + for (const [index, item] of items.entries()) { + if (index in items) yield* apply([item, index, items]) + else yield* Effect.yieldNow + } return undefined case "reduce": { let accumulator: unknown @@ -2820,13 +2853,18 @@ class Interpreter { accumulator = args[1] start = 0 } else { - if (items.length === 0) + start = 0 + while (start < items.length && !(start in items)) { + yield* Effect.yieldNow + start += 1 + } + if (start === items.length) throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node) - accumulator = items[0] - start = 1 + accumulator = items[start++] } for (let index = start; index < items.length; index += 1) { - accumulator = yield* apply([accumulator, items[index], index, items]) + if (index in items) accumulator = yield* apply([accumulator, items[index], index, items]) + else yield* Effect.yieldNow } return accumulator } @@ -2837,13 +2875,18 @@ class Interpreter { accumulator = args[1] start = items.length - 1 } else { - if (items.length === 0) + start = items.length - 1 + while (start >= 0 && !(start in items)) { + yield* Effect.yieldNow + start -= 1 + } + if (start < 0) throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node) - accumulator = items[items.length - 1] - start = items.length - 2 + accumulator = items[start--] } for (let index = start; index >= 0; index -= 1) { - accumulator = yield* apply([accumulator, items[index], index, items]) + if (index in items) accumulator = yield* apply([accumulator, items[index], index, items]) + else yield* Effect.yieldNow } return accumulator } @@ -2867,19 +2910,16 @@ class Interpreter { comparator: unknown, node: AstNode, ): Effect.Effect, unknown, R> { - if (comparator !== undefined && !(comparator instanceof CodeModeFunction)) { - throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node) - } - if (!(comparator instanceof CodeModeFunction)) { + const apply = comparator === undefined ? undefined : this.applyCollectionCallback(comparator, "Array.sort", node) + if (!apply) { return Effect.sync(() => - [...target].sort((a, b) => { + target.slice().sort((a, b) => { const left = coerceToString(a) const right = coerceToString(b) return left < right ? -1 : left > right ? 1 : 0 }), ) } - const self = this const mergeSort = (items: Array): Effect.Effect, unknown, R> => { if (items.length <= 1) return Effect.succeed(items) const midpoint = Math.floor(items.length / 2) @@ -2892,7 +2932,7 @@ class Interpreter { while (leftIndex < left.length && rightIndex < right.length) { // Coerce the comparator's result like JS ToNumber (data objects -> NaN, never a host // crash) and treat NaN as 0 - the spec's "no consistent order" -> keep the left element. - const order = coerceToNumber(yield* self.invokeFunction(comparator, [left[leftIndex], right[rightIndex]])) + const order = coerceToNumber(yield* apply([left[leftIndex], right[rightIndex]])) if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++]) else merged.push(right[rightIndex++]) } @@ -2901,8 +2941,12 @@ class Interpreter { } // Per spec, undefined elements sort to the end and the comparator is never called on them. const defined = target.filter((item) => item !== undefined) - const undefinedCount = target.length - defined.length - return Effect.map(mergeSort(defined), (items) => [...items, ...Array(undefinedCount).fill(undefined)]) + const undefinedCount = target.filter((item) => item === undefined).length + return Effect.map(mergeSort(defined), (items) => { + const result = [...items, ...Array(undefinedCount).fill(undefined)] + result.length = target.length + return result + }) } private evaluateObjectExpression(node: AstNode): Effect.Effect, unknown, R> { @@ -3495,12 +3539,23 @@ export const executeWithLimits = >( const program = parseProgram(options.code, typescript) const promises: Array = [] let unobservedFailure = false + // 所有嵌套、函数与并行帧共享本次根执行计数;复用环境不会复用预算。 + let loopIterations = 0 // 正常 return 也关闭仍在运行的生产者;已完成的宿主副作用保持原样。 const value = yield* Effect.scopedWith((scope) => Interpreter.make( typescript, { invokeTool: tools.invoke, + enterLoop: (node) => { + if (limits.maxLoopIterations === undefined) return + if (loopIterations >= limits.maxLoopIterations) + throw new InterpreterRuntimeError( + `Execution exceeded its total loop-iteration limit of ${limits.maxLoopIterations}. Split the work across Execute calls.`, + node, "LoopIterationLimitExceeded", + ) + loopIterations++ + }, toolKeys: tools.keys, log, scope, diff --git a/packages/codemode/src/stdlib/math.ts b/packages/codemode/src/stdlib/math.ts index cc8dd0670e96..8db7fccf0217 100644 --- a/packages/codemode/src/stdlib/math.ts +++ b/packages/codemode/src/stdlib/math.ts @@ -21,10 +21,9 @@ export const mathMethods = new Set([ export const invokeMathMethod = (name: string, args: Array, node: AstNode): number => { if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node) - const nums = args.map((arg) => { - if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node) - return arg - }) + // Fixed-arity builtins ignore callback index/array arguments, as native JS does. + const input = name === "max" || name === "min" || name === "hypot" ? args : args.slice(0, name === "pow" ? 2 : 1) + const nums = input.map(coerceToNumber) const [a = Number.NaN, b = Number.NaN] = nums switch (name) { case "max": @@ -63,3 +62,5 @@ export const invokeMathMethod = (name: string, args: Array, node: AstNo throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node) } import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" + +import { coerceToNumber } from "./value.js" diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index 6c9fbf2a08be..0acea6523ef7 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -37,7 +37,7 @@ export type SchemaType = Schema.Decoder | JsonSchema export type Definition = { readonly _tag: "CodeModeTool" readonly description: string - // 一个实际合同同时服务展示和形式推导;input 仍独立决定此层是否解码。 + // 模型可见的输入合同;宿主的运行时合同仅用于推导快捷形式。 readonly inputContract: SchemaType readonly inputForms?: InputForms readonly input: SchemaType @@ -54,7 +54,7 @@ type ResultType = S extends Schema.Decoder ? S["Encoded"] : unknown /** Options for defining one CodeMode tool. */ export type Options = { readonly description: string - // 适配器传完整原始合同而非派生形状;其实际解码仍由原宿主执行。 + // 适配器提供原宿主合同以推导快捷形式,不覆盖 input 的可见字段;解码仍由宿主执行。 readonly inputContract?: Schema.Decoder readonly input: I readonly output?: O @@ -100,7 +100,7 @@ export const make = { RangeError, ) expect(() => CodeMode.execute({ code: "return 1", limits: { maxToolCalls: -1 } })).toThrow(RangeError) + expect(() => CodeMode.execute({ code: "return 1", limits: { maxLoopIterations: -1 } })).toThrow(RangeError) expect(() => CodeMode.execute({ code: "return 1", limits: { maxOutputBytes: -1 } })).toThrow(RangeError) // 日志留存预算独立于结果预算;无日志脚本也应在构造执行时拒绝无效配置。 expect(() => CodeMode.execute({ code: "return 1", limits: { maxLogBytes: -1 } })).toThrow(RangeError) @@ -1687,6 +1688,41 @@ describe("CodeMode public contract", () => { if (!result.ok) expect(result.error.kind).toBe("ToolCallLimitExceeded") }) + test("loop budgets count body entries across loop forms and reset per execution", async () => { + for (const code of [ + "for (let i = 0; i < 3; i++) console.log(i)", + "let i = 0; while (i < 3) console.log(i++)", + "let i = 0; do { console.log(i++) } while (i < 3)", + "for (const i of [0, 1, 2]) console.log(i)", + "for await (const i of [0, 1, 2]) console.log(i)", + "for (const i in {a: 0, b: 1, c: 2}) console.log(i)", + ]) { + const runtime = CodeMode.make({ limits: { maxLoopIterations: 3 } }) + for (let run = 0; run < 2; run++) expect(await Effect.runPromise(runtime.execute(code))).toMatchObject({ ok: true }) + const result = await Effect.runPromise(CodeMode.execute({ code, limits: { maxLoopIterations: 2 } })) + expect(result).toMatchObject({ ok: false, error: { kind: "LoopIterationLimitExceeded" } }) + expect(result.logs).toHaveLength(2) + } + for (const code of [ + "for (let i=0; i<2; i++) { for (let j=0; j<2; j++) continue }", + "async function f() { for (let i=0; i<2; i++) { await sleep(0); } } await Promise.all([f(), f()])", + ]) expect(await Effect.runPromise(CodeMode.execute({ code, limits: { maxLoopIterations: 3 } }))) + .toMatchObject({ ok: false, error: { kind: "LoopIterationLimitExceeded" } }) + expect(await Effect.runPromise(CodeMode.execute({ code: "while(false) {}", limits: { maxLoopIterations: 0 } }))) + .toMatchObject({ ok: true }) + }) + + test("parallel tool admission cannot exceed the shared tool-call budget", async () => { + let called = 0 + const count = Tool.make({ description: "count", input: Schema.Struct({}), output: Schema.Number, + run: () => Effect.sync(() => ++called) }) + const result = await Effect.runPromise(CodeMode.execute({ tools: { count }, limits: { maxToolCalls: 2 }, + code: "await Promise.all([tools.count({}), tools.count({}), tools.count({})])" })) + expect(result).toMatchObject({ ok: false, error: { kind: "ToolCallLimitExceeded" } }) + expect(result.toolCalls).toHaveLength(2) + expect(called).toBe(2) + }) + test("timeoutMs and maxToolCalls have no defaults: absent means unlimited", async () => { // 150 tool calls would have exceeded the old default cap of 100; with no limits // provided, there is no cap and no timeout - budgets are host policy. @@ -2910,3 +2946,52 @@ describe("CodeMode retained environments", () => { ) }) }) + +describe("builtin callbacks and array construction", () => { + const execute = (code: string) => Effect.runPromise(CodeMode.make({ tools: {} }).execute(code)) + + test("uses sandbox builtins as collection, sort, promise and replacement callbacks", async () => { + expect(await execute(`return { + floor: [1.8, 2.2].map(Math.floor), json: ['{"n":1}'].map(JSON.parse), + keys: [{a: 1}, {b: 2}].map(Object.keys), finite: [1, Infinity].filter(Number.isFinite), + sort: [3, -1, 2].sort(Array.of), replace: "a1b2".replace(/\\d/g, Math.floor), + promised: await Promise.resolve(2.8).then(Math.floor) + }`)).toMatchObject({ ok: true, value: { + floor: [1, 2], json: [{ n: 1 }], keys: [["a"], ["b"]], finite: [1], + sort: [3, -1, 2], replace: "a1b2", promised: 2, + } }) + }) + + test("constructs numeric lengths and element lists, preserving sparse callback behavior", async () => { + expect(await execute(`const sparse = Array(3); sparse[2] = 4; + return { empty: Array(), filled: new Array(3).fill(2), one: Array("3"), many: Array(1, 2), + holes: Object.keys(Array(3).map(() => 1)), sortedHoles: Object.keys(Array(3).sort(Array.of)), denseSorted: Object.keys(Array(3).toSorted()), filtered: sparse.filter(Boolean), + reduced: sparse.reduce((a, b) => a + b), right: sparse.reduceRight((a, b) => a + b), + every: Array(3).every(() => false), some: Array(3).some(() => true) }`)).toMatchObject({ + ok: true, value: { empty: [], filled: [2, 2, 2], one: ["3"], many: [1, 2], + holes: [], sortedHoles: [], denseSorted: ["0", "1", "2"], filtered: [4], reduced: 4, right: 4, every: true, some: false }, + }) + expect(await execute('try { Array(-1) } catch (e) { return e.name }')).toMatchObject({ ok: true, value: "RangeError" }) + }) + + test("sparse callback scans honor the existing execution timeout", async () => { + for (const method of ["map", "flatMap", "filter", "some", "every", "forEach", "reduce", "reduceRight"]) { + for (const initial of method.startsWith("reduce") ? ["", ", 0"] : [""]) { + expect(await Effect.runPromise(CodeMode.execute({ + code: `return Array(1_000_000).${method}(() => 0${initial})`, limits: { timeoutMs: 10 }, + }))).toMatchObject({ ok: false, error: { kind: "TimeoutExceeded" } }) + } + } + }) + + test("Array.from maps values and indices including array-like holes and undefined mapper", async () => { + expect(await execute(`return { + indices: Array.from({length: 3}, (_, i) => i), floor: Array.from([1.8, 2.2], Math.floor), + text: Array.from("ab", (v, i) => v + i), set: Array.from(new Set([2, 3]), v => v * 2), + plain: Array.from([1, 2], undefined), args: Array.from([5], (...args) => args.length) + }`)).toMatchObject({ ok: true, value: { + indices: [0, 1, 2], floor: [1, 2], text: ["a0", "b1"], set: [4, 6], plain: [1, 2], args: [2], + } }) + expect(await execute('return Array.from([], 1)')).toMatchObject({ ok: false }) + }) +}) diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index d3cfad866d0e..fc83d4b7a322 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -1208,7 +1208,7 @@ describe("async standard-library callbacks and iteration", () => { }) // 内建令牌沿用函数的不透明边界,别名和集合回调只能返回受监管的 Promise。 - test("sleep aliases and callbacks retain callable identity without exposing global methods", async () => { + test("sleep aliases and callbacks retain callable identity without exposing host properties", async () => { expect( await value(`const pause = sleep; const box = { pause }; await box.pause(0.25); await Promise.all([0, 0].map(pause)); @@ -1216,11 +1216,10 @@ describe("async standard-library callbacks and iteration", () => { async function nested() { await pause(0); return 3; } return [typeof pause, await nested(), await pause(0)];`), ).toEqual(["function", 3, null]) - // sleep可当回调传递但不暴露宿主函数属性;Math.abs的反例保留原有全局方法不可提取的限制。 + // 内建可当回调传递,但不能借此读取宿主函数属性。 for (const code of [ "return sleep.name", "return sleep.constructor", - "return [1].map(Math.abs)", "return sleep.call(null, 0)", ]) { expect((await run(code)).ok).toBe(false) diff --git a/packages/codemode/test/signature.test.ts b/packages/codemode/test/signature.test.ts index 94946b3fea22..a895454753be 100644 --- a/packages/codemode/test/signature.test.ts +++ b/packages/codemode/test/signature.test.ts @@ -320,9 +320,7 @@ test("search emits the actual encoded input, decoded output and shared definitio tools: { call: Tool.make({ description: "Actual contract", - input: { type: "string" }, - // 展示输入故意写成string而真实合同是对象;详情必须由inputContract发射,不能沿展示schema取巧。 - inputContract: input, + input, output: Schema.Struct({ amount: Schema.NumberFromString, person }), run: () => Effect.succeed({ amount: "1", person: { name: "Ada" } }), }), @@ -455,22 +453,19 @@ describe("non-identifier tool paths", () => { }) }) -// 适配器展示 schema 不得覆盖实际标量合同;签名和执行都保留原始类型。 +// 标量适配器展示编码类型,执行仍由宿主验证。 test("scalar adapter signature uses its authoritative encoded scalar", async () => { const runtime = CodeMode.make({ tools: { call: Tool.make({ description: "Scalar adapter", - input: { - type: "object", - properties: { misleading: { type: "boolean" } }, - }, + input: { type: "string" }, inputContract: Schema.String, run: (value) => Effect.succeed(Schema.decodeUnknownSync(Schema.String)(value)), }), }, }) - // 显示对象与实际字符串合同相冲突,签名使用string且调用拒绝包装对象,两个出口共同约束合同来源。 + // 签名使用 string,宿主仍拒绝包装对象。 expect(runtime.catalog()[0].signature).toBe("tools.call(input: string): Promise") expect(await Effect.runPromise(runtime.execute('return await tools.call("yes")'))).toMatchObject({ ok: true, @@ -481,7 +476,7 @@ test("scalar adapter signature uses its authoritative encoded scalar", async () }) }) -// 签名、发现和实际校验必须读取同一对象合同,不能被适配器展示字段替代。 +// 宿主合同保留快捷形式和校验,可见 schema 独立控制签名、搜索和错误建议。 for (const [actual, expected, input, value] of [ [ Schema.Struct({ @@ -499,14 +494,14 @@ for (const [actual, expected, input, value] of [ { realKey: "yes", count: 2 }, ], ] as const) { - test(`adapter contract remains authoritative for ${expected}`, async () => { + test(`adapter presentation preserves runtime decoding for ${expected}`, async () => { let decodes = 0 const runtime = CodeMode.make({ tools: { adapter: { call: Tool.make({ description: "Adapter", - input: { type: "object", properties: { misleading: { type: "boolean" } } }, + input: { type: "object", properties: { visible: { type: "boolean", description: "visibleneedle" } } }, inputContract: actual, run: (input) => Effect.sync(() => { @@ -517,18 +512,24 @@ for (const [actual, expected, input, value] of [ }, }, }) - const signature = `tools.adapter.call(input: ${expected}): Promise` + const signature = "tools.adapter.call(input: { visible?: boolean }): Promise" expect(runtime.catalog()[0].signature).toBe(signature) expect(runtime.instructions()).toContain(signature) - // 字段索引同样取真实来源,不能签名已修而 search 仍只索引虚假展示字段。 + // 所有发现出口遵守公开 schema,宿主内部字段不能重新进入模型上下文。 expect( - await Effect.runPromise(runtime.execute('return search({query:"contractneedle"}).items[0].signature')), + await Effect.runPromise(runtime.execute('return search({query:"visibleneedle"}).items[0].signature')), ).toMatchObject({ ok: true, value: signature }) expect(await Effect.runPromise(runtime.execute("return tools.adapter()"))).toMatchObject({ ok: false, error: { suggestions: [signature] }, }) // 目录、搜索和错误建议都已生成,计数仍为0才能说明这些发现出口没有执行宿主decoder。 + expect((await search(runtime)).items[0].inputSchema).toEqual({ + type: "object", properties: { visible: { type: "boolean", description: "visibleneedle" } }, + }) + expect(await Effect.runPromise(runtime.execute('return search({query:"contractneedle"}).items'))).toMatchObject({ + ok: true, value: [], + }) expect(decodes).toBe(0) // 多必填字段没有快捷形式也必须保留完整合同,真实解码只发生在调用本身。 expect(await Effect.runPromise(runtime.execute(`return await tools.adapter.call(${input})`))).toMatchObject({ @@ -539,7 +540,7 @@ for (const [actual, expected, input, value] of [ expect( await Effect.runPromise(runtime.execute("return await tools.adapter.call({misleading:true})")), ).toMatchObject({ ok: false }) - // 第二次传的是展示schema里的虚假字段;宿主decoder仍需被调用并拒绝,不能因目录展示成功而放行。 + // 公开 schema 不替代宿主校验,无效输入仍由原 decoder 拒绝。 expect(decodes).toBe(2) }) } diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index 3354caaf4ac0..6b7c32ac8787 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -18,7 +18,6 @@ import path from "path" */ const ERROR_BYTES = 8 * 1024 -const MAX_RECORD_BYTES = 64 * 1024 const MAX_SUBMATCHES = 100 // #138:候选文件上限 export const MAX_SEARCH_FILES = 5_000 @@ -364,13 +363,10 @@ const layer = Layer.effect( input.file ?? ".", ], parse: (line) => - (Buffer.byteLength(line, "utf8") > MAX_RECORD_BYTES - ? Effect.fail(failure(`Ripgrep JSON record exceeded ${MAX_RECORD_BYTES} bytes`)) - : Effect.try({ - try: () => JSON.parse(line) as unknown, - catch: (cause) => failure("Invalid ripgrep JSON output", cause), - }) - ).pipe( + Effect.try({ + try: () => JSON.parse(line) as unknown, + catch: (cause) => failure("Invalid ripgrep JSON output", cause), + }).pipe( Effect.flatMap((json) => { if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match") return Effect.succeed(undefined) diff --git a/packages/core/src/tool/read-lines.ts b/packages/core/src/tool/read-lines.ts index a6686858077a..33e8cb169ec8 100644 --- a/packages/core/src/tool/read-lines.ts +++ b/packages/core/src/tool/read-lines.ts @@ -40,7 +40,7 @@ type ReadTextPageOptions = { auxiliaryFromStart?: boolean // outline 只消费 callback,不复制扫描文本到 raw,避免第二份 3000 行缓冲。 captureRaw?: boolean - // page 使用固定 16 KiB;outline 传 Infinity 仅关闭返回窗口限制,仍受共享物理预算约束。 + // page 默认 16 KiB,首行可超出以保证分页推进;outline 传 Infinity 仅关闭返回窗口限制,仍受共享物理预算约束。 contentBytesLimit?: number onLine?: (line: string, lineNumber: number) => boolean | void } @@ -146,8 +146,8 @@ export function createTextPageParser(opts: ReadTextPageOptions) { return } - // 16 KiB 按最终 join 语义累计:后续行还要计一个换行分隔字节。 - if (contentBytes + separatorBytes + candidateBytes > contentBytesLimit) { + // 首行完整返回以保证分页推进;后续行按 join 后的字节数决定是否留到下一页。 + if (returnedLines > 0 && contentBytes + separatorBytes + candidateBytes > contentBytesLimit) { cut = true enterAuxiliary(chunkSize, true) resetLine() @@ -175,7 +175,7 @@ export function createTextPageParser(opts: ReadTextPageOptions) { // 增量字节数避免每个字符重复编码整行,否则超长行会退化为平方复杂度。 const candidateBytes = lineEncodedBytes + characterBytes const separatorBytes = returnedLines > 0 ? 1 : 0 - if (contentBytes + separatorBytes + candidateBytes > contentBytesLimit) { + if (returnedLines > 0 && contentBytes + separatorBytes + candidateBytes > contentBytesLimit) { cut = true enterAuxiliary(chunkSize) } diff --git a/packages/core/src/tool/task.ts b/packages/core/src/tool/task.ts index 8081ade08dec..7b221024c718 100644 --- a/packages/core/src/tool/task.ts +++ b/packages/core/src/tool/task.ts @@ -1,7 +1,7 @@ export * as TaskTool from "./task" import { ToolFailure } from "@opencode-ai/llm" -import { ConfigProvider, DateTime, Effect, Layer, Schema, Scope } from "effect" +import { ConfigProvider, DateTime, Effect, Layer, Schema, Scope, Struct } from "effect" import { AgentV2 } from "../agent" import { BackgroundJob } from "../background-job" import { Config } from "../config" @@ -68,9 +68,16 @@ export const layer = Layer.effectDiscard(Effect.gen(function* () { removed: () => "Task agent guidance removed", })), }) + const backgroundEnabled = yield* enabled.pipe(Effect.orDie) yield* tools.register({ [name]: Tool.make({ - description: `${description}\n\nBackground mode returns immediately and delivers completion automatically. Do not sleep or poll; continue non-overlapping work.`, + description: backgroundEnabled + ? `${description}\n\nBackground mode returns immediately and delivers completion automatically. ` + + "Do not sleep or poll; continue non-overlapping work." + : description, input: Input, output: Output, + inputSchema: Schema.toJsonSchemaDocument( + backgroundEnabled ? Input : Input.mapFields(Struct.omit(["background"])), + ).schema, toModelOutput: ({ output }) => [{ type: "text", text: `task_id: ${output.task_id}\n${output.notice ? `${output.notice}\n` : ""}\n${output.output}\n` }], execute: (input, context) => Effect.gen(function* () { const agent = (yield* available).find(item => item.id === input.subagent_type) diff --git a/packages/core/test/ripgrep.test.ts b/packages/core/test/ripgrep.test.ts index 2c73c1485de1..1e6c419c3298 100644 --- a/packages/core/test/ripgrep.test.ts +++ b/packages/core/test/ripgrep.test.ts @@ -145,6 +145,22 @@ describe("Ripgrep", () => { ), ) + it.live("retains matches around a JSON record larger than 64 KiB", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.gen(function* () { + yield* Effect.promise(() => Bun.write(path.join(tmp.path, "long.txt"), + "needle before\nneedle " + "x".repeat(70_000) + "\nneedle after\n")) + const matches = yield* (yield* Ripgrep.Service).grep({ cwd: tmp.path, pattern: "needle", limit: 10 }) + expect(matches).toHaveLength(3) + expect(matches.map((match) => match.line)).toEqual([1, 2, 3]) + expect(matches[0]?.text).toBe("needle before\n") + expect(matches[2]?.text).toBe("needle after\n") + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + it.live("never includes git metadata", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 73242ed5879d..4f9f52f31cae 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1763,6 +1763,23 @@ describe("SessionRunnerLLM", () => { expect(phase).toBe(6) })) + it.live("task advertises background only when enabled", () => Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + for (const enabled of [false, true]) yield* Effect.scoped(Effect.gen(function* () { + yield* Layer.build(TaskTool.layer.pipe(Layer.provide(Layer.mergeAll( + Layer.succeed(ConfigProvider.ConfigProvider, + ConfigProvider.fromUnknown({ OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS: String(enabled) })), + Layer.mock(PermissionV2.Service, {}), + Layer.mock(BackgroundJob.Service, {}), + Layer.succeed(Location.Service, { directory: AbsolutePath.make("/project"), + project: { id: Project.ID.global, directory: AbsolutePath.make("/project") } }), + )))) + const task = (yield* registry.materialize()).definitions.find(tool => tool.name === "task")! + expect(Object.hasOwn(task.inputSchema.properties ?? {}, "background")).toBe(enabled) + expect(task.description.includes("Background mode")).toBe(enabled) + })) + })) + it.live("executes and resumes canonical task sessions with durable results and scoped permissions", () => Effect.gen(function* () { yield* setup const registry = yield* ToolRegistry.Service diff --git a/packages/core/test/tool-read-filesystem.test.ts b/packages/core/test/tool-read-filesystem.test.ts index 8e7fc30e1416..b8ce98ef5dc0 100644 --- a/packages/core/test/tool-read-filesystem.test.ts +++ b/packages/core/test/tool-read-filesystem.test.ts @@ -45,10 +45,10 @@ describe("ReadToolFileSystem", () => { yield* data.files.writeFileString(plain, "alpha\r\n\rbravo\ncharlie") expect(yield* ReadToolFileSystem.read(data.fs, plain, "boundaries.txt", { limit: 20 })) .toMatchObject({ content: "alpha\n\nbravo\ncharlie", truncated: false, read: { total: 4, totalExact: true } }) - // 单行超过正文预算也必须有界停止;不能为了等换行组装整份文件。 + // 超过正文预算的首行完整交付,避免空页和不推进的 next。 yield* data.files.writeFileString(plain, "x".repeat(1024 * 1024)) expect(yield* ReadToolFileSystem.read(data.fs, plain, "boundaries.txt")) - .toMatchObject({ content: "", truncated: true, read: { returned: 0, total: 1, totalExact: false, cut: true } }) + .toMatchObject({ content: "x".repeat(1024 * 1024), truncated: false, read: { returned: 1, total: 1, totalExact: true, cut: false } }) const firstLine = "a".repeat(16 * 1024 - 4) + "\u{1F600}" yield* data.files.writeFileString(plain, `\uFEFF${firstLine}\nbeta`) const unicode = yield* Effect.promise(() => ReadLines.readTextPage(plain, { offset: 1, limit: 20, contentBytesLimit: Infinity })) diff --git a/packages/opencode/src/tool/code-mode.ts b/packages/opencode/src/tool/code-mode.ts index 315d90f44542..e64d8d197a97 100644 --- a/packages/opencode/src/tool/code-mode.ts +++ b/packages/opencode/src/tool/code-mode.ts @@ -8,6 +8,10 @@ import type { PermissionPrecheck } from "@/permission/precheck" import { MAX_RESPONSE_SIZE } from "./webfetch" import { ExecuteEnvironments } from "./execute-environments" +// 预跑与真实执行各自使用同一上限,计数仍由各次 CodeMode 根执行拥有。 +const MAX_TOOL_CALLS = 1024 +const MAX_LOOP_ITERATIONS = 100_000 + // JSON 决定缺省值;显式环境变量仍通过宿主的 ConfigProvider 解码和覆盖。 export const configuration = (config: ConfigV1.Info["codemode"]) => Config.all({ @@ -163,7 +167,7 @@ const toolTree = Effect.fn("CodeMode.toolTree")(function* ( description: entry.description, // 沿用 V1 原 JSON Schema 签名适配:对象原样透传,真实输入校验仍由工具拥有。 input: entry.inputSchema as import("@opencode-ai/codemode").Tool.JsonSchema, - // 完整实际合同统一生成签名、字段索引与快捷形式,真实解码仍只由宿主执行。 + // 原始合同只推导快捷形式;签名与搜索尊重工具/插件裁剪后的 inputSchema。 inputContract: entry.runtimeParameters, output: entry.outputSchema as import("@opencode-ai/codemode").Tool.SchemaType | undefined, run: (input) => @@ -236,6 +240,7 @@ export const CodeModeTool = Tool.define( const init: Tool.DefWithoutID = { // 宿主才拥有环境句柄;共享库说明仍适用于无 Session 的一次性执行。 description: `${CodeMode.description} +Limits: ${MAX_TOOL_CALLS} tool calls and ${MAX_LOOP_ITERATIONS} total explicit loop iterations per execution. State: reuse the returned environmentID to inspect or continue saved variables, functions and closures; omit it for a fresh environment. Schema-less MCP object/array JSON text becomes data; use String(value) for its original text.`, parameters: Parameters, execute: withEnvironment(function* (params, ctx, lease, host) { @@ -289,7 +294,7 @@ State: reuse the returned environmentID to inspect or continue saved variables, tools, environment: lease.environment, // 十分钟约束真实执行;人类输入与权限审批暂停预算,预跑和整段审查在计时前。 - limits: { timeoutMs: params.timeout ?? 600000, maxLogBytes: outputLimits.maxBytes }, + limits: { timeoutMs: params.timeout ?? 600000, maxToolCalls: MAX_TOOL_CALLS, maxLoopIterations: MAX_LOOP_ITERATIONS, maxLogBytes: outputLimits.maxBytes }, fetch: host.fetch, waitForTimeout: host.waitForTimeout, onToolCallStart: ({ index, name, input }) => @@ -349,7 +354,7 @@ State: reuse the returned environmentID to inspect or continue saved variables, snapshotEnvironment: true, // 探针只发现调用,不重复真实脚本的轮询等待;Promise 仍由同一根作用域收尾。 sleep: () => Effect.void, - limits: { timeoutMs: 500, maxToolCalls: 1024, maxLogBytes: outputLimits.maxBytes }, + limits: { timeoutMs: 500, maxToolCalls: MAX_TOOL_CALLS, maxLoopIterations: MAX_LOOP_ITERATIONS, maxLogBytes: outputLimits.maxBytes }, // 预跑只记录HTTP意图,空响应不产生网络或权限副作用。 fetch: host.fetch ? (url, init) => diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index e6eefc1a61a9..814951c79b23 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -810,7 +810,7 @@ export const ShellTool = Tool.define( output, // 超时来自实际竞争结果,完整输出地址沿原保留策略,程序字段遵从R15。 // null退出码也可能来自取消;timedOut必须使用timeout分支的原事实。 - program: { value: { content: output, exit: code, truncated: cut, timedOut: expired, + program: { value: { content: end.text, exit: code, truncated: cut, timedOut: expired, ...(outputPath ? { path: outputPath } : {}), } }, } diff --git a/packages/opencode/test/server/tui-control-voice.test.ts b/packages/opencode/test/server/tui-control-voice.test.ts index 2353faf765a3..39063ede9027 100644 --- a/packages/opencode/test/server/tui-control-voice.test.ts +++ b/packages/opencode/test/server/tui-control-voice.test.ts @@ -1,5 +1,6 @@ import { describe, expect, spyOn, test } from "bun:test" import Http from "node:http" +import { AsyncLocalStorage } from "node:async_hooks" import fs from "node:fs/promises" import os from "node:os" import path from "path" @@ -302,7 +303,11 @@ const exported = {cookies:[{name:'oai-did',value:stage === 'auth-export' ? 'prof const requests: { url: string; cookie: string | null; signal: AbortSignal | null | undefined }[] = [] // 记录原始 URL,额外 session 刷新或绕路请求不能被重定向偷偷掩盖。 // fetch 返回只表示响应头可用,正文是否结束仍由生产解析阶段决定。 + const scope = new AsyncLocalStorage() + const originalFetch = NetworkProxy.fetch const fetch = spyOn(NetworkProxy, "fetch").mockImplementation(async (input, init) => { + // provider 状态的后台 HEAD 不属于语音调用;链内额外 URL 仍完整记录,不能按目标 URL 过滤。 + if (!scope.getStore()) return originalFetch(input, init) requests.push({ url: String(input), cookie: new Headers(init?.headers).get("cookie"), signal: init?.signal }) const response = await globalThis.fetch(url, init) // 只发布响应头就绪,不预读正文,否则会把生产解析阶段的超时缺陷藏到 fixture 里。 @@ -311,8 +316,8 @@ const exported = {cookies:[{name:'oai-did',value:stage === 'auth-export' ? 'prof }) const target = { config, key: "chatgpt", interpreter: process.execPath, script, environment: { FIXTURE_URL: url } } return { - config, file, target, requests, headers: headers.promise, - run: (signal = new AbortController().signal) => TuiControl.transcribeVoiceFile(file, target, signal), + config, file, target, requests, scope, headers: headers.promise, + run: (signal = new AbortController().signal) => scope.run(true, () => TuiControl.transcribeVoiceFile(file, target, signal)), commands: () => Bun.file(log).text(), async [Symbol.asyncDispose]() { fetch.mockRestore() @@ -324,6 +329,19 @@ const exported = {cookies:[{name:'oai-did',value:stage === 'auth-export' ? 'prof } describe("backend Cookie attempts", () => { + test("isolates background probes without hiding extra voice requests", async () => { + await using tmp = await tmpdir() + await using fixture = await backendFixture(tmp.path, () => Response.json({ text: "fixture transcript" })) + const background = await NetworkProxy.fetch(fixture.target.environment.FIXTURE_URL, { method: "HEAD" }) + expect(background.status).toBe(200) + expect(fixture.requests).toEqual([]) + await fixture.scope.run(true, async () => { + const response = await NetworkProxy.fetch("https://chatgpt.com/unexpected-session-refresh") + await response.text() + }) + expect(fixture.requests.map((request) => request.url)).toEqual(["https://chatgpt.com/unexpected-session-refresh"]) + }) + // Cookie-only 429属于匿名额度,成功浏览器返回的实际Bearer必须在下一轮直接复用。 // 429直接进入浏览器;401/403先读取profile,Cookie-only额度拒绝后仍须提交同一份账户快照。 test.each([429, 401, 403])("reuses the browser account snapshot after HTTP %i recovery", async (status) => { diff --git a/packages/opencode/test/session/execute.test.ts b/packages/opencode/test/session/execute.test.ts index 83b927632553..64d7cb61a372 100644 --- a/packages/opencode/test/session/execute.test.ts +++ b/packages/opencode/test/session/execute.test.ts @@ -2265,11 +2265,13 @@ it.instance("final Execute receipt budgets actual image omission before clipping expect((yield* finalModelReceipt(messages)).value).toBe(parent.state.output) })) -it.instance("final Execute failure bounds original diagnostics and provider error text", () => Effect.gen(function* () { +it.instance("final Execute failure preserves displayed output and errors without exposing undisplayed tool results", () => Effect.gen(function* () { + const instance = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(instance.directory, "hidden.txt"), "UNDISPLAYED_TOOL_BODY")) const observed = yield* observeFinalReceipt() const fixture = yield* resolveTools() yield* fixture.processor.process({ user: fixture.user, sessionID: fixture.session.id, model: provider.model, - agent: fixture.agent, system: ['console.log("CAPTURED_LOG"); throw new Error("ERROR_HEAD" + "x".repeat(60_000) + "ERROR_TAIL");'], + agent: fixture.agent, system: ['await tools.$opencode.read("hidden.txt"); console.log("CAPTURED_LOG"); throw new Error("ERROR_HEAD" + "x".repeat(60_000) + "ERROR_TAIL");'], messages: [], tools: fixture.tools }) const messages = yield* fixture.sessions.messages({ sessionID: fixture.session.id }) const parent = messages.flatMap((message) => message.parts) @@ -2283,6 +2285,7 @@ it.instance("final Execute failure bounds original diagnostics and provider erro expect(full).toContain("ERROR_HEAD") expect(full).toContain("ERROR_TAIL") expect(full).toContain("CAPTURED_LOG") + expect(full).not.toContain("UNDISPLAYED_TOOL_BODY") })) it.instance("final Execute long success includes real elapsed notice in one saved receipt", () => Effect.gen(function* () { diff --git a/packages/opencode/test/tool/code-mode.test.ts b/packages/opencode/test/tool/code-mode.test.ts index 909296dff1e3..e7c5ed728b73 100644 --- a/packages/opencode/test/tool/code-mode.test.ts +++ b/packages/opencode/test/tool/code-mode.test.ts @@ -469,12 +469,12 @@ describe("code mode host fixtures", () => { }), ) - testEffect(harness()).effect("preview caps total calls while review retains unique tool inputs", () => + testEffect(harness()).effect("preview and execution each cap total calls while review retains unique inputs", () => Effect.gen(function* () { const tool = yield* CodeModeTool.pipe(Effect.flatMap(Tool.init)) const reviewed: unknown[] = [] let invoked = 0 - // 第 1024 次的新参数仍被记录,第 1025 次只在真实执行出现;重复调用同样耗费预算。 + // 第1024次仍接纳,第1025次不得调用宿主;预跑与真实执行使用独立计数。 const result = yield* tool.execute( { code: "for (let i = 0; i < 1023; i++) await tools.host.check({a:1}); await tools.host.check({a:2}); await tools.host.check({a:3}); return 7", @@ -495,14 +495,15 @@ describe("code mode host fixtures", () => { }), }, }, - ) + ).pipe(Effect.exit) expect(reviewed).toEqual([ { tool: "check", input: { a: 1 } }, { tool: "check", input: { a: 2 } }, ]) - // 预跑只限制收集成本,真实执行仍须到达最后一次调用,不能沿用探针计数。 - expect(invoked).toBe(1025) - expect(scriptOutput(result)).toBe("7") + // 预跑计数不会消耗真实预算,重复参数调用仍计入1024次总量。 + expect(invoked).toBe(1024) + expect(Exit.isFailure(result)).toBe(true) + if (Exit.isFailure(result)) expect(String(Cause.squash(result.cause))).toContain("tool-call limit of 1024") }), ) @@ -977,6 +978,14 @@ describe("code mode host fixtures", () => { expect(output.metadata.toolCalls.every((c) => c.status === "completed")).toBe(true) }) + test("the host loop budget preserves logs and reports the configured total", async () => { + const tool = await build({}) + const error = await failure(tool.execute({ code: 'console.log("before loop"); while (true) {}' }, ctx)) + expect(error.message).toContain("loop-iteration limit of 100000") + expect(error.message).toContain("before loop") + expect(tool.description).toContain("1024 tool calls and 100000 total explicit loop iterations") + }) + test("a program failure fails the tool with a readable error", async () => { const tool = await build({}) const error = await failure(tool.execute({ code: "throw new Error('boom')" }, ctx)) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index a3496e3fc9fd..2a1a335603b7 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -433,11 +433,11 @@ describe("tool.read truncation", () => { expect(changed.output).not.toContain(" tool.id === "github-pr-search") diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index e83b41f06534..16967f44ac38 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -2651,6 +2651,7 @@ describe("tool.shell abort", () => { }) expect(result.metadata.exit).toBe(0) + expect(result.program?.value).toMatchObject({ content: "", exit: 0 }) expect(result.output).toContain("(no output)") expect(result.output).toContain( ' { }) expect(result.metadata.exit).toBe(7) + expect(result.program?.value).toMatchObject({ content: "plain output\n", exit: 7 }) expect(result.output).toContain("plain output") expect(result.output).toContain( ' { // 检查实际默认模式广告,不能只依赖完整 Parameters 的 schema 快照。 expect(def.jsonSchema?.properties?.inspected_files).toMatchObject({ enum: ["none", "summary"] }) expect(def.jsonSchema?.properties?.background).toBeUndefined() + expect(yield* describeCatalog([{ + id: "task", path: ["$opencode", "task"], description: def.description, + inputSchema: ToolJsonSchema.fromTool({ ...def, id: tool.id }), runtimeParameters: def.runtimeParameters, + }])).not.toContain("background") const exit = yield* def .execute( @@ -1299,6 +1305,10 @@ describe("tool.task", () => { const { chat, assistant } = yield* seed() const tool = yield* TaskTool const def = yield* tool.init() + expect(yield* describeCatalog([{ + id: "task", path: ["$opencode", "task"], description: def.description, + inputSchema: ToolJsonSchema.fromTool({ ...def, id: tool.id }), runtimeParameters: def.runtimeParameters, + }])).toContain("background?") const result = yield* def.execute( {