Skip to content

feat: open ./agent-chat as its own door, and put the agent layer under test - #43

Merged
jkrumm merged 4 commits into
masterfrom
feat/agent-chat-surface
Aug 2, 2026
Merged

feat: open ./agent-chat as its own door, and put the agent layer under test#43
jkrumm merged 4 commits into
masterfrom
feat/agent-chat-surface

Conversation

@jkrumm

@jkrumm jkrumm commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Opens ./agent-chat as its own subpath, stands up the DOM test harness the repo never had, puts the agent layer under test for the first time, and ships the streaming/resume guards.

What lands

  • ./agent-chat subpath export + the matching SURFACES entry, with llms.txt and AGENTS.md regenerated. The root barrel's re-export is unchanged, so this adds a door without closing one.
  • DOM test harness (tests/setup/dom.ts + root bunfig.toml). src/agent/** and src/agent-chat/** had zero tests before this and there was nothing to add them to.
  • The src/agent/** suite — parts, thread, ai-sdk-transport, use-agent-stream, and the three use-agent-thread-runs files.
  • The wedge fix: useAgentThreadRuns's unmount cleanup now clears controllersRef, so a thread persisted as streaming resolves on the next mount instead of hanging forever with no consumer behind it. Reproduces under React 19 StrictMode and under an <Activity> hide/show boundary, both of which preserve the fiber's refs across effect teardown.
  • Three oxlint rulesagent-resume-guard, agent-no-raw-usechat, ai-sdk-major — at warn shipped / error repo-local, per the grace-minor doctrine. raw-scroll-container promoted to warn.
  • ai-major-parity in doctor, declarable through basalt.aiMajorSkewReason with a mandatory reason string. Undeclared skew still hard-fails; a declaration left behind after the skew is gone warns that it is stale.

Notes for review

remend and motion are hard requirements of the new subpath, not optional peers. agent-chat/index.ts statically re-exports ThreadTranscriptthread-message.tsx../content/markdown, whose import remend from 'remend' is top-level, so under unbundled ESM importing anything from basalt-ui/agent-chat evaluates that chain. This is documented, pinned in required-peers.test.ts, and gated by a new minimal-peer step in pack-test.sh — the existing scratch consumer installs every optional peer at once, so it structurally could not detect a peer that is secretly required. Making remend lazy is queued for a later minor.

happy-dom replaces TransformStream and WritableStream with Node classic stream classes — same names, different semantics — which breaks every ai streaming path with an error that names neither cause. tests/setup/dom.ts restores the natives from node:stream/web, including ReadableStream and the AbortController family, because restoring a family of globals by halves is its own bug.

The four-commit split is forced by lefthook's isolated-basalt-ui hook and cannot be collapsed.

Version: this was written as 1.9.0. 1.9.0 released from master carrying only the chart-layer batch while this sat on its branch, so it lands as 1.10.0 and the documented ladder after it moves up one. The version references inside the commits were corrected rather than left to publish a false claim.

Gates

fmt:check, lint, typecheck, build, check-theme, check-coverage (8/8), both generator drift checks, and bun test all green. pack-test.sh green, including resolved basalt-ui/agent-chat and the new agent-chat minimal-peer resolution step — the only evidence the new door resolves from the published tarball, since the playground exercises src/ and never dist/.

Browser gate walked on /agent-chat-subpath and /agent-wedge: the subpath renders a transcript with no provider or shell in its import graph, and a seeded stuck thread resolves both on an explicit reconcile and on a plain page reload.

One skip is deliberate — a verified-failing reproduction of consumeAndFinalize overwriting a stop() that lands while resolveOutcome is awaited. It is owned by the next phase, which rewrites stop().

Summary by CodeRabbit

  • New Features
    • Added the basalt-ui/agent-chat export for thread-based chat interfaces.
    • Added playground demos for agent chat transcripts and recovery of interrupted conversations.
    • Added workspace checks for compatible AI SDK major versions, with documented exceptions.
  • Bug Fixes
    • Improved recovery of interrupted streaming threads after remounts or lifecycle changes.
  • Documentation
    • Expanded setup, dependency, migration, and agent-chat usage guidance.
  • Tests
    • Added broad coverage for chat transport, streaming, persistence, recovery, lint rules, and package exports.

… all

src/agent/** and src/agent-chat/** shipped with zero tests because there was
nowhere to put them: no happy-dom, no @testing-library/*, no bunfig.toml. The
two most intricate modules in the package had no assertions between them.

Four pieces, each of which is wrong in a way that fails silently:

- The registrator is a separate package from happy-dom
  (@happy-dom/global-registrator), and @testing-library/dom has been a peer of
  @testing-library/react since v16, so both install explicitly.

- RTL's auto-cleanup no-ops under bun test. It registers with
  `typeof afterEach === 'function'` against the global scope, and Bun exposes
  afterEach only through bun:test, so the branch never fires and every test
  leaks its DOM into the next. Wired by hand in the preload, with a comment
  saying why — it reads as redundant otherwise.

- GlobalRegistrator.register() overwrites TransformStream and WritableStream
  with Stream.Transform and Stream.Writable — Node's classic streams, which
  share a name with the web-streams API and nothing else. The ai package
  constructs TransformStream at runtime inside its SSE parsing, so every
  streaming test breaks with an error naming neither cause. The native classes
  are restored from node:stream/web after registration.

- Restoring those three then leaves AbortController and AbortSignal as
  happy-dom's, and a native stream refuses a foreign signal: pipeTo with one
  throws `options.signal must be AbortSignal`. Restoring half a family of
  globals creates a mismatch that did not exist before, so the abort pair is
  captured before registration and put back after. Nothing crosses that seam
  today only because every test injects its own fetch, and the agent layer is
  abort-based end to end.

Root devDependencies only; the published package's manifest is untouched, so
its empty dependencies and its optional-peer story are unchanged.

The lefthook isolation allowlist gains bunfig.toml, package.json and bun.lock.
The root test surface is as much the package's own infrastructure as tests/
already was, and without this the harness and the tests it enables could never
be staged in the same commit.
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

📦 basalt-ui package modified

Trigger the Make Release workflow after merging to publish to npm.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@jkrumm, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 31 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bfec8bf6-37d8-48e2-ac87-84c27af8a040

📥 Commits

Reviewing files that changed from the base of the PR and between a387cfa and 573578c.

📒 Files selected for processing (37)
  • .oxlintrc.json
  • apps/playground/src/demo/AgentChatSubpathDemoPage.tsx
  • apps/playground/src/demo/AgentWedgeDemoPage.tsx
  • apps/playground/src/demo/nav-model.tsx
  • apps/playground/src/routes/agent-chat-subpath.tsx
  • apps/playground/src/routes/agent-wedge.tsx
  • docs/AGENT-CHAT-SPEC.md
  • packages/basalt-ui/AGENTS.md
  • packages/basalt-ui/CLAUDE.md
  • packages/basalt-ui/README.md
  • packages/basalt-ui/agent/rules/basalt-agent.md
  • packages/basalt-ui/configs/oxlint-plugin.js
  • packages/basalt-ui/configs/oxlint-plugin.test.ts
  • packages/basalt-ui/configs/oxlint.json
  • packages/basalt-ui/llms.txt
  • packages/basalt-ui/package.json
  • packages/basalt-ui/scripts/export-surface.json
  • packages/basalt-ui/scripts/pack-test.sh
  • packages/basalt-ui/src/agent-chat/index.ts
  • packages/basalt-ui/src/agent/ai-sdk-transport.test.ts
  • packages/basalt-ui/src/agent/parts.test.ts
  • packages/basalt-ui/src/agent/thread.test.ts
  • packages/basalt-ui/src/agent/use-agent-stream.test.tsx
  • packages/basalt-ui/src/agent/use-agent-thread-runs.resume.test.tsx
  • packages/basalt-ui/src/agent/use-agent-thread-runs.test.tsx
  • packages/basalt-ui/src/agent/use-agent-thread-runs.ts
  • packages/basalt-ui/src/agent/use-agent-thread-runs.wedge.test.tsx
  • packages/basalt-ui/src/charts/primitives/ChartFrame.test.tsx
  • packages/basalt-ui/src/cli/index.ts
  • packages/basalt-ui/src/dashboard/stat-card.test.tsx
  • packages/basalt-ui/src/provider/index.test.tsx
  • packages/basalt-ui/src/surfaces.ts
  • packages/basalt-ui/src/theme/use-basalt-spacing.test.tsx
  • packages/basalt-ui/src/tokens/build-fonts-css.test.ts
  • packages/basalt-ui/tests/doctor.test.ts
  • packages/basalt-ui/tests/lefthook-preset.test.ts
  • packages/basalt-ui/tests/required-peers.test.ts

Walkthrough

The PR adds the basalt-ui/agent-chat export, playground demonstrations, agent lifecycle regression coverage, three Oxlint rules, workspace AI-version checks, and a repository-wide DOM test harness.

Changes

Agent Chat platform

Layer / File(s) Summary
Agent Chat contracts and rollout
docs/AGENT-CHAT-SPEC.md
Defines the agent-chat API, replay-safe parts, tool states, renderers, transports, adapters, markdown, composer, feed, invariants, tests, rollout, and migration requirements.
Agent Chat export and consumer wiring
packages/basalt-ui/..., apps/playground/src/...
Adds the ./agent-chat export, doctrine registration, peer checks, package tests, documentation, and playground routing.
Agent stream lifecycle and wedge recovery
packages/basalt-ui/src/agent/..., apps/playground/src/demo/AgentWedgeDemoPage.tsx
Adds coverage for stream stopping, retries, resume reconciliation, controller cleanup, and interrupted-thread recovery.
DOM test harness and test execution
tests/setup/dom.ts, bunfig.toml, package.json, lefthook.yml, packages/basalt-ui/**/*.test.*
Adds happy-dom setup, native stream restoration, browser API shims, root test guidance, and batched formatting probes.
Oxlint and doctor enforcement
packages/basalt-ui/configs/..., packages/basalt-ui/src/cli/index.ts, packages/basalt-ui/tests/doctor.test.ts
Adds resume, raw AI SDK hook, and AI SDK major-version rules, plus workspace parity checks with explicit and stale exemption handling.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Poem

A rabbit checks the stream at dawn,
Restores each thread before it’s gone.
New chat paths bloom, guards stand tall,
DOM tests hop through every hall.
“Ship the carrots!” cheers the bun.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main changes: adding the agent-chat subpath and expanding agent-layer testing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 11

🧹 Nitpick comments (10)
apps/playground/src/demo/AgentWedgeDemoPage.tsx (1)

121-121: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add an explicit return type to the exported component.

The coding guidelines require explicit types on public exports. AgentWedgeDemoPage is exported without a return type annotation.

♻️ Proposed change
-export function AgentWedgeDemoPage() {
+export function AgentWedgeDemoPage(): JSX.Element {

As per coding guidelines: "Use strict TypeScript, avoid any, prefer type inference, and provide explicit types on public exports."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/playground/src/demo/AgentWedgeDemoPage.tsx` at line 121, Update the
exported AgentWedgeDemoPage component declaration with an explicit React
component return type, preserving its existing implementation and behavior.

Source: Coding guidelines

packages/basalt-ui/src/agent/use-agent-thread-runs.test.tsx (1)

348-356: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Link the skipped test to a tracking issue.

The comment documents a real defect in consumeAndFinalize: after await resolveOutcome(...), the function writes setOutcome and setStatus without re-checking the supersede and abort guards it applied before the await. A stop() during a slow resolveOutcome is therefore overwritten. The analysis matches the current implementation, because stop() deletes the controller entry, so a re-check of controllersRef.current.get(threadId) !== controller would suppress the late write.

The PR defers the fix by design. A test.skip with no tracked reference can be lost. Add an issue link next to the test.skip.

Do you want me to open an issue that captures this reproduction and the proposed guard?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/basalt-ui/src/agent/use-agent-thread-runs.test.tsx` around lines 348
- 356, Add a tracking issue link immediately next to the skipped test
declaration for consumeAndFinalize, referencing the issue that documents the
stop() versus slow resolveOutcome race and proposed post-await guard. Keep the
test skipped and do not implement the fix in this change.
packages/basalt-ui/src/agent/use-agent-thread-runs.wedge.test.tsx (1)

69-138: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the duplicated createTestThreadsStore double into one shared module. Three test files each define a near-identical in-memory ThreadsStore implementation of roughly seventy lines. The shared root cause is the absence of a single test double for the ThreadsStore contract. Any future change to that interface must then be applied in three places, and the copies can drift apart silently.

  • packages/basalt-ui/src/agent/use-agent-thread-runs.wedge.test.tsx#L69-L138: move this implementation into a new shared test helper, for example packages/basalt-ui/src/agent/test-threads-store.ts, and keep the explanatory rationale comment with it. Accept an optional onCall hook so the variant in use-agent-thread-runs.test.tsx is covered.
  • packages/basalt-ui/src/agent/use-agent-thread-runs.resume.test.tsx#L23-L92: delete the local copy and import the shared helper.
  • packages/basalt-ui/src/agent/use-agent-thread-runs.test.tsx#L22-L102: delete the local copy and import the shared helper, passing onCall for the finalize-order test.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/basalt-ui/src/agent/use-agent-thread-runs.wedge.test.tsx` around
lines 69 - 138, Extract the duplicated createTestThreadsStore implementation
into packages/basalt-ui/src/agent/test-threads-store.ts, preserving the
explanatory rationale comment and adding an optional onCall hook for
finalize-order tracking. In
packages/basalt-ui/src/agent/use-agent-thread-runs.wedge.test.tsx lines 69-138,
replace the local implementation with an import; do the same in
packages/basalt-ui/src/agent/use-agent-thread-runs.resume.test.tsx lines 23-92.
In packages/basalt-ui/src/agent/use-agent-thread-runs.test.tsx lines 22-102,
import the shared helper and pass onCall to the finalize-order test.
apps/playground/src/demo/AgentChatSubpathDemoPage.tsx (1)

75-75: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add an explicit return type to the public component.

AgentChatSubpathDemoPage is a public export. Declare its return type.

As per coding guidelines, "**/*.{ts,tsx}: Use strict TypeScript, avoid any, prefer type inference, and provide explicit types on public exports."

Proposed fix
+import type { ReactElement } from 'react'
+
-export function AgentChatSubpathDemoPage() {
+export function AgentChatSubpathDemoPage(): ReactElement {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/playground/src/demo/AgentChatSubpathDemoPage.tsx` at line 75, Update the
public AgentChatSubpathDemoPage component declaration to include an explicit
React JSX return type, preserving its existing implementation and behavior.

Source: Coding guidelines

packages/basalt-ui/scripts/pack-test.sh (1)

369-374: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Cover the complete agent-chat export contract.

export-surface.json declares seven runtime exports, but this scratch consumer checks only four. A missing Composer, ThreadOutcomeCard, or threadPartRenderers export would still pass. Add assertions for the remaining three exports.

Suggested assertions
 const agentChat = await import('basalt-ui/agent-chat')
 
+if (typeof agentChat.Composer !== 'function') throw new Error('agent-chat.Composer missing')
+if (typeof agentChat.ThreadOutcomeCard !== 'function') throw new Error('agent-chat.ThreadOutcomeCard missing')
+if (!agentChat.threadPartRenderers || typeof agentChat.threadPartRenderers !== 'object') {
+  throw new Error('agent-chat.threadPartRenderers missing')
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/basalt-ui/scripts/pack-test.sh` around lines 369 - 374, Extend the
`agentChat` export assertions in the scratch consumer to cover the three missing
runtime exports declared by `export-surface.json`: `Composer`,
`ThreadOutcomeCard`, and `threadPartRenderers`. Preserve the existing checks and
validate each added export against its expected runtime type.
packages/basalt-ui/src/cli/index.ts (1)

1971-1984: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

A malformed aiMajorSkewReason is silent while the majors agree.

resolveAiMajorSkewReason computes presentButInvalid at Line 1949, but only the skew branch consults it. In the agreeing branch, the code tests aiMajorSkewReason !== null, which is also null for a malformed value. A consumer who writes aiMajorSkewReason: true therefore gets no output at all until a real skew appears, and only then sees the "present but is not a non-empty string" failure.

The stale-exemption warning exists for the same reason: an exemption nobody re-checks hides a later skew. A malformed exemption hides it just as effectively, and it is detectable now.

Warn on the malformed key in this branch too.

♻️ Proposed change
     } else {
       const [major] = distinctMajors
       if (aiMajorSkewReason !== null) {
         warn(
           `basalt.aiMajorSkewReason ("${aiMajorSkewReason}") is declared but the ai package major ` +
             `already matches across all ${aiMajors.length} workspace package(s) declaring it ` +
             `(ai@${major}) — the exemption is no longer needed and can be deleted.`,
         )
+      } else if (aiMajorSkewReasonInvalid) {
+        warn(
+          'basalt.aiMajorSkewReason is present but is not a non-empty string (a bare `true` is ' +
+            'not accepted) — it would NOT exempt a skew. The ai package major currently matches ' +
+            `across all ${aiMajors.length} workspace package(s) declaring it (ai@${major}); ` +
+            'delete the key or give it a written reason.',
+        )
       } else {
         pass(
           `ai package major matches across ${aiMajors.length} workspace package(s) declaring it (ai@${major})`,
         )
       }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/basalt-ui/src/cli/index.ts` around lines 1971 - 1984, Update the
agreeing-majors branch near the existing aiMajorSkewReason warning to consult
presentButInvalid before checking aiMajorSkewReason !== null. Emit the same
malformed-value warning used by the skew branch when presentButInvalid is true;
otherwise preserve the existing stale-exemption warning and pass behavior.
packages/basalt-ui/tests/doctor.test.ts (2)

208-225: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Hoist the duplicated runDoctor helper.

Lines 209-225 and Lines 348-364 define byte-identical helpers, including the same JSDoc line. Both are new in this change. Move one copy to module scope and delete the other.

This also resolves the no-console warning that CI reports at Line 210 once instead of twice. The console.log and console.error references here save and restore the originals, which is a legitimate test-harness use of the rule's target, so a scoped disable on the hoisted helper is appropriate.

♻️ Proposed refactor — one module-scope helper
+/**
+ * Run doctor, capturing stdout/stderr so the emitted lines can be asserted on either way.
+ * Swapping the console methods IS the mechanism here, not a stray debug statement.
+ */
+// oxlint-disable-next-line no-console
+function runDoctor(): { code: number; out: string } {
+  const originalLog = console.log
+  const originalError = console.error
+  let out = ''
+  console.log = (...args: unknown[]) => {
+    out += `${args.join(' ')}\n`
+  }
+  console.error = (...args: unknown[]) => {
+    out += `${args.join(' ')}\n`
+  }
+  try {
+    return { code: doctor(tmpDir), out }
+  } finally {
+    console.log = originalLog
+    console.error = originalError
+  }
+}
+
 describe('basalt doctor — ai-major-parity', () => {
-  /** Run doctor, capturing stdout/stderr so the emitted lines can be asserted on either way. */
-  function runDoctor(): { code: number; out: string } {
-    const originalLog = console.log
-    const originalError = console.error
-    let out = ''
-    console.log = (...args: unknown[]) => {
-      out += `${args.join(' ')}\n`
-    }
-    console.error = (...args: unknown[]) => {
-      out += `${args.join(' ')}\n`
-    }
-    try {
-      return { code: doctor(tmpDir), out }
-    } finally {
-      console.log = originalLog
-      console.error = originalError
-    }
-  }
-
   it('hard-fails and names both packages when workspace packages disagree on the ai major', () => {

Apply the same deletion to the second copy at Lines 347-364.

Also applies to: 347-364

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/basalt-ui/tests/doctor.test.ts` around lines 208 - 225, Hoist the
duplicated runDoctor helper to module scope, preserving its existing JSDoc and
behavior, then remove both in-test copies around the doctor test cases. Add a
narrowly scoped no-console suppression for the hoisted helper because it
intentionally captures and restores console.log and console.error.

Source: Linters/SAST tools


267-272: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the remaining workspaces shapes.

This case covers an absent workspaces field. Two other branches added in this change have no fixture:

  • The object form { workspaces: { packages: [...] } }, handled at packages/basalt-ui/src/cli/index.ts Lines 1676-1680. The check must find the packages.
  • An unrecognized shape, for example workspaces: 'packages/*' or workspaces: {}, handled at packages/basalt-ui/src/cli/index.ts Line 1681. The check must stay silent rather than throw.

The second branch protects a documented invariant: the doc at packages/basalt-ui/src/cli/index.ts Lines 1660-1661 states the walk must never throw doctor over an unrecognized workspaces shape.

💚 Proposed additional tests
it('walks the object form of the workspaces field', () => {
  setupPassingLayout()
  writeFixture(
    'package.json',
    JSON.stringify({
      name: 'consumer-monorepo',
      workspaces: { packages: ['packages/*'] },
    }),
  )
  writeFixture(
    'packages/api/package.json',
    JSON.stringify({ name: 'api', dependencies: { ai: '5.0.196' } }),
  )
  writeFixture(
    'packages/dashboard/package.json',
    JSON.stringify({ name: 'dashboard', dependencies: { ai: '^7.0.18' } }),
  )
  const { code, out } = runDoctor()
  expect(code).toBe(1)
  expect(out).toContain('api@ai5')
})

it('stays silent for an unrecognized workspaces shape instead of throwing', () => {
  setupPassingLayout()
  writeFixture(
    'package.json',
    JSON.stringify({ name: 'consumer-monorepo', workspaces: 'packages/*' }),
  )
  const { code, out } = runDoctor()
  expect(code).toBe(0)
  expect(out).not.toContain('ai package major')
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/basalt-ui/tests/doctor.test.ts` around lines 267 - 272, Extend the
doctor tests around the existing no-workspaces case to cover both remaining
workspaces shapes: verify `{ packages: [...] }` discovers package dependencies
and reports the expected major mismatch, and verify an unrecognized shape such
as a string remains silent with exit code 0 instead of throwing. Use the
existing `setupPassingLayout`, `writeFixture`, and `runDoctor` helpers.
packages/basalt-ui/configs/oxlint-plugin.test.ts (1)

303-307: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a fixture for the per-specifier type-only branch.

agentNoRawUseChat has two independent type-only escapes: the whole-declaration check (node.importKind === 'type', plugin Line 647) and the per-specifier check (specifier.importKind === 'type', plugin Line 652). This fixture exercises only the first. The second branch has no coverage.

This file already applies that standard elsewhere, at Lines 321-327 and Lines 383-390, where a second fixture pins the other half of a two-branch matcher.

💚 Proposed additional test
   it('does NOT flag a type-only import of UIMessage from the same module', () => {
     const { code, rules } = run(`import type { UIMessage } from '`@ai-sdk/react`'\n`, 'lib.ts')
     expect(code).toBe(0)
     expect(rules).not.toContain('agent-no-raw-usechat')
   })
+
+  // The matcher has TWO type-only escapes — the whole declaration (`importKind === 'type'` on the
+  // node) and the individual specifier; the fixture above only pins the first.
+  it('does NOT flag an inline per-specifier type-only useChat', () => {
+    const { code, rules } = run(`import { type useChat } from '`@ai-sdk/react`'\n`, 'lib.ts')
+    expect(code).toBe(0)
+    expect(rules).not.toContain('agent-no-raw-usechat')
+  })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/basalt-ui/configs/oxlint-plugin.test.ts` around lines 303 - 307, Add
a separate test fixture in the agentNoRawUseChat tests for a value import
declaration containing a type-only UIMessage specifier, so it exercises
specifier.importKind === 'type' rather than the whole-declaration importKind
check. Assert the code remains valid and agent-no-raw-usechat is not reported,
following the paired-fixture pattern used around the existing tests.
packages/basalt-ui/configs/oxlint-plugin.js (1)

664-678: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a shared drift check for the duplicated majorOf behavior.

configs/oxlint-plugin.js and src/cli/index.ts each keep their own majorOf for structural reasons, but no test pins both implementations against shared edge cases. A shared fixture or cross-copy assertion can catch missed parsing changes before oxlint and doctor disagree on the same repository.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/basalt-ui/configs/oxlint-plugin.js` around lines 664 - 678, Add a
shared test fixture or cross-copy assertion covering identical edge cases for
the duplicated majorOf implementations in configs/oxlint-plugin.js and
src/cli/index.ts. Ensure both copies are exercised against the same inputs and
their results are asserted equal, without merging the implementations or
changing the import-free structure.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/AGENT-CHAT-SPEC.md`:
- Line 6: Replace the `/tmp/hermes-synthesis.md` reference in the gap-analysis
source note with a repository-tracked path or durable external link; if neither
is available, remove the reference while preserving the surrounding
documentation.

In `@packages/basalt-ui/agent/rules/basalt-agent.md`:
- Around line 234-243: Update the earlier workspace ownership statement to
identify the Mantine chrome as shipping from the basalt-ui/agent-chat subpath
rather than only the root basalt-ui entry, keeping the documentation consistent
with the agent-chat section. Rename the “Ready-built UI” heading to “Prebuilt
UI” to satisfy the static-analysis requirement.

In `@packages/basalt-ui/configs/oxlint-plugin.js`:
- Around line 693-709: Update the BASALT_AI_MAJOR initialization to read the
declared ai version specifically from peerDependencies rather than using
aiMajorFromPkg’s dependencies/devDependencies fallback. Preserve the null result
when the package is unreadable or has no peer declaration, and keep the existing
peer-major naming and report wording consistent.

In `@packages/basalt-ui/llms.txt`:
- Around line 122-128: Update the version metadata in llms.txt from 1.9.0 to
1.10.0 in both the line-1 version declaration and the “Version: 1.9.0” field
near the package metadata, leaving the agent-chat documentation unchanged.
- Around line 122-128: Update the basalt-ui/agent-chat surface metadata to
represent remend and motion as required peers rather than optional, adding the
per-subpath required-peer field or list used by the project. Adjust SURFACES and
pack consumers to honor that required-peer data while preserving other optional
peers and ensuring the generated machine-readable surface map matches the
documented requirements.

In `@packages/basalt-ui/src/agent/use-agent-stream.test.tsx`:
- Around line 86-89: Move the result.current.status assertion into the existing
waitFor callback alongside the parts assertion, so waitFor retries until both
the expected final part and 'done' status are observed. Keep the current
expectations and streaming setup otherwise unchanged.

In `@packages/basalt-ui/src/agent/use-agent-thread-runs.test.tsx`:
- Around line 180-190: Update the async assertions in the use-agent-thread-runs
tests around start() and retry() so they yield to the stream() generator before
checking calls. Await a microtask or otherwise wait for the generator body after
each relevant start()/retry() invocation, including the assertions near the
first start, busy second start, and line 336, while preserving the existing
expected call sequences.

In `@packages/basalt-ui/src/agent/use-agent-thread-runs.wedge.test.tsx`:
- Around line 242-284: Replace the setTimeout-based delay inside the transport’s
resume() generator with a caller-controlled deferred gate, using the existing
deferred() helper pattern from sibling tests. Keep the first resume attempt
suspended through the render and hide/show cycle, then resolve the gate
immediately afterward before the final waitFor, preserving the assertion that
resumeCalls equals 2.

In `@packages/basalt-ui/src/cli/index.ts`:
- Around line 1591-1631: Update packages/basalt-ui/src/cli/index.ts:1591-1631 in
subdirNames to exclude node_modules and dot-directories from traversal. Update
packages/basalt-ui/src/cli/index.ts:1641-1650 in expandWorkspaceGlobs to remove
!-prefixed patterns from includes and subtract their matched directories from
the results. Add fixtures in packages/basalt-ui/tests/doctor.test.ts near the
existing packages/** case covering both exclusions.
- Around line 1641-1650: Update expandWorkspaceGlobs to recognize patterns
beginning with “!” as exclusions instead of passing them to
expandPatternSegments as literal path segments. Collect directories from
positive patterns, resolve matching directories for negated patterns, and
subtract those directories from the collected results while preserving
package.json filtering and existing positive-pattern behavior.

In `@packages/basalt-ui/tests/required-peers.test.ts`:
- Around line 4-13: Update the root-entry required-peer invariant in
required-peers.test.ts: change the documented required-peer count from six to
seven, add motion to RUNTIME_REQUIRED_PEERS, and revise the root-entry
requirement documentation to include motion alongside the existing peers. Keep
subpath optionality and the existing boundary checks unchanged.

---

Nitpick comments:
In `@apps/playground/src/demo/AgentChatSubpathDemoPage.tsx`:
- Line 75: Update the public AgentChatSubpathDemoPage component declaration to
include an explicit React JSX return type, preserving its existing
implementation and behavior.

In `@apps/playground/src/demo/AgentWedgeDemoPage.tsx`:
- Line 121: Update the exported AgentWedgeDemoPage component declaration with an
explicit React component return type, preserving its existing implementation and
behavior.

In `@packages/basalt-ui/configs/oxlint-plugin.js`:
- Around line 664-678: Add a shared test fixture or cross-copy assertion
covering identical edge cases for the duplicated majorOf implementations in
configs/oxlint-plugin.js and src/cli/index.ts. Ensure both copies are exercised
against the same inputs and their results are asserted equal, without merging
the implementations or changing the import-free structure.

In `@packages/basalt-ui/configs/oxlint-plugin.test.ts`:
- Around line 303-307: Add a separate test fixture in the agentNoRawUseChat
tests for a value import declaration containing a type-only UIMessage specifier,
so it exercises specifier.importKind === 'type' rather than the
whole-declaration importKind check. Assert the code remains valid and
agent-no-raw-usechat is not reported, following the paired-fixture pattern used
around the existing tests.

In `@packages/basalt-ui/scripts/pack-test.sh`:
- Around line 369-374: Extend the `agentChat` export assertions in the scratch
consumer to cover the three missing runtime exports declared by
`export-surface.json`: `Composer`, `ThreadOutcomeCard`, and
`threadPartRenderers`. Preserve the existing checks and validate each added
export against its expected runtime type.

In `@packages/basalt-ui/src/agent/use-agent-thread-runs.test.tsx`:
- Around line 348-356: Add a tracking issue link immediately next to the skipped
test declaration for consumeAndFinalize, referencing the issue that documents
the stop() versus slow resolveOutcome race and proposed post-await guard. Keep
the test skipped and do not implement the fix in this change.

In `@packages/basalt-ui/src/agent/use-agent-thread-runs.wedge.test.tsx`:
- Around line 69-138: Extract the duplicated createTestThreadsStore
implementation into packages/basalt-ui/src/agent/test-threads-store.ts,
preserving the explanatory rationale comment and adding an optional onCall hook
for finalize-order tracking. In
packages/basalt-ui/src/agent/use-agent-thread-runs.wedge.test.tsx lines 69-138,
replace the local implementation with an import; do the same in
packages/basalt-ui/src/agent/use-agent-thread-runs.resume.test.tsx lines 23-92.
In packages/basalt-ui/src/agent/use-agent-thread-runs.test.tsx lines 22-102,
import the shared helper and pass onCall to the finalize-order test.

In `@packages/basalt-ui/src/cli/index.ts`:
- Around line 1971-1984: Update the agreeing-majors branch near the existing
aiMajorSkewReason warning to consult presentButInvalid before checking
aiMajorSkewReason !== null. Emit the same malformed-value warning used by the
skew branch when presentButInvalid is true; otherwise preserve the existing
stale-exemption warning and pass behavior.

In `@packages/basalt-ui/tests/doctor.test.ts`:
- Around line 208-225: Hoist the duplicated runDoctor helper to module scope,
preserving its existing JSDoc and behavior, then remove both in-test copies
around the doctor test cases. Add a narrowly scoped no-console suppression for
the hoisted helper because it intentionally captures and restores console.log
and console.error.
- Around line 267-272: Extend the doctor tests around the existing no-workspaces
case to cover both remaining workspaces shapes: verify `{ packages: [...] }`
discovers package dependencies and reports the expected major mismatch, and
verify an unrecognized shape such as a string remains silent with exit code 0
instead of throwing. Use the existing `setupPassingLayout`, `writeFixture`, and
`runDoctor` helpers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 117a7574-0a47-4362-8e04-b8e788224d70

📥 Commits

Reviewing files that changed from the base of the PR and between e1f0c8b and a387cfa.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (41)
  • .oxlintrc.json
  • apps/playground/src/demo/AgentChatSubpathDemoPage.tsx
  • apps/playground/src/demo/AgentWedgeDemoPage.tsx
  • apps/playground/src/demo/nav-model.tsx
  • apps/playground/src/routes/agent-chat-subpath.tsx
  • apps/playground/src/routes/agent-wedge.tsx
  • bunfig.toml
  • docs/AGENT-CHAT-SPEC.md
  • lefthook.yml
  • package.json
  • packages/basalt-ui/AGENTS.md
  • packages/basalt-ui/CLAUDE.md
  • packages/basalt-ui/README.md
  • packages/basalt-ui/agent/rules/basalt-agent.md
  • packages/basalt-ui/configs/oxlint-plugin.js
  • packages/basalt-ui/configs/oxlint-plugin.test.ts
  • packages/basalt-ui/configs/oxlint.json
  • packages/basalt-ui/llms.txt
  • packages/basalt-ui/package.json
  • packages/basalt-ui/scripts/export-surface.json
  • packages/basalt-ui/scripts/pack-test.sh
  • packages/basalt-ui/src/agent-chat/index.ts
  • packages/basalt-ui/src/agent/ai-sdk-transport.test.ts
  • packages/basalt-ui/src/agent/parts.test.ts
  • packages/basalt-ui/src/agent/thread.test.ts
  • packages/basalt-ui/src/agent/use-agent-stream.test.tsx
  • packages/basalt-ui/src/agent/use-agent-thread-runs.resume.test.tsx
  • packages/basalt-ui/src/agent/use-agent-thread-runs.test.tsx
  • packages/basalt-ui/src/agent/use-agent-thread-runs.ts
  • packages/basalt-ui/src/agent/use-agent-thread-runs.wedge.test.tsx
  • packages/basalt-ui/src/charts/primitives/ChartFrame.test.tsx
  • packages/basalt-ui/src/cli/index.ts
  • packages/basalt-ui/src/dashboard/stat-card.test.tsx
  • packages/basalt-ui/src/provider/index.test.tsx
  • packages/basalt-ui/src/surfaces.ts
  • packages/basalt-ui/src/theme/use-basalt-spacing.test.tsx
  • packages/basalt-ui/src/tokens/build-fonts-css.test.ts
  • packages/basalt-ui/tests/doctor.test.ts
  • packages/basalt-ui/tests/lefthook-preset.test.ts
  • packages/basalt-ui/tests/required-peers.test.ts
  • tests/setup/dom.ts

Comment thread docs/AGENT-CHAT-SPEC.md Outdated
Comment on lines +234 to +243
### Ready-built UI (`basalt-ui/agent-chat`, Mantine)

The Mantine-coupled chrome (`ThreadWorkspace` and the lower-level pieces below it) ships its own
subpath, `basalt-ui/agent-chat` — added in 1.10.0. The root `basalt-ui` entry still re-exports the
same components (nothing that imported from `basalt-ui` before 1.10.0 needs to change), but a
consumer who only needs the thread-chat UI can import from `./agent-chat` directly, without pulling
in `BasaltProvider`, `BasaltShell`, the dashboard composites, or `basalt-ui/connectivity`:

```tsx
import { ThreadWorkspace } from 'basalt-ui'
import { ThreadWorkspace } from 'basalt-ui/agent-chat'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the agent-chat ownership documentation.

The earlier workspace section at Line 200-203 still says that the Mantine chrome ships from the root basalt-ui entry. This section now says that it ships from basalt-ui/agent-chat. Update the earlier statement so the guide has one ownership model.

Use Prebuilt instead of Ready-built. The static-analysis hint flags the current wording.

🧰 Tools
🪛 LanguageTool

[grammar] ~234-~234: Ensure spelling is correct
Context: ...Ready-built UI (basalt-ui/agent-chat, Mantine) The Mantine-coupled chrome (`ThreadWorks...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/basalt-ui/agent/rules/basalt-agent.md` around lines 234 - 243,
Update the earlier workspace ownership statement to identify the Mantine chrome
as shipping from the basalt-ui/agent-chat subpath rather than only the root
basalt-ui entry, keeping the documentation consistent with the agent-chat
section. Rename the “Ready-built UI” heading to “Prebuilt UI” to satisfy the
static-analysis requirement.

Source: Linters/SAST tools

Comment thread packages/basalt-ui/configs/oxlint-plugin.js
Comment thread packages/basalt-ui/llms.txt
Comment on lines +86 to +89
await waitFor(() => {
expect(result.current.parts).toEqual([{ type: 'text', text: 'second-1' }])
})
expect(result.current.status).toBe('done')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Move the status assertion inside waitFor to remove a race.

waitFor returns as soon as parts equals [{ type: 'text', text: 'second-1' }]. The hook sets that value while the second generator is still running, before the loop ends and the status becomes 'done'. The assertion on line 89 therefore reads the status at an unspecified point and can observe 'streaming'.

The same pattern is safe on line 44, because the gate keeps that stream open. Here nothing blocks completion.

💚 Proposed fix
     await waitFor(() => {
       expect(result.current.parts).toEqual([{ type: 'text', text: 'second-1' }])
+      expect(result.current.status).toBe('done')
     })
-    expect(result.current.status).toBe('done')
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await waitFor(() => {
expect(result.current.parts).toEqual([{ type: 'text', text: 'second-1' }])
})
expect(result.current.status).toBe('done')
await waitFor(() => {
expect(result.current.parts).toEqual([{ type: 'text', text: 'second-1' }])
expect(result.current.status).toBe('done')
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/basalt-ui/src/agent/use-agent-stream.test.tsx` around lines 86 - 89,
Move the result.current.status assertion into the existing waitFor callback
alongside the parts assertion, so waitFor retries until both the expected final
part and 'done' status are observed. Keep the current expectations and streaming
setup otherwise unchanged.

Comment on lines +180 to +190
act(() => {
result.current.start(threadId, 'first')
})
expect(calls).toEqual(['first'])

// Busy: the SAME thread already has a controller. This must no-op — no second stream() call,
// and (critically) must NOT clobber the cached input retry() will replay.
act(() => {
result.current.start(threadId, 'second')
})
expect(calls).toEqual(['first'])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Compare synchronous vs awaited act() usage around transport call-count assertions.
set -euo pipefail

fd -e tsx -e ts . packages/basalt-ui/src --exec rg -n -C4 'act\(\(\) => \{' {} \; | rg -n -C4 'calls' || true

# Show every awaited act() for contrast.
rg -n -C2 'await act\(' packages/basalt-ui/src || true

Repository: jkrumm/basalt-ui

Length of output: 4473


🏁 Script executed:

#!/bin/bash
set -euo pipefail

target="packages/basalt-ui/src/agent/use-agent-thread-runs.test.tsx"
impl="packages/basalt-ui/src/agent/use-agent-thread-runs.ts"

echo "== test file outline/size =="
wc -l "$target"
echo

echo "== relevant test sections =="
sed -n '150,210p' "$target" | nl -ba -v150
echo
sed -n '300,345p' "$target" | nl -ba -v300
echo

echo "== implementation start/call locations =="
rg -n "stream\\(|consumeAndFinalize|start\\(" "$impl" "$target"
echo

echo "== implementation relevant sections =="
sed -n '1,220p' "$impl" | nl -ba -v1

Repository: jkrumm/basalt-ui

Length of output: 318


🏁 Script executed:

#!/bin/bash
set -euo pipefail

target="packages/basalt-ui/src/agent/use-agent-thread-runs.test.tsx"
impl="packages/basalt-ui/src/agent/use-agent-thread-runs.ts"

echo "== test file size =="
wc -l "$target" "$impl"

echo
echo "== relevant test sections =="
python3 - <<'PY'
from pathlib import Path
for path, ranges in [("packages/basalt-ui/src/agent/use-agent-thread-runs.test.tsx", [(150,210),(300,345)])]:
    print(f"\n-- {path} --")
    lines = Path(path).read_text().splitlines()
    for start,end in ranges:
        print(f"\n## {start}-{end}")
        for i in range(max(1,start-1), min(end, len(lines))):
            print(f"{i+1}: {lines[i]}")
PY

echo
echo "== source references =="
rg -n "stream\(|consumeAndFinalize|start\(" "$impl" "$target" || true

echo
echo "== implementation relevant sections =="
python3 - <<'PY'
from pathlib import Path
path="packages/basalt-ui/src/agent/use-agent-thread-runs.ts"
lines=Path(path).read_text().splitlines()
show=True
for i,line in enumerate(lines, start=1):
    if "consumeAndFinalize" in line or "start(" in line or "stream(" in line or i < 180 or i > 180 and i <= 260:
        print(f"{i}: {line}")
PY

echo
echo "== microtask ordering probe (standalone, no repo code executed) =="
node - <<'JS'
const calls = []
async function yieldStream() {
  calls.push('next');
}
function start() {
  void (async function consumed() {
    await yieldStream();
  })();
}
start();
console.log("sync-first-sink", JSON.stringify(calls));
await Promise.resolve();
console.log("microtask-flush", JSON.stringify(calls));
JS

Repository: jkrumm/basalt-ui

Length of output: 21504


Make the calls assertions wait for the async stream() generator.

start() non-awaited invokes consumeAndFinalize, whose first generator yield can happen in a microtask. The synchronous expect(calls) assertions at lines 183, 190, and 336 can therefore pass before the transport side effect runs. Await the microtask between start()/retry() and the calls expectations, or wait for the generator body directly.
[low_effort và high_reward]

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/basalt-ui/src/agent/use-agent-thread-runs.test.tsx` around lines 180
- 190, Update the async assertions in the use-agent-thread-runs tests around
start() and retry() so they yield to the stream() generator before checking
calls. Await a microtask or otherwise wait for the generator body after each
relevant start()/retry() invocation, including the assertions near the first
start, busy second start, and line 336, while preserving the existing expected
call sequences.

Comment thread packages/basalt-ui/src/agent/use-agent-thread-runs.wedge.test.tsx
Comment thread packages/basalt-ui/src/cli/index.ts Outdated
Comment thread packages/basalt-ui/src/cli/index.ts
Comment thread packages/basalt-ui/tests/required-peers.test.ts Outdated
jkrumm added 3 commits August 2, 2026 19:53
…r test

The Mantine chrome over ./agent was built and shipped but reachable only
through the root barrel, so taking the transcript meant taking BasaltProvider,
the shell, the dashboard composites and ./connectivity along with it. Importing
ThreadTranscript from basalt-ui/agent-chat now costs none of those. The root
barrel's re-export is unchanged: this adds a door without closing one.

What the subpath does NOT shed is remend and motion. Both are reached through
static top-level imports — thread-message.tsx pulls content/markdown, and
thread-feed.tsx and thread-detail-panel.tsx pull motion/react — so under
unbundled ESM, importing anything from ./agent-chat evaluates them and the
subpath fails to resolve without either. They are optional peers in the
manifest because npm expresses optionality per package and never per subpath;
the description string, the README and a test carry the truth instead.

The dist gate could not have caught that. Its scratch consumer installs every
optional peer at once, so a peer that is secretly required resolves fine. There
is now a second, deliberately minimal install that takes only what ./agent-chat
truly needs, in the same shape as the existing charts/tokens-only step.

Behind the door the layer is finally verifiable. src/agent/** had zero tests;
it now pins the abort/supersede/resume/finalize lifecycle, the threads store's
ring buffers and resume-token deletion, the part parser, and the AI SDK
transport's snapshot-to-delta diffing.

Which surfaced F3, and it is not the dev-only bug the register called it.
useAgentThreadRuns' unmount cleanup aborted every controller but never cleared
the map, so the mount reconcile then saw has(id) as true, computed orphaned as
false, and skipped — leaving the thread wedged in 'streaming' with no consumer,
permanently. React reuses the fiber under StrictMode's doubleInvokeEffectsOnFiber,
and React 19.2's Activity does the same thing in production: effects destroyed
and re-created, refs preserved. The fix is controllersRef.current.clear(),
matching what stopAll() already did. Reverting that one line fails two of the
wedge file's three cases and nothing else in the suite, which is the property
that makes it a guard rather than a decoration.

The test drives Activity directly rather than through Mantine's Collapse. The
version pinned here has no keepMounted default, so a bare Collapse keeps its
children mounted and cannot wedge — but a consumer writing Collapse with
keepMounted does get the Activity path on this same version, so the hazard is
live here and not only on later Mantine.

Three guards ship at warn, error repo-local, per the grace minor.
agent-resume-guard flags useChat with resume true, and bare resumeStream calls,
where nothing owns single-consumer discipline. agent-no-raw-usechat routes
consumers onto useAgentStream and useAgentThreadRuns, which abort on unmount;
it reports the import specifier, so pulling types from the same module stays
clean. ai-sdk-major flags a declared ai major that disagrees with the peer
basalt declares. All three honour basalt-agent-allow, deliberately not
theme-allow — a colour exemption must never be able to switch off a streaming
guard.

ai-sdk-major cannot catch the case it was written for on its own: a lint run
resolves the nearest package.json, so a workspace streaming on ai 5 into a
client parsing it on ai 7 looks fine from inside either package. doctor gains a
third axis that walks every workspace manifest and fails on the skew.

That skew is sometimes correct, though, and a guard that permanently fails a
correct configuration gets switched off. So the intentional case is declarable
through the existing basalt config block, and the declaration must carry a
written reason. An undeclared skew still fails. A declared one passes with both
the skew and the reason echoed. A declaration left behind once the skew is gone
warns that it is stale. The complaint this guard answers was that nothing pins
the pairing, and a required reason is that pin.

raw-scroll-container moves from off to warn in the shipped preset. The root
Requirements table now says remend is a root-entry requirement rather than a
./content one, pinned by a test that fails when that import goes lazy, so the
claim cannot rot quietly ahead of the change that makes it false.
…yground

Two pages, each standing in for a claim the release makes that a test cannot
observe.

The subpath page imports only from basalt-ui/agent-chat and basalt-ui/agent —
no root barrel, no BasaltProvider — so its module graph is the evidence that
taking the transcript no longer costs the whole framework. A header comment
says so, because one casual root-barrel import would destroy the proof while
leaving the page working.

The wedge page mounts a persisted streaming thread that resolves instead of
hanging. That is the visible form of the F3 fix, and it is worth having next to
the test: a thread stuck in 'streaming' with no consumer looks identical to a
slow one until you wait forever.
The API specification for 1.10.0 through 1.13.0 — every signature, the
type-level invariants, the guard authoring, and the test plan. It was written
before this release and has been carried untracked since; committing it here
means the phases that follow have a fixed reference rather than a file in
someone's working tree.

Two claims in it were corrected against source while implementing this release
and are worth knowing before reading it: the proposed ./agent-chat description
advertised ToolChip and ThreadFeedRow, which do not exist until 1.11.0 and
1.13.0 and would have published a surface listing exports the tarball does not
carry; and its closing server-side section still needs reconciling against the
program spec, which wins on any conflict.

The ladder shifted by one after the spec was written. 1.9.0 released from
master carrying only the chart-layer batch while this work sat on its branch,
so the door lands as 1.10.0 and every phase after it moves up one.
@jkrumm

jkrumm commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

Verified all ten findings against source before acting. Seven applied, three refuted:

Applied — the /tmp/hermes-synthesis.md reference (rewritten to not name an unresolvable path); the stale "chrome ships from the root entry" sentence in basalt-agent.md, and the same claim in README.md:496, which the review missed — that one matters more, since the README quickstart is what a human copies; BASALT_AI_MAJOR's self-read now resolves peer-first (aiMajorFromPkg itself is unchanged, since dependencies-wins is correct for consumers); node_modules and dot-directory exclusion in the workspace walk; !-negation entries now honoured; two doctor regression tests covering both; a deferred() gate replacing the setTimeout(0) in the wedge test; and motion added to RUNTIME_REQUIRED_PEERS.

Refuted, with evidence:

  • llms.txt version — generated from package.json and regenerated after semantic-release bumps it. Hand-editing to 1.10.0 would desync it and fail the gen-llms --check drift gate. No consumer ever sees a mismatched pair.
  • use-agent-stream.test.tsx:89 — the assertion is deterministic given React's commit granularity. Folding it into waitFor would weaken it: as written it asserts done is already true the moment parts settles, which is a real statement about the hook.
  • use-agent-thread-runs.test.tsx:190 — a synchronous busy guard makes the described race unreachable, and the supporting claim that sibling call sites use awaited act() is false; all eleven others in the file are synchronous by design.

Two notes on severity. motion was labelled Major but nothing breaks at runtime — the README was already right and every pack-test suite installs it; it was guard drift in the file that is supposed to be that fact's home. The negation and node_modules gaps were the genuinely valuable catches: both make a brand-new hard check fail a healthy consumer repo with an exit code they cannot act on, which is the failure mode that gets a guard switched off.

Gates green after the fixes: fmt:check, lint (exit 0), typecheck, build, check-theme, pack-test, and bun test at 1201 pass / 1 skip / 0 fail.

@jkrumm
jkrumm force-pushed the feat/agent-chat-surface branch from a387cfa to 573578c Compare August 2, 2026 17:54
@jkrumm
jkrumm merged commit 8b5763d into master Aug 2, 2026
3 checks passed
@jkrumm
jkrumm deleted the feat/agent-chat-surface branch August 2, 2026 17:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant