Skip to content

fix(run): output-schema review follow-ups and provider constraint hints - #134

Merged
byapparov merged 8 commits into
mainfrom
fix/129-review-round2
Oct 7, 2026
Merged

byapparov merged 8 commits into
mainfrom
fix/129-review-round2

Conversation

@byapparov

Copy link
Copy Markdown
Contributor

Follow-up to #129 (released in v0.4.6). It addresses the second automated review round on #129, which posted after merge, and two improvements from live provider probes.

Review round 2 (verdicts recorded on #129)

  • Schema size bound: schemas above 64 KiB are a configuration error (exit 2 on the CLI path). The compile cache is keyed by a SHA-256 of the schema instead of the raw string. A linear-time regex engine remains Output schemas: use a linear-time regex engine for pattern/patternProperties #131.
  • Distinct config errors: a non-object JSON root now says schema root must be a JSON object. The type: "object" wording stays with the compiler.
  • Single terminal path: a rejected prompt/command request settles the event loop before flushing, so structured_output, session_complete and the classified stderr error each appear exactly once.
  • Telemetry for every rejection: each rejected StructuredOutput call emits structured_output_rejected and is counted in the terminal attempts, including extra calls after the budget is spent. The budget only decides whether another corrective request happens; the N+1 request bound is unchanged.
  • Bounded retries: retryCount is capped at 10, both on the wire schema and for --output-schema-retries.
  • Docs: the terminal event is one per prompt run.
  • Test hygiene: the attach fixture waits for the SSE stream before enqueueing, and the models.dev stalled-body test has a SIGKILL watchdog.
  • File API: one documented pattern in run.output.ts.
  • Not changed: the retryCount NaN finding was verified false (prompt() zod-parses its input, so the default of 2 applies).

Provider improvements (live-probe evidence on aictrl-dev/aictrl#5959)

  • Canonical schema in the tool description. The pinned Vertex adapter silently drops pattern, additionalProperties, min/max and $ref from tool parameters, so Gemini never saw those constraints and learned them only from rejections. The compact canonical schema is now appended to the StructuredOutput description (capped at 8 KiB; past the cap it says the validator enforces the full schema).
  • Named tool choice on the corrective turn. GLM honours a named tool_choice but not reliably "required". Normal schema steps keep required, so the agent keeps its tools. The corrective turn after a prose-only finish forces StructuredOutput by name.

Verification

  • packages/cli: bun run test gives 1664 pass, 7 skip, 0 fail. bun turbo typecheck is green; prettier and git diff --check are clean.
  • Mutation checks: reverting the loop settlement, the rejection counting, the schema in the description, or the named corrective choice each turns its test red.

🤖 Generated with Claude Code

@byapparov

Copy link
Copy Markdown
Contributor Author

Live verification (compiled lite binary at 2cef742; real Vertex gemini-3.8-flash and GLM):

Run Result
Vertex, hostile ^SEC-[0-9]{4}$ schema, retries 1 (×2) Accepted on attempt 1 both times, 0 rejections. Before this PR the same runs needed 1 rejection each, which is consistent with the canonical schema now being visible in the tool description (~1 KB; the adapter still sends only type/required/properties). Two runs is suggestive, not a statistic.
GLM, "answer in prose" prompt, retries 2 Prose on step 1 → one missing rejection → the corrective turn called StructuredOutput → accepted on attempt 2 (12 s).
Vertex, same prose prompt The model called StructuredOutput immediately, so the named corrective choice was not exercised on Vertex.
Vertex, no schema Unchanged (exit 0, no structured events).
Vertex, strict schema, default retries Exit 3 / missing. The model wandered outside --dir and the headless permission auto-reject ended the loop before a result. This is a harness/prompt artifact (the same prompt passed in rounds 1 and 2), not caused by this PR; filed separately.

`aictrl run` changes into --dir before reading --output-schema, so relative
schema and result paths resolve against --dir (as --file paths already do).
A relative path given from the shell's directory then failed with a bare
"schema.json: cannot read schema file". Resolve both paths up front and name
the absolute path in every configuration error, and document the behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@byapparov

byapparov commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor Author

Added 7184b474fd, a fix for a usability bug found while verifying the npm 0.4.6 install. aictrl run changes into --dir before reading --output-schema, so a relative path given from the shell's directory failed with a bare schema.json: cannot read schema file. Paths still resolve against --dir, consistent with --file. Configuration errors now print the resolved absolute path, and the README documents the behaviour. A regression test is included; reverting the fix makes it fail. Full suite at 7184b474fd: 1665 pass, 7 skip, 0 fail. Typecheck is green.

Comment thread packages/cli/README.md
@@ -41,8 +41,9 @@ aictrl run --output-schema result.schema.json --output-schema-retries 2 \
```

- `--output-schema <file>`: JSON Schema file, forwarded in local and `--attach` runs.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 64 KiB schema cap is enforced but undocumented.

Suggested change
- `--output-schema <file>`: JSON Schema file, forwarded in local and `--attach` runs.
Add to the `--output-schema <file>` bullet (and EVENTS.md's schema-validation paragraph): "The serialized schema must not exceed 64 KiB."
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, packages/cli/README.md:43):

Problem: 64 KiB schema cap is enforced but undocumented
Detail: output-schema.ts now rejects serialized schemas over 64 KiB ("serialized schema must not exceed 64 KiB") with test coverage, but none of the three docs this PR updates (README.md, packages/cli/README.md, EVENTS.md) mention the cap — the --output-schema bullet and the retries bullet document other bounds (0–10, 2 KB diagnostics) while staying silent on the new size limit. Users hitting the config error on a large schema have no documented contract.
Suggested fix: Add to the `--output-schema <file>` bullet (and EVENTS.md's schema-validation paragraph): "The serialized schema must not exceed 64 KiB."

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

output-schema.ts now rejects serialized schemas over 64 KiB ("serialized schema must not exceed 64 KiB") with test coverage, but none of the three docs this PR updates (README.md, packages/cli/README.md, EVENTS.md) mention the cap — the --output-schema bullet and the retries bullet document other bounds (0–10, 2 KB diagnostics) while staying silent on the new size limit. Users hitting the config error on a large schema have no documented contract.


- `--output-schema <file>`: JSON Schema file, forwarded in local and `--attach` runs.
- `--output-schema-retries <n>`: additional corrective turns, integer from 0 to 10,
  default 2. Zero permits one model request; N permits at most N+1 corrective
  requests. Each rejected call is counted, including multiple calls in one step.
- `--output-result <file>`: atomically write only the validated JSON value,

Comment thread packages/cli/src/cli/cmd/run.ts Outdated
try {
while (true) {
while (queue.length > 0) {
while (!opts.signal.aborted) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Abort drops queued events from subscribe() stream.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, packages/cli/src/cli/cmd/run.ts:1166-1170):

Problem: Abort drops queued events from subscribe() stream
Detail: On the new failure paths run.ts calls subscription.abort() while loop() may still have unconsumed events in the subscribe() queue. Both generator loops exit on abort without draining, so events already received from GlobalBus/SSE (e.g. a structured_output_rejected published just before a provider failure) are silently dropped from the NDJSON/JSON output. This may be deliberate ordering control (the new fixture asserts exactly one "reason" line), but terminal rejection telemetry published pre-failure is lost with no signal to the consumer.
Suggested fix: On abort, drain remaining queued events before breaking (e.g. after the aborted check, `while (queue.length > 0) yield queue.shift()`), or have run.ts await one final stream flush after subscription.abort() before consuming loopDone.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

On the new failure paths run.ts calls subscription.abort() while loop() may still have unconsumed events in the subscribe() queue. Both generator loops exit on abort without draining, so events already received from GlobalBus/SSE (e.g. a structured_output_rejected published just before a provider failure) are silently dropped from the NDJSON/JSON output. This may be deliberate ordering control (the new fixture asserts exactly one "reason" line), but terminal rejection telemetry published pre-failure is lost with no signal to the consumer.

                while (!opts.signal.aborted) {
                  while (queue.length > 0 && !opts.signal.aborted) {
                    yield queue.shift()
                  }
                  if (opts.signal.aborted) break
                  await new Promise<void>((r) => {
                    resolve = r
                  })
                }
              } finally {

Comment thread packages/cli/src/session/message-v2.ts Outdated
type: z.literal("json_schema"),
schema: z.record(z.string(), z.any()).meta({ ref: "JSONSchema" }),
retryCount: z.number().int().min(0).default(2),
retryCount: z.number().int().min(0).max(10).default(2),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 retryCount .max(10) breaks persisted values >10.

Suggested change
retryCount: z.number().int().min(0).max(10).default(2),
Either document the contract break, or clamp instead of rejecting on parse: `retryCount: z.number().int().min(0).transform((n) => Math.min(n, 10)).default(2)` so pre-existing values clamp rather than fail replay.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, packages/cli/src/session/message-v2.ts:86):

Problem: retryCount .max(10) breaks persisted values >10
Detail: Tightening OutputFormatJsonSchema.retryCount with .max(10) changes the message/wire-format contract: values >10 were previously valid (only the CLI guarded >= 0, so an older client or SDK caller could send retryCount 50), and they now fail zod parse on the message/replay path instead of clamping. If persisted sessions or attach peers can carry old values, replay breaks after upgrade with no migration path.
Suggested fix: Either document the contract break, or clamp instead of rejecting on parse: `retryCount: z.number().int().min(0).transform((n) => Math.min(n, 10)).default(2)` so pre-existing values clamp rather than fail replay.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

Tightening OutputFormatJsonSchema.retryCount with .max(10) changes the message/wire-format contract: values >10 were previously valid (only the CLI guarded >= 0, so an older client or SDK caller could send retryCount 50), and they now fail zod parse on the message/replay path instead of clamping. If persisted sessions or attach peers can carry old values, replay breaks after upgrade with no migration path.

      .object({
        type: z.literal("json_schema"),
        schema: z.record(z.string(), z.any()).meta({ ref: "JSONSchema" }),
        retryCount: z.number().int().min(0).max(10).default(2),
      })

: `Canonical JSON Schema (truncated; the validator enforces the full schema): ${new TextDecoder().decode(bytes.subarray(0, 8 * 1024), { stream: true })}`
const result = tool({
description: STRUCTURED_OUTPUT_DESCRIPTION,
description: `${STRUCTURED_OUTPUT_DESCRIPTION}\n\n${description}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Schema text embedded in tool description as instructions.

Suggested change
description: `${STRUCTURED_OUTPUT_DESCRIPTION}\n\n${description}`,
Strip or bound long free-text annotation fields (description, title, examples, errorMessage) before embedding, or document that --output-schema content is fully trusted instruction input.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, packages/cli/src/session/prompt.ts:1135):

Problem: Schema text embedded in tool description as instructions
Detail: The tool description now embeds the verbatim serialized schema, promoting schema-supplied free text (description/title/errorMessage/examples fields) from the structured inputSchema channel into the natural-language instruction channel. The schema author is normally the trusted operator, but any caller that sources the schema from less-trusted content gets that content treated as agent instructions. Consider documenting the schema file as fully trusted instruction input or stripping long annotation fields before embedding.
Suggested fix: Strip or bound long free-text annotation fields (description, title, examples, errorMessage) before embedding, or document that --output-schema content is fully trusted instruction input.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The tool description now embeds the verbatim serialized schema, promoting schema-supplied free text (description/title/errorMessage/examples fields) from the structured inputSchema channel into the natural-language instruction channel. The schema author is normally the trusted operator, but any caller that sources the schema from less-trusted content gets that content treated as agent instructions. Consider documenting the schema file as fully trusted instruction input or stripping long annotation fields before embedding.

    const result = tool({
      description: `${STRUCTURED_OUTPUT_DESCRIPTION}\n\n${description}`,

@@ -1127,8 +1125,14 @@ export namespace SessionPrompt {
}): AITool & { id: "StructuredOutput" } {
const validate = input.validate ?? OutputSchema.compile(input.schema)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 64 KiB cap bypassed when custom validate is passed.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, packages/cli/src/session/prompt.ts:1126):

Problem: 64 KiB cap bypassed when custom validate is passed
Detail: The new 64 KiB guard lives only inside OutputSchema.compile, but createStructuredOutputTool skips compile entirely when the caller passes input.validate (`input.validate ?? OutputSchema.compile(...)`). On that path JSON.stringify(input.schema) and Buffer.from(canonical) run on an unbounded schema and the tool is built with no size cap at all. Callers that inject a custom validator (createStructuredOutputTool is exported) silently bypass the limit the PR introduces.
Suggested fix: Hoist the cap out of compile (e.g. export OutputSchema.canonicalize(schema) that stringifies, enforces 64 KiB, returns {text, hash}) and call it unconditionally in createStructuredOutputTool before the validate fallback.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The new 64 KiB guard lives only inside OutputSchema.compile, but createStructuredOutputTool skips compile entirely when the caller passes input.validate (input.validate ?? OutputSchema.compile(...)). On that path JSON.stringify(input.schema) and Buffer.from(canonical) run on an unbounded schema and the tool is built with no size cap at all. Callers that inject a custom validator (createStructuredOutputTool is exported) silently bypass the limit the PR introduces.

  }): AITool & { id: "StructuredOutput" } {
    const validate = input.validate ?? OutputSchema.compile(input.schema)
    const schema = Object.fromEntries(Object.entries(input.schema).filter(([key]) => key !== "$schema"))
    const canonical = JSON.stringify(input.schema)
    const bytes = Buffer.from(canonical)
    const description =
      bytes.length <= 8 * 1024

Comment thread packages/cli/src/session/prompt.ts Outdated
}): AITool & { id: "StructuredOutput" } {
const validate = input.validate ?? OutputSchema.compile(input.schema)
const schema = Object.fromEntries(Object.entries(input.schema).filter(([key]) => key !== "$schema"))
const canonical = JSON.stringify(input.schema)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ Embedded schema copy diverges from provider inputSchema.

Suggested change
const canonical = JSON.stringify(input.schema)
Stringify the same object passed to jsonSchema() (post-$schema-strip, post-ProviderTransform) for the embedded copy, or at minimum stringify the stripped `schema` variable instead of `input.schema`.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, packages/cli/src/session/prompt.ts:1128):

Problem: Embedded schema copy diverges from provider inputSchema
Detail: canonical = JSON.stringify(input.schema) serializes the raw schema including $schema, while the schema advertised as the provider inputSchema strips $schema and applies ProviderTransform.schema. The text labelled "Canonical JSON Schema" therefore matches neither the inputSchema the model sees nor a key-order-normalized form, so the provider can be steered by two divergent copies. Embedding the validator's copy is arguably the intent, but the naming overstates it and the $schema line is dead weight in the description.
Suggested fix: Stringify the same object passed to jsonSchema() (post-$schema-strip, post-ProviderTransform) for the embedded copy, or at minimum stringify the stripped `schema` variable instead of `input.schema`.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

canonical = JSON.stringify(input.schema) serializes the raw schema including $schema, while the schema advertised as the provider inputSchema strips $schema and applies ProviderTransform.schema. The text labelled "Canonical JSON Schema" therefore matches neither the inputSchema the model sees nor a key-order-normalized form, so the provider can be steered by two divergent copies. Embedding the validator's copy is arguably the intent, but the naming overstates it and the $schema line is dead weight in the description.

    const schema = Object.fromEntries(Object.entries(input.schema).filter(([key]) => key !== "$schema"))\n    const canonical = JSON.stringify(input.schema)\n    const bytes = Buffer.from(canonical)

Comment thread packages/cli/src/session/prompt.ts Outdated
const description =
bytes.length <= 8 * 1024
? `Canonical JSON Schema: ${canonical}`
: `Canonical JSON Schema (truncated; the validator enforces the full schema): ${new TextDecoder().decode(bytes.subarray(0, 8 * 1024), { stream: true })}`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ stream:true decode drops split trailing character.

--- a/packages/cli/src/session/prompt.ts
+++ b/packages/cli/src/session/prompt.ts
@@ -1131,7 +1131,7 @@
     const description =
       bytes.length <= 8 * 1024
         ? `Canonical JSON Schema: ${canonical}`
-        : `Canonical JSON Schema (truncated; the validator enforces the full schema): ${new TextDecoder().decode(bytes.subarray(0, 8 * 1024), { stream: true })}`
+        : `Canonical JSON Schema (truncated; the validator enforces the full schema): ${new TextDecoder().decode(bytes.subarray(0, 8 * 1024))}`
     const result = tool({
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, packages/cli/src/session/prompt.ts:1133):

Problem: stream:true decode drops split trailing character
Detail: decode(bytes.subarray(0, 8*1024), { stream: true }) promises more input will follow, so an incomplete trailing multi-byte sequence is buffered and — since decode() is never called again — silently dropped (1–3 bytes vanish rather than becoming U+FFFD). Cosmetic since the blob is already truncated JSON, but stream mode is the wrong tool for a one-shot slice.
Suggested fix: Decode without stream mode: new TextDecoder().decode(bytes.subarray(0, 8 * 1024)) — non-stream decode emits U+FFFD for the split codepoint instead of dropping it.

Suggested patch:
--- a/packages/cli/src/session/prompt.ts
+++ b/packages/cli/src/session/prompt.ts
@@ -1131,7 +1131,7 @@
     const description =
       bytes.length <= 8 * 1024
         ? `Canonical JSON Schema: ${canonical}`
-        : `Canonical JSON Schema (truncated; the validator enforces the full schema): ${new TextDecoder().decode(bytes.subarray(0, 8 * 1024), { stream: true })}`
+        : `Canonical JSON Schema (truncated; the validator enforces the full schema): ${new TextDecoder().decode(bytes.subarray(0, 8 * 1024))}`
     const result = tool({

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

decode(bytes.subarray(0, 8*1024), { stream: true }) promises more input will follow, so an incomplete trailing multi-byte sequence is buffered and — since decode() is never called again — silently dropped (1–3 bytes vanish rather than becoming U+FFFD). Cosmetic since the blob is already truncated JSON, but stream mode is the wrong tool for a one-shot slice.

    const description =
      bytes.length <= 8 * 1024
        ? `Canonical JSON Schema: ${canonical}`
        : `Canonical JSON Schema (truncated; the validator enforces the full schema): ${new TextDecoder().decode(bytes.subarray(0, 8 * 1024), { stream: true })}`
    const result = tool({

Comment thread packages/cli/src/session/prompt.ts Outdated
@@ -753,7 +751,7 @@ export namespace SessionPrompt {
async onSuccess(output) {
if (structuredOutput !== undefined) return
if (!available) throw new Error("StructuredOutput attempt budget exhausted")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ Error still says 'attempt budget' after reterming.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, packages/cli/src/session/prompt.ts:753):

Problem: Error still says 'attempt budget' after reterming
Detail: This PR deliberately reterms the vocabulary: maxAttempts = the corrective turn budget, `attempt` = the per-call counter that may exceed it (EVENTS.md, CLI help, READMEs, tests). The unchanged error "StructuredOutput attempt budget exhausted" still conflates the two by calling the budget an "attempt budget", undercutting the reterming done everywhere else in the same PR.
Suggested fix: Rename the message to "StructuredOutput corrective turn budget exhausted" to match the documented terms.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

This PR deliberately reterms the vocabulary: maxAttempts = the corrective turn budget, attempt = the per-call counter that may exceed it (EVENTS.md, CLI help, READMEs, tests). The unchanged error "StructuredOutput attempt budget exhausted" still conflates the two by calling the budget an "attempt budget", undercutting the reterming done everywhere else in the same PR.

          async onSuccess(output) {\n            if (structuredOutput !== undefined) return\n            if (!available) throw new Error("StructuredOutput attempt budget exhausted")\n            attempts++\n            structuredOutput = output\n          },

// Extract the subscribe method body
const subscribeBody = source.slice(subscribeIdx, subscribeIdx + 600)
const subscribeBody = source.slice(subscribeIdx, source.indexOf("return { stream }", subscribeIdx))
// GlobalBus.on must appear BEFORE the generator definition

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ Sentinel indexOf -1 silently widens slice body.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, packages/cli/test/cli/run-event-race.test.ts:14):

Problem: Sentinel indexOf -1 silently widens slice body
Detail: subscribeBody = source.slice(subscribeIdx, source.indexOf("return { stream }", subscribeIdx)): if the sentinel is ever renamed/removed, indexOf returns -1 and slice(subscribeIdx, -1) yields nearly the whole file, so the ordering assertions run against unrelated code and can false-pass/false-fail instead of erroring clearly. (Same pattern in the second test.)
Suggested fix: const end = source.indexOf("return { stream }", subscribeIdx); expect(end).toBeGreaterThan(-1); then slice(subscribeIdx, end). Apply to both tests.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

subscribeBody = source.slice(subscribeIdx, source.indexOf("return { stream }", subscribeIdx)): if the sentinel is ever renamed/removed, indexOf returns -1 and slice(subscribeIdx, -1) yields nearly the whole file, so the ordering assertions run against unrelated code and can false-pass/false-fail instead of erroring clearly. (Same pattern in the second test.)

    const subscribeIdx = source.indexOf("async subscribe(")\n    expect(subscribeIdx).toBeGreaterThan(-1)\n    // Extract the subscribe method body\n    const subscribeBody = source.slice(subscribeIdx, source.indexOf("return { stream }", subscribeIdx))\n    // GlobalBus.on must appear BEFORE the generator definition

const canonical = { type: "object", description: "x".repeat(9 * 1024) }
const tool = SessionPrompt.createStructuredOutputTool({ schema: canonical, onSuccess() {} })
expect(tool.description).toContain("truncated; the validator enforces the full schema")
expect(tool.description?.split(": ").at(-1)).toBe(JSON.stringify(canonical).slice(0, 8 * 1024))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ Truncation test asserts chars, code truncates bytes.

Suggested change
expect(tool.description?.split(": ").at(-1)).toBe(JSON.stringify(canonical).slice(0, 8 * 1024))
Assert against the byte-truncated expectation: new TextDecoder().decode(Buffer.from(JSON.stringify(canonical)).subarray(0, 8 * 1024)) — or keep the char-slice with a comment that the fixture must stay ASCII.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, packages/cli/test/session/output-schema.test.ts:701):

Problem: Truncation test asserts chars, code truncates bytes
Detail: The assertion JSON.stringify(canonical).slice(0, 8 * 1024) slices UTF-16 code units while the implementation truncates BYTES (Buffer.from(canonical).subarray(0, 8*1024) decoded); the two coincide only for the ASCII "x".repeat fixture. A future multi-byte fixture would fail (or pass for the wrong reason), so the test encodes the wrong invariant.
Suggested fix: Assert against the byte-truncated expectation: new TextDecoder().decode(Buffer.from(JSON.stringify(canonical)).subarray(0, 8 * 1024)) — or keep the char-slice with a comment that the fixture must stay ASCII.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The assertion JSON.stringify(canonical).slice(0, 8 * 1024) slices UTF-16 code units while the implementation truncates BYTES (Buffer.from(canonical).subarray(0, 8*1024) decoded); the two coincide only for the ASCII "x".repeat fixture. A future multi-byte fixture would fail (or pass for the wrong reason), so the test encodes the wrong invariant.

test("tool description caps the canonical schema at 8 KiB and explains truncation", () => {\n  const canonical = { type: "object", description: "x".repeat(9 * 1024) }\n  const tool = SessionPrompt.createStructuredOutputTool({ schema: canonical, onSuccess() {} })\n  expect(tool.description).toContain("truncated; the validator enforces the full schema")\n  expect(tool.description?.split(": ").at(-1)).toBe(JSON.stringify(canonical).slice(0, 8 * 1024))\n})

@aictrl-dev

aictrl-dev Bot commented Oct 7, 2026

Copy link
Copy Markdown

Code review

Verdict: Looks good — only minor / nit comments below. · 🔴 0 · 🟠 0 · 🟡 6 · ⚪ 7 · 2/13 resolved

  • 🟡 packages/cli/README.md:43 — 64 KiB schema cap is enforced but undocumented
  • ⚪ packages/cli/README.md:44-46 — Docs mix 'corrective requests' and 'corrective turns'
  • ⚪ packages/cli/src/cli/cmd/run.output.ts:30-31 — Divergent root-schema error wording between layers · resolved
  • 🟡 packages/cli/src/cli/cmd/run.ts:1166-1170 — Abort drops queued events from subscribe() stream
  • 🟡 packages/cli/src/session/message-v2.ts:86 — retryCount .max(10) breaks persisted values >10
  • ⚪ packages/cli/src/session/prompt.ts:753 — Error still says 'attempt budget' after reterming
  • 🟡 packages/cli/src/session/prompt.ts:1126 — 64 KiB cap bypassed when custom validate is passed
  • 🟡 packages/cli/src/session/prompt.ts:1128 — Canonical JSON + size-check logic duplicated
  • ⚪ packages/cli/src/session/prompt.ts:1128 — Embedded schema copy diverges from provider inputSchema
  • ⚪ packages/cli/src/session/prompt.ts:1133 — stream:true decode drops split trailing character · resolved
  • 🟡 packages/cli/src/session/prompt.ts:1135 — Schema text embedded in tool description as instructions
  • ⚪ packages/cli/test/cli/run-event-race.test.ts:14 — Sentinel indexOf -1 silently widens slice body
  • ⚪ packages/cli/test/session/output-schema.test.ts:701 — Truncation test asserts chars, code truncates bytes
🤖 Fix all 11 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #134 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/README.md:43 — 64 KiB schema cap is enforced but undocumented
   Detail: output-schema.ts now rejects serialized schemas over 64 KiB ("serialized schema must not exceed 64 KiB") with test coverage, but none of the three docs this PR updates (README.md, packages/cli/README.md, EVENTS.md) mention the cap — the --output-schema bullet and the retries bullet document other bounds (0–10, 2 KB diagnostics) while staying silent on the new size limit. Users hitting the config error on a large schema have no documented contract.
   Suggested fix: Add to the `--output-schema <file>` bullet (and EVENTS.md's schema-validation paragraph): "The serialized schema must not exceed 64 KiB."
2. packages/cli/README.md:44-46 — Docs mix 'corrective requests' and 'corrective turns'
   Detail: README.md and EVENTS.md standardize on "corrective turns" (EVENTS once glossing "turns/requests"), but packages/cli/README.md introduces "N permits at most N+1 corrective requests" / "Zero permits one model request" — a third term in the same reterming pass. If the request-vs-turn distinction is deliberate (parallel calls within a turn), say so; otherwise pick one term for the budget.
   Suggested fix: Use "corrective turns" in packages/cli/README.md, or keep "requests" but add the same turns/requests gloss EVENTS.md uses.
3. packages/cli/src/cli/cmd/run.ts:1166-1170 — Abort drops queued events from subscribe() stream
   Detail: On the new failure paths run.ts calls subscription.abort() while loop() may still have unconsumed events in the subscribe() queue. Both generator loops exit on abort without draining, so events already received from GlobalBus/SSE (e.g. a structured_output_rejected published just before a provider failure) are silently dropped from the NDJSON/JSON output. This may be deliberate ordering control (the new fixture asserts exactly one "reason" line), but terminal rejection telemetry published pre-failure is lost with no signal to the consumer.
   Suggested fix: On abort, drain remaining queued events before breaking (e.g. after the aborted check, `while (queue.length > 0) yield queue.shift()`), or have run.ts await one final stream flush after subscription.abort() before consuming loopDone.
4. packages/cli/src/session/message-v2.ts:86 — retryCount .max(10) breaks persisted values >10
   Detail: Tightening OutputFormatJsonSchema.retryCount with .max(10) changes the message/wire-format contract: values >10 were previously valid (only the CLI guarded >= 0, so an older client or SDK caller could send retryCount 50), and they now fail zod parse on the message/replay path instead of clamping. If persisted sessions or attach peers can carry old values, replay breaks after upgrade with no migration path.
   Suggested fix: Either document the contract break, or clamp instead of rejecting on parse: `retryCount: z.number().int().min(0).transform((n) => Math.min(n, 10)).default(2)` so pre-existing values clamp rather than fail replay.
5. packages/cli/src/session/prompt.ts:753 — Error still says 'attempt budget' after reterming
   Detail: This PR deliberately reterms the vocabulary: maxAttempts = the corrective turn budget, `attempt` = the per-call counter that may exceed it (EVENTS.md, CLI help, READMEs, tests). The unchanged error "StructuredOutput attempt budget exhausted" still conflates the two by calling the budget an "attempt budget", undercutting the reterming done everywhere else in the same PR.
   Suggested fix: Rename the message to "StructuredOutput corrective turn budget exhausted" to match the documented terms.
6. packages/cli/src/session/prompt.ts:1126 — 64 KiB cap bypassed when custom validate is passed
   Detail: The new 64 KiB guard lives only inside OutputSchema.compile, but createStructuredOutputTool skips compile entirely when the caller passes input.validate (`input.validate ?? OutputSchema.compile(...)`). On that path JSON.stringify(input.schema) and Buffer.from(canonical) run on an unbounded schema and the tool is built with no size cap at all. Callers that inject a custom validator (createStructuredOutputTool is exported) silently bypass the limit the PR introduces.
   Suggested fix: Hoist the cap out of compile (e.g. export OutputSchema.canonicalize(schema) that stringifies, enforces 64 KiB, returns {text, hash}) and call it unconditionally in createStructuredOutputTool before the validate fallback.
7. packages/cli/src/session/prompt.ts:1128 — Canonical JSON + size-check logic duplicated
   Detail: output-schema.ts compile() measures the serialized schema with Buffer.byteLength(text) > 64*1024, while prompt.ts createStructuredOutputTool() re-serializes the same schema and measures it with Buffer.from(canonical).length <= 8*1024 plus a TextDecoder slice. Two divergent canonical-JSON/byte-size idioms for the same schema can drift independently (and today stringify different forms — input.schema vs $schema-stripped). A shared helper would keep both limits consistent.
   Suggested fix: Export a canonical(schema)/serializedSize(schema) helper from output-schema.ts and use it in both compile() and createStructuredOutputTool(); standardize on Buffer.byteLength(text).
8. packages/cli/src/session/prompt.ts:1128 — Embedded schema copy diverges from provider inputSchema
   Detail: canonical = JSON.stringify(input.schema) serializes the raw schema including $schema, while the schema advertised as the provider inputSchema strips $schema and applies ProviderTransform.schema. The text labelled "Canonical JSON Schema" therefore matches neither the inputSchema the model sees nor a key-order-normalized form, so the provider can be steered by two divergent copies. Embedding the validator's copy is arguably the intent, but the naming overstates it and the $schema line is dead weight in the description.
   Suggested fix: Stringify the same object passed to jsonSchema() (post-$schema-strip, post-ProviderTransform) for the embedded copy, or at minimum stringify the stripped `schema` variable instead of `input.schema`.
9. packages/cli/src/session/prompt.ts:1135 — Schema text embedded in tool description as instructions
   Detail: The tool description now embeds the verbatim serialized schema, promoting schema-supplied free text (description/title/errorMessage/examples fields) from the structured inputSchema channel into the natural-language instruction channel. The schema author is normally the trusted operator, but any caller that sources the schema from less-trusted content gets that content treated as agent instructions. Consider documenting the schema file as fully trusted instruction input or stripping long annotation fields before embedding.
   Suggested fix: Strip or bound long free-text annotation fields (description, title, examples, errorMessage) before embedding, or document that --output-schema content is fully trusted instruction input.
10. packages/cli/test/cli/run-event-race.test.ts:14 — Sentinel indexOf -1 silently widens slice body
   Detail: subscribeBody = source.slice(subscribeIdx, source.indexOf("return { stream }", subscribeIdx)): if the sentinel is ever renamed/removed, indexOf returns -1 and slice(subscribeIdx, -1) yields nearly the whole file, so the ordering assertions run against unrelated code and can false-pass/false-fail instead of erroring clearly. (Same pattern in the second test.)
   Suggested fix: const end = source.indexOf("return { stream }", subscribeIdx); expect(end).toBeGreaterThan(-1); then slice(subscribeIdx, end). Apply to both tests.
11. packages/cli/test/session/output-schema.test.ts:701 — Truncation test asserts chars, code truncates bytes
   Detail: The assertion JSON.stringify(canonical).slice(0, 8 * 1024) slices UTF-16 code units while the implementation truncates BYTES (Buffer.from(canonical).subarray(0, 8*1024) decoded); the two coincide only for the ASCII "x".repeat fixture. A future multi-byte fixture would fail (or pass for the wrong reason), so the test encodes the wrong invariant.
   Suggested fix: Assert against the byte-truncated expectation: new TextDecoder().decode(Buffer.from(JSON.stringify(canonical)).subarray(0, 8 * 1024)) — or keep the char-slice with a comment that the fixture must stay ASCII.
📋 Out-of-diff findings (13)
Sev Location Finding
🟡 packages/cli/README.md:43 64 KiB schema cap is enforced but undocumented
⚪ packages/cli/README.md:44-46 Docs mix 'corrective requests' and 'corrective turns'
⚪ packages/cli/src/cli/cmd/run.output.ts:30-31 Divergent root-schema error wording between layers
🟡 packages/cli/src/cli/cmd/run.ts:1166-1170 Abort drops queued events from subscribe() stream
🟡 packages/cli/src/session/message-v2.ts:86 retryCount .max(10) breaks persisted values >10
⚪ packages/cli/src/session/prompt.ts:753 Error still says 'attempt budget' after reterming
🟡 packages/cli/src/session/prompt.ts:1126 64 KiB cap bypassed when custom validate is passed
🟡 packages/cli/src/session/prompt.ts:1128 Canonical JSON + size-check logic duplicated
⚪ packages/cli/src/session/prompt.ts:1128 Embedded schema copy diverges from provider inputSchema
⚪ packages/cli/src/session/prompt.ts:1133 stream:true decode drops split trailing character
🟡 packages/cli/src/session/prompt.ts:1135 Schema text embedded in tool description as instructions
⚪ packages/cli/test/cli/run-event-race.test.ts:14 Sentinel indexOf -1 silently widens slice body
⚪ packages/cli/test/session/output-schema.test.ts:701 Truncation test asserts chars, code truncates bytes

Reviewed 13 files · 0 inline · view all 13 findings ↗


aictrl · AI code review for fast-moving teams · aictrl.dev

// Relative paths resolve against --dir (run changes into it first), like --file.
// Report the resolved path so a misplaced relative path is obvious.
const file = path.resolve(input.schema)
const text = await Bun.file(file)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Absolute path prefix can push reason past 300-char cap.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, packages/cli/src/cli/cmd/run.output.ts:21-35):

Problem: Absolute path prefix can push reason past 300-char cap
Detail: Config-error messages now embed the fully resolved absolute path (`${file}: cannot read schema file`, `${file}: invalid JSON`, `${file}: schema root must be a JSON object`, `${file}: serialized schema must not exceed 64 KiB`, `${file}: <ajv strict-mode message>`), but the CLI caps invocation_error messages at 300 chars (the test harness asserts message <= 300). The path prefix is unbounded (PATH_MAX 4096): with a cwd/--dir nesting deep enough that the prefix approaches ~260+ chars, the reason suffix falls past the cap and users get only path text with no failure cause. The base behavior echoed the user-supplied (typically short relative) path, so the degradation is introduced by the absolutization added here; the NDJSON clip test was even reworked (toStartWith(path.sep)) showing awareness of the longer prefix.
Suggested fix: When composing these errors, bound the path portion (e.g. keep the last ~120 chars with an ellipsis marker) or emit `reason (path: ${file})` so the 300-char truncation eats path text rather than the diagnostic reason.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

Config-error messages now embed the fully resolved absolute path (${file}: cannot read schema file, ${file}: invalid JSON, ${file}: schema root must be a JSON object, ${file}: serialized schema must not exceed 64 KiB, ${file}: <ajv strict-mode message>), but the CLI caps invocation_error messages at 300 chars (the test harness asserts message <= 300). The path prefix is unbounded (PATH_MAX 4096): with a cwd/--dir nesting deep enough that the prefix approaches ~260+ chars, the reason suffix falls past the cap and users get only path text with no failure cause. The base behavior echoed the user-supplied (typically short relative) path, so the degradation is introduced by the absolutization added here; the NDJSON clip test was even reworked (toStartWith(path.sep)) showing awareness of the longer prefix.

  // Relative paths resolve against --dir (run changes into it first), like --file.
  // Report the resolved path so a misplaced relative path is obvious.
  const file = path.resolve(input.schema)
  const text = await Bun.file(file)
    .text()
    .catch(() => {
      throw new Error(`${file}: cannot read schema file`)
    })

export function compile(schema: Record<string, unknown>) {
if (schema.type !== "object") throw new Error('schema root must have type "object"')
const key = JSON.stringify(schema)
const text = JSON.stringify(schema)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 64 KiB cap bounds bytes, not schema depth.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, packages/cli/src/session/output-schema.ts:25):

Problem: 64 KiB cap bounds bytes, not schema depth
Detail: The new DoS guard in OutputSchema.compile limits only the serialized byte size of the schema. Ajv compile-time codegen and validate-time recursion scale with structural depth, not byte length: ~9,000 nested {"not":{...}} layers (7 bytes/level) fit well under 64 KiB and are a known Ajv pattern for "Maximum call stack size exceeded" (RangeError crashes the process) or pathological compile cost. This is distinct from the recorded "cap bypassed when custom validate is passed" finding: here the cap executes but is insufficient. The trust boundary is not only the local --output-schema flag — OutputFormatJsonSchema.schema is parsed from persisted/replayed session messages via z.record(z.string(), z.any()) (message-v2.ts) with no structural constraints, so a crafted or shared session file can reach compile() with a depth-bomb schema; the content-hash cache key means each distinct crafted schema forces a fresh compile.
Suggested fix: Before hashing/compiling, walk the schema and enforce structural bounds (e.g. max nesting depth ~100, max node/keyword count), throwing the same user-facing schema error on violation; optionally wrap the Ajv compile in try/catch to convert RangeError into a graceful OutputSchema error instead of a process crash.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The new DoS guard in OutputSchema.compile limits only the serialized byte size of the schema. Ajv compile-time codegen and validate-time recursion scale with structural depth, not byte length: ~9,000 nested {"not":{...}} layers (7 bytes/level) fit well under 64 KiB and are a known Ajv pattern for "Maximum call stack size exceeded" (RangeError crashes the process) or pathological compile cost. This is distinct from the recorded "cap bypassed when custom validate is passed" finding: here the cap executes but is insufficient. The trust boundary is not only the local --output-schema flag — OutputFormatJsonSchema.schema is parsed from persisted/replayed session messages via z.record(z.string(), z.any()) (message-v2.ts) with no structural constraints, so a crafted or shared session file can reach compile() with a depth-bomb schema; the content-hash cache key means each distinct crafted schema forces a fresh compile.

  export function compile(schema: Record<string, unknown>) {
    if (schema.type !== "object") throw new Error('schema root must have type "object"')
    const text = JSON.stringify(schema)
    if (Buffer.byteLength(text) > 64 * 1024) throw new Error("serialized schema must not exceed 64 KiB")
    const key = createHash("sha256").update(text).digest("hex")
    const cached = cache.get(key)

const event = result.events.find((event) => event.type === "invocation_error")
expect(event?.code).toBe("OUTPUT_SCHEMA_CONFIG")
expect(event?.message).toStartWith("schema.json: strict mode: unknown keyword:")
expect(event?.message).toStartWith(path.sep)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 toStartWith(path.sep) fails on Windows drive paths.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, packages/cli/test/cli/run-output-schema.test.ts:725):

Problem: toStartWith(path.sep) fails on Windows drive paths
Detail: The updated NDJSON clip assertion checks absoluteness with expect(event?.message).toStartWith(path.sep). On Windows, path.resolve in run.output.ts yields a drive-anchored path like C:\tmp\schema.json, which does not start with path.sep (\), so the assertion fails even though the production behavior (resolved absolute path in the error) is correct. The same patch's other new expectations (${path.sep}home${path.sep}schema.json: cannot read schema file) use substring matching and are platform-safe, making this one line the inconsistent outlier.
Suggested fix: Assert resolution platform-neutrally, e.g. keep the toContain(`${path.sep}schema.json: strict mode: unknown keyword:`) check and drop the toStartWith(path.sep) prefix assertion, or assert path.isAbsolute() on the message's path portion instead.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The updated NDJSON clip assertion checks absoluteness with expect(event?.message).toStartWith(path.sep). On Windows, path.resolve in run.output.ts yields a drive-anchored path like C:\tmp\schema.json, which does not start with path.sep (), so the assertion fails even though the production behavior (resolved absolute path in the error) is correct. The same patch's other new expectations (${path.sep}home${path.sep}schema.json: cannot read schema file) use substring matching and are platform-safe, making this one line the inconsistent outlier.

  expect(result.requests).toEqual([])
  const event = result.events.find((event) => event.type === "invocation_error")
  expect(event?.code).toBe("OUTPUT_SCHEMA_CONFIG")
  expect(event?.message).toStartWith(path.sep)
  expect(event?.message).toContain(`${path.sep}schema.json: strict mode: unknown keyword:`)
  expect((event?.message as string).length).toBe(300)

Comment thread EVENTS.md Outdated
Zero permits exactly one model request. A step can contain multiple StructuredOutput
calls; every rejected call is reported and counted even after the budget is spent.
`--output-result <file>` writes only accepted JSON using two-space indentation and a trailing newline, with a temporary file
in the destination directory followed by an atomic rename. Failures preserve the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ Rewritten EVENTS.md sentence left unwrapped at ~121 chars.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, EVENTS.md:546):

Problem: Rewritten EVENTS.md sentence left unwrapped at ~121 chars
Detail: The edit reflowed the --output-schema-retries paragraph but joined the --output-result sentence onto a single ~121-character line ("`--output-result <file>` writes only accepted JSON using two-space indentation and a trailing newline, with a temporary file"). Every surrounding line in EVENTS.md, including the untouched continuation "in the destination directory followed by an atomic rename", wraps at ~80 columns, and the pre-PR text wrapped this same sentence across two lines. The companion line 543 (~86 chars) is also slightly over. Re-wrap the paragraph to preserve the file's markdown source convention.
Suggested fix: Split line 546 so the sentence wraps at ~80 columns, e.g. "...trailing newline, with a temporary\nfile in the destination directory followed by an atomic rename."

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The edit reflowed the --output-schema-retries paragraph but joined the --output-result sentence onto a single ~121-character line ("--output-result <file> writes only accepted JSON using two-space indentation and a trailing newline, with a temporary file"). Every surrounding line in EVENTS.md, including the untouched continuation "in the destination directory followed by an atomic rename", wraps at ~80 columns, and the pre-PR text wrapped this same sentence across two lines. The companion line 543 (~86 chars) is also slightly over. Re-wrap the paragraph to preserve the file's markdown source convention.

`--output-schema-retries <n>` permits N additional corrective turns (0–10, default 2).
Zero permits exactly one model request. A step can contain multiple StructuredOutput
calls; every rejected call is reported and counted even after the budget is spent.
`--output-result <file>` writes only accepted JSON using two-space indentation and a trailing newline, with a temporary file
in the destination directory followed by an atomic rename. Failures preserve the

Comment thread packages/cli/README.md
@@ -41,8 +41,11 @@ aictrl run --output-schema result.schema.json --output-schema-retries 2 \
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ Shared path-resolution note nested under one flag's bullet.

Suggested change
Move the shared note to after the `--output-result` bullet (or into the surrounding prose), or split it so each flag's bullet carries only its own path-resolution sentence.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, packages/cli/README.md:42-43):

Problem: Shared path-resolution note nested under one flag's bullet
Detail: The added note "Relative `--output-schema` and `--output-result` paths resolve against `--dir`..." is indented as a continuation of the `--output-schema` bullet, but it governs two flags and forward-references `--output-result`, whose own bullet appears three bullets later ("--output-result <file>: atomically write..."). Every other sub-line in these bullets describes only its own flag. The shared behavior reads better placed where both flags are in scope.
Suggested fix: Move the shared note to after the `--output-result` bullet (or into the surrounding prose), or split it so each flag's bullet carries only its own path-resolution sentence.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The added note "Relative --output-schema and --output-result paths resolve against --dir..." is indented as a continuation of the --output-schema bullet, but it governs two flags and forward-references --output-result, whose own bullet appears three bullets later ("--output-result : atomically write..."). Every other sub-line in these bullets describes only its own flag. The shared behavior reads better placed where both flags are in scope.

- `--output-schema <file>`: JSON Schema file, forwarded in local and `--attach` runs.
  Relative `--output-schema` and `--output-result` paths resolve against `--dir`
  when it is given (as `--file` paths do); configuration errors print the resolved path.
- `--output-schema-retries <n>`: additional corrective turns, integer from 0 to 10,

Comment thread packages/cli/src/cli/cmd/run.ts Outdated
}

const events = await sdk.event.subscribe()
const subscription = new AbortController()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ `subscription` names an AbortController, not a subscription.

Suggested change
const subscription = new AbortController()
Rename the controller to something like `subscriptionAbort` (or `eventAbort`) and keep `events` as the subscription handle, so `subscriptionAbort.abort()`/`.signal` read unambiguously.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, packages/cli/src/cli/cmd/run.ts:564-565):

Problem: `subscription` names an AbortController, not a subscription
Detail: The new binding `const subscription = new AbortController()` is named after the resource it aborts, while the actual subscription handle returned by sdk.event.subscribe({ signal: subscription.signal }) is named `events`. Later reads like subscription.abort() initially suggest the subscription object itself has an abort method. The file already names its session-abort path distinctly (function abort() at ~L863), so the controller should follow the same controller-vs-action naming to avoid confusion with the event subscription it cancels.
Suggested fix: Rename the controller to something like `subscriptionAbort` (or `eventAbort`) and keep `events` as the subscription handle, so `subscriptionAbort.abort()`/`.signal` read unambiguously.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The new binding const subscription = new AbortController() is named after the resource it aborts, while the actual subscription handle returned by sdk.event.subscribe({ signal: subscription.signal }) is named events. Later reads like subscription.abort() initially suggest the subscription object itself has an abort method. The file already names its session-abort path distinctly (function abort() at ~L863), so the controller should follow the same controller-vs-action naming to avoid confusion with the event subscription it cancels.

          return false
        }

      const subscription = new AbortController()
      const events = await sdk.event.subscribe({ signal: subscription.signal })
        let error: string | undefined
        const startTime = Date.now()

@aictrl-dev

aictrl-dev Bot commented Oct 7, 2026

Copy link
Copy Markdown

Code review

Verdict: Looks good — only minor / nit comments below. · 🔴 0 · 🟠 0 · 🟡 3 · ⚪ 3 · 0/6 resolved

  • ⚪ EVENTS.md:546 — Rewritten EVENTS.md sentence left unwrapped at ~121 chars
  • ⚪ packages/cli/README.md:42-43 — Shared path-resolution note nested under one flag's bullet
  • 🟡 packages/cli/src/cli/cmd/run.output.ts:21-35 — Absolute path prefix can push reason past 300-char cap
  • ⚪ packages/cli/src/cli/cmd/run.ts:564-565 — `subscription` names an AbortController, not a subscription
  • 🟡 packages/cli/src/session/output-schema.ts:25 — 64 KiB cap bounds bytes, not schema depth
  • 🟡 packages/cli/test/cli/run-output-schema.test.ts:725 — toStartWith(path.sep) fails on Windows drive paths
🤖 Fix all 6 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #134 (head branch).
Run the relevant tests/linters after each change.

1. EVENTS.md:546 — Rewritten EVENTS.md sentence left unwrapped at ~121 chars
   Detail: The edit reflowed the --output-schema-retries paragraph but joined the --output-result sentence onto a single ~121-character line ("`--output-result <file>` writes only accepted JSON using two-space indentation and a trailing newline, with a temporary file"). Every surrounding line in EVENTS.md, including the untouched continuation "in the destination directory followed by an atomic rename", wraps at ~80 columns, and the pre-PR text wrapped this same sentence across two lines. The companion line 543 (~86 chars) is also slightly over. Re-wrap the paragraph to preserve the file's markdown source convention.
   Suggested fix: Split line 546 so the sentence wraps at ~80 columns, e.g. "...trailing newline, with a temporary\nfile in the destination directory followed by an atomic rename."
2. packages/cli/README.md:42-43 — Shared path-resolution note nested under one flag's bullet
   Detail: The added note "Relative `--output-schema` and `--output-result` paths resolve against `--dir`..." is indented as a continuation of the `--output-schema` bullet, but it governs two flags and forward-references `--output-result`, whose own bullet appears three bullets later ("--output-result <file>: atomically write..."). Every other sub-line in these bullets describes only its own flag. The shared behavior reads better placed where both flags are in scope.
   Suggested fix: Move the shared note to after the `--output-result` bullet (or into the surrounding prose), or split it so each flag's bullet carries only its own path-resolution sentence.
3. packages/cli/src/cli/cmd/run.output.ts:21-35 — Absolute path prefix can push reason past 300-char cap
   Detail: Config-error messages now embed the fully resolved absolute path (`${file}: cannot read schema file`, `${file}: invalid JSON`, `${file}: schema root must be a JSON object`, `${file}: serialized schema must not exceed 64 KiB`, `${file}: <ajv strict-mode message>`), but the CLI caps invocation_error messages at 300 chars (the test harness asserts message <= 300). The path prefix is unbounded (PATH_MAX 4096): with a cwd/--dir nesting deep enough that the prefix approaches ~260+ chars, the reason suffix falls past the cap and users get only path text with no failure cause. The base behavior echoed the user-supplied (typically short relative) path, so the degradation is introduced by the absolutization added here; the NDJSON clip test was even reworked (toStartWith(path.sep)) showing awareness of the longer prefix.
   Suggested fix: When composing these errors, bound the path portion (e.g. keep the last ~120 chars with an ellipsis marker) or emit `reason (path: ${file})` so the 300-char truncation eats path text rather than the diagnostic reason.
4. packages/cli/src/cli/cmd/run.ts:564-565 — `subscription` names an AbortController, not a subscription
   Detail: The new binding `const subscription = new AbortController()` is named after the resource it aborts, while the actual subscription handle returned by sdk.event.subscribe({ signal: subscription.signal }) is named `events`. Later reads like subscription.abort() initially suggest the subscription object itself has an abort method. The file already names its session-abort path distinctly (function abort() at ~L863), so the controller should follow the same controller-vs-action naming to avoid confusion with the event subscription it cancels.
   Suggested fix: Rename the controller to something like `subscriptionAbort` (or `eventAbort`) and keep `events` as the subscription handle, so `subscriptionAbort.abort()`/`.signal` read unambiguously.
5. packages/cli/src/session/output-schema.ts:25 — 64 KiB cap bounds bytes, not schema depth
   Detail: The new DoS guard in OutputSchema.compile limits only the serialized byte size of the schema. Ajv compile-time codegen and validate-time recursion scale with structural depth, not byte length: ~9,000 nested {"not":{...}} layers (7 bytes/level) fit well under 64 KiB and are a known Ajv pattern for "Maximum call stack size exceeded" (RangeError crashes the process) or pathological compile cost. This is distinct from the recorded "cap bypassed when custom validate is passed" finding: here the cap executes but is insufficient. The trust boundary is not only the local --output-schema flag — OutputFormatJsonSchema.schema is parsed from persisted/replayed session messages via z.record(z.string(), z.any()) (message-v2.ts) with no structural constraints, so a crafted or shared session file can reach compile() with a depth-bomb schema; the content-hash cache key means each distinct crafted schema forces a fresh compile.
   Suggested fix: Before hashing/compiling, walk the schema and enforce structural bounds (e.g. max nesting depth ~100, max node/keyword count), throwing the same user-facing schema error on violation; optionally wrap the Ajv compile in try/catch to convert RangeError into a graceful OutputSchema error instead of a process crash.
6. packages/cli/test/cli/run-output-schema.test.ts:725 — toStartWith(path.sep) fails on Windows drive paths
   Detail: The updated NDJSON clip assertion checks absoluteness with expect(event?.message).toStartWith(path.sep). On Windows, path.resolve in run.output.ts yields a drive-anchored path like C:\tmp\schema.json, which does not start with path.sep (\), so the assertion fails even though the production behavior (resolved absolute path in the error) is correct. The same patch's other new expectations (${path.sep}home${path.sep}schema.json: cannot read schema file) use substring matching and are platform-safe, making this one line the inconsistent outlier.
   Suggested fix: Assert resolution platform-neutrally, e.g. keep the toContain(`${path.sep}schema.json: strict mode: unknown keyword:`) check and drop the toStartWith(path.sep) prefix assertion, or assert path.isAbsolute() on the message's path portion instead.
📋 Out-of-diff findings (6)
Sev Location Finding
⚪ EVENTS.md:546 Rewritten EVENTS.md sentence left unwrapped at ~121 chars
⚪ packages/cli/README.md:42-43 Shared path-resolution note nested under one flag's bullet
🟡 packages/cli/src/cli/cmd/run.output.ts:21-35 Absolute path prefix can push reason past 300-char cap
⚪ packages/cli/src/cli/cmd/run.ts:564-565 `subscription` names an AbortController, not a subscription
🟡 packages/cli/src/session/output-schema.ts:25 64 KiB cap bounds bytes, not schema depth
🟡 packages/cli/test/cli/run-output-schema.test.ts:725 toStartWith(path.sep) fails on Windows drive paths

Reviewed 13 files · 0 inline · view all 6 findings ↗


aictrl · AI code review for fast-moving teams · aictrl.dev

@byapparov byapparov added the bug Something isn't working label Oct 7, 2026
byapparov and others added 4 commits October 7, 2026 15:19
- Bound schema structure (depth 64, 10k nested objects) as well as bytes:
  ~2,000 nested levels fit in 16 KB yet overflow Ajv's recursive codegen
  with a RangeError. The check is iterative.
- Lead configuration errors with the reason and put the resolved path last,
  so the 300-char clip drops path text rather than the cause.
- Rename the event-stream AbortController to subscriptionAbort.
- Platform-neutral clip assertion; re-wrap EVENTS.md; move the shared
  path-resolution note out of a single flag's bullet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@byapparov

Copy link
Copy Markdown
Contributor Author

Review response — PR #134

Triage of both aictrl review rounds on this PR (13:45Z, 13 findings; 14:08Z, 6 findings). One round-1 finding was a duplicate already resolved. Fixes are pushed; the head is 1f1bf14c8e. Full CLI suite: 1671 pass, 0 fail. Typecheck is green. Mutation checks on the bound bypass, the abort drain and the depth bound each fail without the fix.

Issues addressed (pushed to this PR)

  • Absolute path prefix can push reason past 300-char cap: packages/cli/src/cli/cmd/run.output.ts: reason-first messages; the clip drops path text, not the cause (commit d9fecedfa6)
  • 64 KiB cap bounds bytes, not schema depth: packages/cli/src/session/output-schema.ts: iterative depth-64 / 10k-object bound; 2,000 levels (16 KB) previously threw RangeError (commit d9fecedfa6)
  • toStartWith(path.sep) fails on Windows: packages/cli/test/cli/run-output-schema.test.ts: platform-neutral reason-first assertion (commit d9fecedfa6)
  • subscription names an AbortController: packages/cli/src/cli/cmd/run.ts: renamed subscriptionAbort (commit d9fecedfa6)
  • Unwrapped EVENTS.md line: EVENTS.md: re-wrapped (commit d9fecedfa6)
  • Shared path note under one bullet: packages/cli/README.md: moved after the flag list (commit d9fecedfa6)
  • 64 KiB cap bypassed when custom validate is passed: packages/cli/src/session/prompt.ts: one canonical() helper enforces bytes + structure, called unconditionally (commit 4487718cbb)
  • Canonical JSON + size-check logic duplicated: packages/cli/src/session/prompt.ts: shared canonical() helper (commit 4487718cbb)
  • Embedded schema copy diverges from inputSchema: packages/cli/src/session/prompt.ts: embeds the $schema-stripped object (commit 4487718cbb)
  • stream:true decode drops split character: packages/cli/src/session/prompt.ts: one-shot decode (the earlier fixed-on-push sweep was a false positive) (commit 4487718cbb)
  • Truncation test asserts chars, code truncates bytes: packages/cli/test/session/output-schema.test.ts: byte-truncated expectation + multibyte fixture (commit 4487718cbb)
  • retryCount .max(10) breaks persisted values >10: packages/cli/src/session/message-v2.ts: wire parse clamps to 10; CLI flag still rejects >10 with exit 2 (commit 4487718cbb)
  • Abort drops queued events: packages/cli/src/cli/cmd/run.ts: drain queued events on abort; terminal output still single (commit 0f53783d5e)
  • Error still says attempt budget: packages/cli/src/session/prompt.ts: now 'corrective turn budget exhausted' (commit 0f53783d5e)
  • Schema text embedded as instructions: packages/cli/src/session/prompt.ts: documented: the schema file is trusted instruction input (commit 1f1bf14c8e)
  • Docs mix corrective requests and turns: packages/cli/README.md: one term across READMEs and EVENTS.md (commit 1f1bf14c8e)
  • 64 KiB schema cap undocumented: packages/cli/README.md: byte, depth and object limits documented (commit 1f1bf14c8e)
  • Sentinel indexOf -1 widens slice: packages/cli/test/cli/run-event-race.test.ts: assert the sentinel is found before slicing (commit 4487718cbb)

Review claims verified false (no change needed)

Not addressed here

  • None.

@byapparov
byapparov merged commit 2e24a5e into main Oct 7, 2026
4 checks passed
@byapparov
byapparov deleted the fix/129-review-round2 branch October 7, 2026 15:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant