SaaS Hardening Plan — Agent Studio & Core Services¶
Status: approved for execution · Owner: Erik Golden · Written: 2026-08-11 Audience: AI agents executing individual tasks. Each task is self-contained: read only the task section plus the Global rules.
Goal: close the gap between feature velocity and verification. The architecture is sound; the work is (1) making the existing tests/audits gate merges, (2) adding golden-path E2E coverage, (3) tiering the 36-app portfolio, and (4) decomposing the five god files without behavior change.
Evidence base (measured 2026-08-11, branch environment/vanguard):
| Fact | Value |
|---|---|
ai-manager unit tests (npm test) |
413 tests, 410 pass / 3 fail, ~2 s |
surface-app (npx vitest run) |
95 tests, all pass, <1 s |
ai-orchestrator (.venv/bin/pytest tests) |
1,152 tests collected |
| conversation-api | 15 test_*.py files at app root (payments, customer-auth, catalog manifest) |
Route-auth audit (npm run audit:routes in ai-manager) |
passes — 409 routes scanned, 55-entry justified allowlist |
| CI workflows running ANY test suite or the route audit | zero (typecheck.yml covers only tsc --noEmit for ai-manager + surface-app) |
| E2E/Playwright tests | none |
| Apps with zero tests | 13 of 36 |
Execution status (2026-08-13): all 18 rows are DONE — T1.1–T1.3, T2.1–T2.4, T3.1–T3.2, T4.1–T4.2, T5.1–T5.7. Each row carries a Progress block recording what was built, what the work found, and what was deliberately left alone; read that block before re-opening a row. Where a row's Verify asked for something no test can do, the Progress block says so with a ⚠️ rather than claiming it. Measured after the work, same branch:
| Fact | Before (2026-08-11) | After (2026-08-13) |
|---|---|---|
ai-manager npm test |
413 tests, 410 pass / 3 fail | 755 pass / 0 fail, 25 suites, ~2 s |
surface-app npx vitest run |
95 pass | 98 pass, 16 files, <1 s |
ai-orchestrator pytest tests |
1,152 collected | 1,283 passed, 2 skipped, ~92 s |
| CI running any suite / the route audit | zero | test.yml (unit + route audit + E2E, no paths: filter) |
| E2E specs | none | agent lifecycle + workflow/tools (e2e/), running on PR |
Carried-forward verification debt — everything the plan asked for that a suite cannot answer, in one place: T2.4 and T3.2 are green locally but green-on-PR is unverified (no PR has been opened); T5.3's two-window collab check, T5.4's and T5.5's browser passes (T5.5 also two-window), and T5.6's live vanguard call + copilot session were not run. The decomposition rows are backed by move-only proofs and unit tests instead, which is evidence of fidelity, not of the feature working in a browser.
Product defects found while executing the plan (all pre-existing, all silent, all fixed in the
row that found them): the collab-gateway permission map running backwards; two collab data-loss
bugs the T2.3 E2E spec found (workflow inline_tools dropped by the Yjs allow-list, and an
unpopulated room overwriting pre-hydration edits); messagesToPrompt admitting null;
handleElevenLabsSearch's stale integrationId; and T5.7's six registration-surface defects,
including all four OCR providers being unregistered in every environment. A decomposition plan
found more real bugs than a feature would have — that is the argument for the plan, recorded here
because it is easy to forget once the diffs are merged.
Follow-ups discovered but deliberately out of scope (not plan rows — no row was silently
widened): palabra's service_type and its missing TRANSLATION_SERVICE_LOOKUP entry; the two
uncovered shadow if/elif surfaces in T5.7's ⚠️ note; three pre-existing F821s in the orchestrator
(_ca_kba_refs, DataBranchRouter, InjectionQueueProcessor); the preserved already-dead imports
from T5.6; collapsing voiceTranscriptSrc into PhoneCallTest; deleting the dead JsonTreeEditor
+ handleTreeChange; the 10-way MAIN_PIPELINE_X duplication; the cacheKeyPrefix dedup;
event-publisher's duplicate-publish bug; Tier-2 CI gating; and connect-scv-translator +
event-publisher still having zero tests.
Global rules (read before ANY task)¶
- No behavior change in decomposition tasks. Workstream 5 tasks move code; they never change what it does. If you find a bug while moving code, note it in the PR description and leave the behavior as-is (exception: code explicitly listed as dead in this plan may be deleted).
- Behavior-preserving zones. Each decomposition task lists invariant code (from
CLAUDE.md/ appCLAUDE.mdfiles). Move these blocks verbatim, including their comments — the comments are the institutional memory. - The collab-gateway Yjs shape is an allow-list. Any refactor of ai-manager editor code
that changes which top-level
configkeys are written will have those keys silently dropped by the gateway (collab-gatewaypipeline-shape.ts/workflow-shape.ts). Decomposition must not change the emitted config shape at all. - Verification is part of the task. Every task has a Verify block. A task is not done
until every command in it passes. For UI tasks, also exercise the feature in a browser
(dev stack:
bash .devcontainer/wake-up.sh, see repoCLAUDE.md). - One task = one PR, titled
[<service>] <description>per repo convention. Docs are part of the change (see "Keep docs in sync" inCLAUDE.md). - Don't reuse migration numbers; don't touch
deploy/init-scripts/archive/. (No task in this plan should need a schema change; if one seems to, stop and flag it.) - Never grep/typecheck against
.next/. Usenpm run typecheck(it usestsconfig.typecheck.json), never baretsc --noEmit.
Dependency notation: T2.1 ← T1.2 means T2.1 requires T1.2 merged first. Tasks without
dependencies are parallelizable.
Workstream 1 — Make the existing tests gate merges (highest leverage, do first)¶
T1.1 Fix the 3 failing ai-manager unit tests — DONE 2026-08-12¶
cd apps/ai-manager && npm test currently fails:
provider targets resolve to source and capability filtersthe speech-to-speech block list matches the orchestrator backstopkernel drift check: kernel.ts matches the async-agent sources
All three are drift-style tests (they compare two sources of truth). For each: identify which
side drifted (the test's expectation or the mirrored source), and fix the stale side — do
not simply update the assertion to match current output without confirming which side is
canonical. Test 2 mirrors an orchestrator backstop list; test 3 mirrors apps/async-agent
sources into kernel.ts — for those, the non-ai-manager side is usually canonical.
Verify: cd apps/ai-manager && npm test → # fail 0.
T1.2 Add a test.yml CI workflow — DONE 2026-08-12 ← T1.1¶
Create .github/workflows/test.yml modeled on the existing
typecheck.yml (path-filtered, per-app matrix,
fail-fast: false). Jobs:
| Job | Trigger paths | Command |
|---|---|---|
ai-manager-unit |
apps/ai-manager/**, shared/** |
npm ci && npm test in apps/ai-manager |
surface-app-unit |
apps/surface-app/** |
npm ci && npx vitest run in apps/surface-app |
ai-orchestrator-unit |
apps/ai-orchestrator/**, libs/** |
create venv, pip install -r requirements.txt (or the pyproject dev extras), pytest tests -q in apps/ai-orchestrator |
conversation-api-unit |
apps/conversation-api/** |
install via uv sync (there is a uv.lock), pytest test_*.py -q in apps/conversation-api |
async-agent-unit |
apps/async-agent/**, shared/workflow-node-types/**, libs/** |
install deps (pyproject), pytest -q in apps/async-agent (31 test files; same integration-marker rule as the orchestrator job) |
route-auth-audit |
apps/ai-manager/app/api/**, scripts/audit/** |
npm run audit:routes in apps/ai-manager |
Notes for the implementing agent:
- The orchestrator suite may need service stubs/env vars; if some tests require live infra,
mark them with a pytest marker (e.g. @pytest.mark.integration) and run
pytest -m "not integration" in CI rather than skipping the whole job. Start by running the
suite locally (.venv/bin/pytest tests -q) to get the real pass/fail baseline; fix or mark,
don't delete.
- Cache: actions/setup-node npm cache (copy typecheck.yml), actions/setup-python +
pip/uv cache.
- Make all jobs required checks on PRs to main once green for a week (repo settings —
human step, flag in PR description).
Verify: open a draft PR touching each app; every matrix job runs and passes.
T1.3 Wire audit:routes failure output into contributor docs — DONE 2026-08-12¶
Small task: add a section to docs/10-reference/ (or extend an existing auth doc) documenting
withAuth / withAnyAuth / withSessionAuth / withManagementAuth, the
scripts/audit/route-auth-allowlist.yaml rules (every entry needs a reason), and the command
to run locally. Update the stale CLAUDE.md line that says routes use requirePermission() —
the real convention is withAuth(...) from with-auth.ts.
Verify: npm run audit:routes passes; doc added and linked from docs/README.md or an
existing auth page; CLAUDE.md code-style bullet corrected.
Workstream 2 — Golden-path E2E (Playwright)¶
T2.1 Playwright scaffold — DONE 2026-08-12¶
Add Playwright to apps/ai-manager (@playwright/test, e2e/ directory,
playwright.config.ts with baseURL from env). Target the vanguard dev stack first
(services on their known ports — see memory/port docs); CI execution comes later (T2.4).
Include an auth fixture: log in via the Authentik SSO flow once per run and reuse
storageState. If SSO automation is brittle, add a test-only session-mint path guarded by
an env var that is never set outside dev/CI — get maintainer sign-off before adding any
auth bypass.
Verify: npx playwright test e2e/smoke.spec.ts passes against the local stack (smoke =
login + agents list renders).
What actually landed (@playwright/test 1.62.1; docs in
browser-e2e-tests.md): 3 tests green, and green
again under --repeat-each=3 (7/7). Deltas from the plan:
- No auth bypass was needed, so none was added — the sign-off clause above is moot. The
product ships a real local username/password route, so
e2e/fixtures/e2e-user.tsUPSERTs an ordinaryusers+tenant_membershipspair and the setup project signs in throughPOST /api/auth/login. Session insert, cookie, tenant resolution and permission expansion are all exercised for real; only the OIDC hop is skipped. Don't reuse the seededadmin/admin123: it 401s on any DB restored from a snapshot, most real users carry ansso:no-passwordsentinel, and CI's DB has no seeded human at all. - Rails on the fixture, because it is a credential factory: local-DB-only guard
(
E2E_ALLOW_REMOTE_DB=1overrides), a random password per run by default, tenantadminand not platform admin, ande2e/.auth/gitignored (it holds a live session cookie). - No
webServer— the golden paths cross services, so the suite targets a running stack.workers: 1is load-bearing (shared tenant agent list; one collab room per document). - The smoke spec also absorbs T4.2's deferred browser check and asserts
pageerroris empty, which is the only reliable signal that a deleted helper is still referenced (React swallows the throw into an error boundary and the page still looks fine). - Three locator traps, each of which cost a red run — full detail in the doc: canvas nodes are
not React Flow-selectable (
.selectednever appears; drive the node's own Edit button); the Properties/Behavior tab row renders only whenstageHasBehaviorTabis true, and the first node is usually a transport that has none; the inspector title isstage_namewhile the node heading is the stagelabel, so they routinely differ. - Two
data-testids added (node-inspector,node-inspector-title) — the app had zero. That panel is the one surface every stage edit goes through and has no role/text handle that survives across stage types. Keep additions this deliberate; prefer role/text/library classnames (.react-flow) everywhere else. - Not on the PR gate at the time: it needs a multi-service stack. T2.4 put the authoring half there (Postgres + Redis + collab-gateway + ai-manager); both smoke tests are in that half.
T2.2 Golden path: agent lifecycle — DONE 2026-08-12¶
One spec covering the core value path:
- Create agent (or open a seeded demo agent —
TSF_SEED_DEMOdata is ON in dev). - Edit: change the prompt in the stage editor; confirm autosave indicator settles.
- Publish (the publish dialog; assert no validation errors and the version list gains an entry).
- Open the emulator (
/emulator), start a chat session against the published agent, send one message, assert a non-empty agent reply renders. - Open the conversation in the transcript view; assert the exchange is present.
Chat (not voice) keeps CI feasible; voice E2E is out of scope for this plan.
Verify: spec passes 3× consecutively locally (flake check).
What actually landed
(agent-lifecycle.spec.ts, 6 tests; docs in
browser-e2e-tests.md): the full suite (9 tests)
passed four consecutive times, ~23–27 s per run. Deltas from the plan:
- Create, don't open a seeded agent — and via the wizard's Template path specifically. A
Custom chat agent is created with
pipeline: [], which can't be published or tested, so it would dead-end the spec at step 3 for reasons unrelated to the code under test. Creating also means the spec owns its data: it tags names with a per-run token and deletes the agent inafterAlleven on failure. - The emulator was the wrong surface for step 4. The editor's own Preview/Tester panel
renders the chat tester for a
text_to_textagent, and its Start button is disabled until the orchestrator preflight returns — so waiting for it to enable is a second buildability assertion, on top of publish's./emulatorwould have added an agent-selection UI with no extra coverage. - Two assertions do the real work, and neither is the one the plan named. "Autosave settles"
is only a UI claim, so step 2 is followed by a read-back of
GET /api/pipelines/<id>assertingconfig.template.inline— the collab Y.Doc converters drop unlisted top-level keys on every flush with the editor still showing the change. And publish's value is that it runs the structural validator plus the preflight, so "v1 exists" means "this config is buildable". - The suite's origin had to change, and this is the finding worth keeping.
baseURLnow defaults tohttps://ai-manager.$PUBLIC_FQDN, becauselocation /collab/— the same-origin proxy for the Yjs WebSocket to collab-gateway:3510 — exists only in nginx. Onlocalhost:3001the socket never connects, the gateway never flushes, and every edit is silently discarded: the editor shows the change, the API never gains it, andAutosaveIndicatorrenders nothing at all (itsidlebranch returnsnull). A suite on:3001cannot test autosave and reports green while not testing it. T2.4 must front ai-manager with the proxy, not just start Next — it took the other permitted route,NEXT_PUBLIC_COLLAB_WS_URLpointed straight at collab-gateway, verified by probe first. - Six more locator traps (full detail in the doc). The two that would have passed while
testing nothing: the stage inspector renders two CodeMirror editors, so a positional
.cm-contentwrites togreeting_messageinstead of the prompt — hence the newstage-prompt-editortestid and asserting persistence on the field by name; and the Activity sheet's conversation list row already contains the probe text in its preview column, so the transcript assertion has to anchor on the player's ownTranscript (…)heading first. - Two more
data-testids, four total.stage-prompt-editor(above) andmessage-texton surface-app'sMessageContent— a bubble contains its ownspeaker • timemeta line, so a bubble-level "not empty" check is satisfied by a timestamp, i.e. it passes for the silent-agent failure it exists to catch. - Skips are loud, failures are real. Chat/transcript skip with the product's own error text when a session can't start (no LLM integration, orchestrator down); a session that starts and then never answers fails.
T2.3 Golden path: workflow + tools — DONE 2026-08-12 ← T2.1¶
Second spec: create a workflow with 2 nodes in the workflow graph editor, connect them, save,
publish, run once via the tester/run API, assert a completed run. The run executes in
async-agent (Tier 1 — the LangGraph workflow executor), so this spec doubles as
async-agent's golden-path E2E; include an llm_agent node and a code node so the
async-agent → ai-orchestrator gRPC path and the code-executor sandbox path are both exercised. Plus: open ToolSelector on an
agent, add an inline API tool, save, reopen, assert it persisted (this pins the ToolSelector
refactor in WS5).
Verify: spec passes 3× consecutively.
What actually landed
(workflow-lifecycle.spec.ts, 8 tests;
docs in browser-e2e-tests.md): the full suite (16
tests) passed three consecutive times, ~46–49 s per run, in separate processes. Deltas from
the plan:
- The
codenode doesn't exist to add. It's absent fromWORKFLOW_NODE_PALETTEand its manifest says "Deprecated node — code now migrates to inline tools", so the spec covers the successor path: an inline Python tool on a Tool node, executed throughPOST /api/tools/inline/test→ code-executor/v1/execute. That is also the deterministic way to reach the sandbox — waiting for a model to decide to call a tool is not a test. - The ToolSelector step is on the workflow
toolnode, not on an agent. Inline tool creation is enabled there and deliberately not inWorkflowLLMConfig's own picker, and putting it on the workflow node makes the assertion stronger:config.toolis required for publish, so the tool has to have actually attached. - Two real product bugs found, both silent, both fixed (detail in
browser-e2e-tests.md):
the Yjs bridges applying an unpopulated room over pre-hydration edits (fix extracted to
lib/collab/empty-room.ts+ 10 unit assertions, so it's covered by the CI-gated suite rather than only by E2E), andinline_toolsmissing from the workflow Y.Doc allow-list in both converter copies — the definition was dropped on every flush while the node kept itsinline:<id>reference (fix + 6 round-trip assertions inworkflow-shape.test.ts; the three key lists are now one const). - One environment bug found: nsjail cannot create its mount namespace under Docker 29, so
CODE_EXECUTOR_DOCKER_PRIVILEGED=trueis now the dev default. It had gone unnoticed because/healthzand/v1/validateboth stay green without it — nothing but an actual execution reveals it. The Test panel now renderserror.traceback, which is the only thing that separates that from a crash in the author's own code; the spec skips on the nsjail signature and fails on anything else. - Nine new locator/geometry traps documented (minimap intercepting centre-clicks;
ToolSelector'srole="combobox"with no accessible name; the seeded Python source; the one-tool Action node; node-levelintegration_id; the tester's competing "Saved" and "Completed" strings; theEdgeLabelRendererportal). A fifthdata-testid(workflow-instructions-editor) was added — a CodeMirror instance has no accessible name and the AI Agent inspector holds more than one editor.
T2.4 E2E in CI — DONE 2026-08-12 (green-on-PR still unverified) ← T2.2, T2.3¶
Add an e2e job to test.yml: boot the minimum stack in the runner (Postgres + ai-manager,
with the emulator agent pointing at a mocked/stub LLM integration — a fake OpenAI-compatible
endpoint is sufficient and avoids API keys in CI). If the minimum stack proves too heavy for
GitHub-hosted runners, fall back to running E2E as a nightly scheduled workflow against the
vanguard EC2 environment, and keep PR CI at unit+audit. Either outcome is acceptable; document
the choice in the workflow file header.
"Minimum stack" is not "Postgres + next dev" — see T2.2's finding. /collab/ lives in
nginx, so a runner that skips the proxy silently disables autosave and takes the lifecycle
spec's persistence assertion with it. Either front ai-manager with the proxy in the runner or
set NEXT_PUBLIC_COLLAB_WS_URL to reach collab-gateway directly; a green run without one of
those two is not evidence.
Verify: the job runs green on a PR (or the nightly runs green twice), and a deliberately broken assertion fails it.
What actually landed (the e2e job in
test.yml; docs in
browser-e2e-tests.md):
the PR-gate outcome, not the nightly fallback. Deltas from the plan:
- In the runner, not nightly — and the fallback was rejected on evidence, not preference. There
are no self-hosted runners and no
schedule:trigger anywhere in.github/workflows, vanguard's Postgres isn't publicly reachable, ande2e/fixtures/e2e-user.tsrefuses a non-localDATABASE_URLwithoutE2E_ALLOW_REMOTE_DB=1— because it UPSERTs a user with a known password, which is not a thing to leave standing in a shared environment. The nightly option was three blockers deep before cost entered the picture. - Topology B, and it was proved before the job was written.
NEXT_PUBLIC_COLLAB_WS_URL=ws://localhost:3510rather than nginx in the runner, which would have copied the vanguard vhost into CI to drift unnoticed. A hand probe confirmed the gateway accepts the WS upgrade on:3510carrying alocalhost:3001session cookie and answers withhello, a presence roster, and a Yjs sync frame — cookies aren't port-scoped andws:/http:are the same site, sosameSite: 'lax'holds and/api/auth/collab-verifystill authenticates. That satisfies the "not evidence without one of those two" clause above by construction. - Redis is a second service container, not an optimization.
apps/collab-gateway/src/redis.tsthrows at import withoutREDIS_URL, so the "minimum stack" is four processes: Postgres, Redis, collab-gateway, ai-manager. next dev, notnext build && next start— a production build costs minutes, and the login route sets the session cookiesecure: NODE_ENV === 'production', which a plain-http runner silently drops. The whole suite would have failed on a login that returned 200. The price is per-route compilation, so the start step warms/agents,/workflows, and both editor routes before Playwright runs.- The stub LLM integration is the small half of the plan's step; the capability gate is the real
one.
E2E_SEED_STUB_INTEGRATION=1gives a bare tenant something for the auto-select assertions to select, but a fake OpenAI-compatible endpoint would not have made chat work — that needs ai-orchestrator and conversation-api too. So the job declaresE2E_STACK=minimaland the four execution steps (chat, transcript, sandbox, run) skip by design. It's a declaration, not a probe: the chat step waits for Start Chat to become enabled, and with no orchestrator there's no error text to match — it would burn 60 s and then fail, teaching people to ignore red. - A CI-only coverage hole surfaced while wiring it, and is fixed. A fresh database has zero
non-template agents (
listPipelinesfiltersis_template = FALSE), sosmoke.spec.ts's editor test — the deletion guard for FlowEditor and ServiceConfigForm — would have skipped on every CI run, in exactly the environment where it's the only thing watching those files. It now instantiates a builtin template through the wizard's ownPOST /api/templates/<id>/instantiateand deletes it in afinally(notafterAll:requestis a test-scoped fixture Playwright refuses there). - The baseline step asserts
count(*) FROM agents WHERE is_template > 0. Reference data is invisible to the drift guard, and a template-less DB fails the suite's first step as a wizard timeout with nothing pointing at the seed. - What's verified, stated exactly.
test.ymlparses and the job's shape is checked (2 services, 16 env keys, 12 steps); the suite is 12 passed / 4 skipped in 37 s against a full local stack; the empty-tenant seed branch was verified by API probe (200 / 201 / 200), not yet by a Playwright run, because vanguard's tenant always has agents — CI is the first place that branch executes. Green-on-PR and the deliberately-broken-assertion check both still require a push, which the uncommitted tree blocks. Neither is claimed here.
Workstream 3 — Portfolio tiering¶
T3.1 Define and document service tiers — DONE 2026-08-12¶
Create docs/03-services/service-tiers.md:
- Tier 1 (core, full quality bar): ai-manager, ai-orchestrator, conversation-api, surface-app, ari-controller-node, payment-vault, collab-gateway, async-agent and its core supporting services: agent-host (MCP tool host) and code-executor (sandboxed Python).
- Tier 2 (supported connectors): genesys-connector-go, connect-scv-translator, media-proxy, catalog-builder, event-publisher.
- Tier 3 (experimental / demo / best-effort): everything else (webex-, five9-, teams-agent-gateway, antigravity-runner, status-page, lambdas, …).
Quality bar per tier: Tier 1 = unit tests in CI + E2E coverage of its golden path + docs
current. Tier 2 = unit tests for core logic in CI + a service doc page. Tier 3 = builds in CI,
README states its status. The tier list above is a starting proposal — confirm with the
maintainer in the PR, then wire it into docs/README.md and reference it from CLAUDE.md.
Verify: doc merged, linked, and each Tier-1 app's README states its tier.
What actually landed (service-tiers.md, linked from
docs/README.md and the root CLAUDE.md handbook table; a
tier line in all ten Tier-1 READMEs). The three tier lists are exactly the plan's proposal —
the maintainer had already confirmed async-agent + agent-host + code-executor as Tier 1 on
2026-08-11, so there was nothing left to settle in a PR. Deltas:
- The Tier-1 E2E bar is stated as half-met, not claimed. The page says outright that golden-path E2E coverage is "met for the authoring path and open for the voice path" and links to the capability gate. Writing "Tier 1 = E2E coverage of its golden path" as though it were true would have made the page the kind of document this whole plan exists to fix.
- Assigning the bar is what exposed the gap. Five of the ten Tier-1 services were not gated in CI at all (T3.2 below). The page carries a "What was true before this page" section listing all five findings, because a tier table with no history reads as aspiration.
- Three service languages in the catalog were wrong, and were verified empirically rather
than by reading the previous note: collab-gateway was listed
Go + Node(zero.gofiles), event-publisherPython 3.12(zero.pyfiles; it is Go 1.25). - event-publisher's page was wrong well past its language, so it was rewritten. Its stated
sinks (
SNS, EventBridge, Pub/Sub, Service Bus, RabbitMQ) are actually EventBridge, Pub/Sub, and Event Grid;CLOUD_PROVIDERis an allow-list defaulting toall, not a single-target selector; it listens on one channel, not "a small set"; the "tracks last-acked offset" durability claim describes a mechanism that does not exist (the real one is anintegration_eventsoutbox); and the page had a Vanguardovermind restartsection for a service with no Procfile entry. Rewriting it surfaced a live bug now documented on the page:get_pending_firewall_events()servespending+failedrows butmark_events_published()only updatespending, so an event that fails once and then succeeds republishes every 30 s forever — the retry ceiling can't catch it, because that ceiling counts failures. - collab-gateway had neither a README nor a
CLAUDE.md, while being the write authority forconfigJSONB. Both were written (README, CLAUDE.md). Its service page's permission table disagreed with the code, and the code turned out to be the wrong side — see the bug below. This task first "corrected" the doc to match the code, which is the failure mode a doc task has to watch for: the page had been describing the intended model, and rewriting it to match a stale implementation buried a live authorization bug for another day. It only came back out because the retired-name audit test went red on the rewritten page. - BUG (Tier 1, silent, six weeks live): collab-gateway's permission map was never updated for
ai-manager's five-permission consolidation, so catalog collaboration was broken for every
non-platform-admin.
expandRolePermissionsissues only the five current names; the gateway's hand-copiedLEGACY_TO_NEW_PERMISSIONran backwards (current → retired) and both room gates required retired names. Agent and workflow rooms passed by accident through a localEditAgents → ViewAgentsimplies table; catalog rooms required the retired multimodal permission, which nothing can issue, so the A2UI catalog editor's presence roster was empty and its writes were dropped. Neither gate logs a denial — a failed join is an empty avatar stack, a failed write is dropped Y.Doc updates under a "Saved" chip — and a platform admin, who is who tests it, sees none of it. Fixed by mirroring ai-manager's map, pointing both gates at the current five, and movingcanWriteRoomout ofrooms.tsintoauthz.ts(being in a separate file is how it ended up comparing raw strings with no legacy expansion at all). Guarded by a newauthz.test.ts— 13 tests, including a drift guard that reads ai-manager'spermissions.tsas text — and by addingapps/collab-gateway/srcto the scan roots ofauth-permissions-audit.test.ts, which had never covered the one service that consumes ai-manager's permissions over the wire. - agent-host had no
CLAUDE.mdeither, so this session's fail-open finding had nowhere to live; one now exists with five invariants, indexed in the rootCLAUDE.md. - Five broken doc links were fixed in the Tier-1 READMEs, four of them pointing into the
pre-overhaul
docs/layout (docs/a2ui/,docs/voice-sip/,docs/architecture/,docs/deployment/). Every link in every file touched by this task now resolves. - The tables count test files, not tests, on purpose — a test count rots on the next commit and invites treating it as a coverage number. The counting method is recorded in an HTML comment at the foot of the page so the next person can reproduce it.
T3.2 Minimum test floor for untested Tier-1/2 apps — DONE 2026-08-12 (green-on-PR still unverified) ← T3.1, T1.2¶
collab-gateway (Tier 1) has zero tests; code-executor (Tier 1, 1 test file) and
agent-host (Tier 1, 3 test files) are near-zero; event-publisher and the Tier-2 Go
connectors have none. For each Tier-1/2 app below the floor: add a small suite (5–15 tests)
covering its most invariant-laden logic —
- collab-gateway: the shape allow-lists (
pipeline-shape.ts/workflow-shape.ts): round-trip a config through the shape and assert known keys survive and unknown keys drop. That single test class protects every editor refactor in WS5. - code-executor: sandbox boundary behavior — code that times out, code that attempts
network/filesystem access outside the sandbox contract, and a happy-path execution returning
structured output. This is a security boundary for async-agent's
codenodes. - agent-host: MCP tool listing + one tool invocation round-trip against the server in
mcp_server.py(use the MCP SDK client, per the tool-health invariant — no hand-rolled handshake).
Wire each new suite into test.yml.
Verify: new suites pass locally and in CI; collab-gateway shape round-trip test exists.
What actually landed. All three suites, plus two gates the plan didn't ask for and one prerequisite repair. Measured states, each by running the suite: agent-host 45, ari-controller-node 68, payment-vault 360, code-executor 29, collab-gateway 15.
- The floor was lower than the plan thought, because half of Tier 1 wasn't running at all.
Five of ten Tier-1 suites were ungated in
test.yml:ari-controller-node,payment-vault,agent-host,code-executor,collab-gateway. Four of those had real suites — 68 and 360 tests among them — that simply never executed on a PR. Gating them was a bigger coverage win than any test written here, and it cost five job entries. - code-executor's existing suite was 13-of-25 RED, and had been for an unknown length of
time: a symlinked interpreter in
conftest.pymeant the sandbox subprocess couldn't importtsf_runtime. Fixing that came before adding anything. The four new tests are the sandbox boundary rather than the plan's timeout/happy-path list (wall-time and structured output were already covered): host environment doesn't leak into user code,execution_idis the only caller-supplied env var, each execution gets a fresh cwd that is destroyed after, and the egress proxy is injected only when configured. - collab-gateway's 15 tests include the one the plan didn't specify and the failure mode
needs. The round-trip half is as described — known keys survive, unknown keys drop, and the
cases that actually bit: empty
settings, the nested[[a],[b]]branch group, placeholder pruning at every depth, the translation subtype marker, workflowtriggers/inline_tools. The last test reads both copies of the converters as text and asserts the ten allow-lists match, because the real-world failure isn't "forgot to add a key" — it's "added it to one of the two files", which round-trips perfectly in the browser and is deleted on the next flush. No new dependency:node --import tsx --test, andtsconfig.build.jsonkeeps the test out of the image. - agent-host's suite went through the SDK's own client, per the plan, and immediately earned
its keep.
mcp.shared.memory.create_connected_server_and_client_session(mcp_server.mcp)takes theFastMCPinstance and yields a realClientSession— no HTTP, no hand-rolled handshake. Eight tests:tools/listshape, atools/callround-trip onget_a2ui_catalog_info, the readable-error contract, unknown-toolisError, and the three?tools=filter cases including fail-closed. - It found a live fail-open product bug. Two filter tests failed.
_filtered_list_toolswas registered only inside theWARM_TRANSFER_MCP_ENABLEDbranch — a flag that is unset by default and commented out incharts/agent-host/values.yaml— so?tools=andmultimodal_clients.tool_scopewere ignored in every environment. An operator pins a client to one tool, the config saves, and the model gets the whole catalog with nothing logged. Confirmed by running with the flag on (8/8 green → the logic was fine, the gating wasn't) and fixed by hoisting the block to module scope. Exposure is narrower than it first looks and the comment now says so precisely:/m/<slug>/mcphad no other scope check, but the REST path/m/<slug>/tools/<name>re-checks at invoke time and held either way. - It also found a suite that only passed on a developer's shell. Eight warm-transfer tests
failed with
ARI_CONTROLLER_INTERNAL_TOKEN is not configured— underenv -itoo, so not caused by the change._ari_auth_headersraises rather than sending unauthenticated, which is correct; the tests silently depended on an exported dev variable, so the new CI job would have been red on arrival. Fixed with an autouse fixture (not a line in_reset_wt— three tests callwt.setup()directly), plus a realtest_missing_internal_token_is_reported_not_sent_unauthenticatedasserting both halves: the error names the variable, and no POST was attempted. That guard was verified by temporarily making_ari_auth_headersreturn{}— the test failed as designed — then restoring and confirming byte-identical. - payment-vault's documented way to install its own test runner had never worked.
pip install -e ".[dev]"fails outright: it's a flat-layout app with two top-level packages, so setuptools refuses to auto-discover. The pins moved torequirements-test.txt, which is what the CI job uses. - Verification method, stated exactly. The decisive check for agent-host was a fresh
python3 -m venvrunning the exact CI install command (pip install -r requirements.txt, which resolvedmcp1.29.0 — newer than the dev venv's 1.28.1) underenv -i: 45 passed. Then all five newly-gated suites were re-run to the counts above. Green-on-PR is still unverified for the same reason as T2.4 — it needs a push.
Workstream 4 — Repo hygiene¶
T4.1 Quarantine vendored ces-v1-py — DONE 2026-08-12¶
apps/ai-orchestrator/ces-v1-py/ contains a committed build/ directory (duplicate 6–9k-line
generated files), a 39k-line generated GAPIC test, a .tar, and a .whl at the orchestrator
root. Actions: delete ces-v1-py/build/, ces_v1_ces-v1-py.tar, and the .whl from git
(verify first with grep -rn "nvidia_ace" apps/ai-orchestrator --include='*.txt' --include='*.toml'
that requirements install it from a registry, not the local wheel — if the wheel is
load-bearing, move it to a documented artifact location instead of deleting); add ignore rules;
add ces-v1-py to .ignore so ripgrep skips it. Do not delete the package source itself —
it is imported.
Verify: orchestrator boots in vanguard (overmind restart ai-orchestrator, then check
overmind status + a health probe); pytest tests -q collection count unchanged (1,152).
What actually landed (82 tracked files, 2.9 MB; table in context-efficiency-backlog.md):
- Nothing was load-bearing, so nothing needed relocating. The
nvidia_acewheel has zero references repo-wide — absent frompyproject.tomlanduv.lock, never imported, andnvidia_ace installed: Falsein the dev venv. The two archives are referenced only by this plan. Both binaries stay recoverable from git history (e49fee2f,a9109bf4). build/can't be installed from by construction:setup.pyderivespackagesfromfind_namespace_packages()filtered onstartswith("google"), which excludes everybuild.lib.google.…name. That is the fact that made the deletion safe — check it before deleting abuild/in any other vendored package.- Removed beyond the plan's list:
ces-v1-py/google_cloud_ces.egg-info/(6 files) and an innerces-v1-py/ces-v1-py.tar.gz. The 39k-line generated GAPIC test the plan mentions lives in the package source (ces-v1-py/tests/) and was left alone — it is what.ignoreis for. .ignorealready hadces-v1-py/from the 2026-08-08 pass; that step was a no-op beyond a comment refresh. The new rules are inapps/ai-orchestrator/.gitignore, and they cover*.tar/*.tar.gz/*.whlservice-wide, not just the three named paths.- The collection baseline in the Verify line above is stale. Actual: 1,174 tests across
90 files, stable across repeated runs. Proven unrelated to this change by diffing the sorted
--collect-onlynode-id sets with the artifacts present vs. absent — identical. - Health probe is on :8099, not the
8081that.devcontainer/env.local.templateclaims (AI_ORCHESTRATOR_HTTP_PORT/ORCHESTRATOR_HTTP_URLare both wrong there); 8081 returns 000. Verified:overmind statusrunning,:8099/health200, gRPC:50051open, clean boot log. - Also verified past what the plan asked:
google.cloud.ces_v1still imports from site-packages, the four CES-touching test files pass 41/41, anduv build ces-v1-py --wheelsucceeds from the stripped tree (272,919-byte wheel) — which is the operationuv sync --frozenperforms in the Docker build, so the image is unaffected. - An ignore rule is not a deletion, and this task existed because of that. Root
.gitignorehad already listedapps/ai-orchestrator/ces-v1-py/build/since the tree was vendored — the 73 files were committed before the rule, and gitignore never applies to an already-tracked path. After writing an ignore rule, checkgit ls-files <path>; if it prints anything, you still owe agit rm -r --cached. The same check foundlibs/reasoning/trueserve_reasoning.egg-info/(4 files) tracked and matched by no rule —libs/reasoningis imported viasys.path, never pip-installed (trueserve-reasoningis in zero venvs), so nothing read it. Untracked, and root now ignoreslibs/*/*.egg-info/too. - Don't A/B this with
git stash push -- <path>. With a deleted path in the pathspec it fatals and still creates the stash, pushing every pre-existing stash down an index. Usegit restore --source=HEAD --worktree -- <paths>, which leaves the index alone.
T4.2 Dead-code deletion in editor files (pre-decomposition) — DONE 2026-08-12¶
Zero-risk deletions identified by analysis (delete, don't comment out):
FlowEditor.tsxlines ~2526–2961: ~435-line commented-out "MANUAL LAYOUT DISABLED" block.FlowEditor.tsxlines ~3364–3396: debug logging effect; plus ~14 strayconsole.log/warn.FlowEditor.tsx: duplicateMAIN_PIPELINE_Xdeclaration (~159 and insideflowElementsToPipeline~1430) — keep one.ServiceConfigForm.tsxlines ~5691–5770:{false && otherFields.length > 0 && (...)}"Additional Configuration — HIDDEN" block; then remove now-unusedhandleFieldChange/handleRemoveFieldif nothing else references them.
Line numbers are approximate — locate by the quoted markers, not the numbers.
Verify: npm run typecheck + npm test in ai-manager; open the agent editor in the browser,
confirm canvas renders and a stage edits normally.
What actually landed (FlowEditor 5,236 → 4,542; ServiceConfigForm 5,774 → 5,690; full inventory in context-efficiency-backlog.md):
- The commented-out block was 413 lines, and the
useEffectwrapping it is live — it still forces node/edge opacity to 1. Delete the comment, keep the effect. - All four duplicate
MAIN_PIPELINE_X = 400locals went, not just the one inflowElementsToPipeline;MAIN_PIPELINE_X_FOR_EDGESwas a fifth under another name. handleFieldChange/handleRemoveFieldhave ~60 other callers each — they stay.otherFieldswas the only symbol the hidden block owned.eslintfound six more unreferenced symbols inFlowEditor.tsxthat the analysis missed (getEdgeType,flowElementsToTranscriptBranch,pipelinesEqual,handleAddService,handleAddParallelGroup,hasInitialCenteredRef) — the last two positioned nodes by hand, whichuseLayouthas owned for some time. Runnpx eslint <file>after the listed deletions: removing a caller un-masks the next layer.- Kept: the two
console.errorcalls for a missing start/end node, and the unreadlifecycleparameter onpipelineToFlowElements(removing it changesuseMemodependencies, so it is a behaviour decision rather than a deletion).
Workstream 5 — God-file decomposition¶
General method for every task here:
1. Do it as a stack of small commits, each one extraction, each passing typecheck + tests.
2. Extractions are move-only: cut the block, paste into the new file, add imports/exports,
fix references. No renames, no "improvements", no reordering of effects.
3. Keep the original file exporting anything external consumers import (re-export from the new
location) — e.g. BUILTIN_PREFIX and WarmTransferToolContext from ToolSelector.tsx.
4. After each commit: npm run typecheck && npm test (ai-manager) or pytest tests -q
(orchestrator), plus the file-specific browser check listed in the task.
T5.1 ServiceConfigForm.tsx (5,774 → ~900-line orchestrator) — DONE 2026-08-12 ← T4.2¶
File: apps/ai-manager/components/ServiceConfigForm.tsx. Consumers:
StageTemplateEditorDialog.tsx, editor/NodeInspectorPanel.tsx, flow/ParallelGroupEditDialog.tsx.
Extraction order (each row = one commit; line refs are pre-T4.2 approximations):
| # | New file | Moves | Risk |
|---|---|---|---|
| 1 | service-config/NodeCapabilitiesAndBehaviors.tsx |
lines ~1090–2242: NodeCapabilitiesAndBehaviors, BackgroundAudioSection, AgentIdentitySection, catalog helpers (splitCatalogUri, renderableCatalogsForAgent, …) — already self-contained components |
Low |
| 2 | service-config/EndSessionAnalysisPanel.tsx |
~2244–2694: panel + its helper cluster (formatEnumValuesInput, defaultAnalyzerConfig, …) |
Low |
| 3 | service-config/use-voice-catalogs.ts |
Palabra fetch ~3270–3314, ElevenLabs search/fetch ~3317–3490, TTS preview handlers ~2945–3065 → usePalabraVoices, useElevenLabsVoices, useTtsPreview |
Low |
| 4 | service-config/use-warm-transfer-context.ts |
~3135–3211 → useWarmTransferContext(isVoicePipeline, baseAgentId) |
Low |
| 5 | service-config/use-integration-options.ts |
deduplicate the twin fetch effects at ~370–449 and ~2372–2440 → one hook; leave the form.setValue auto-default side effects in the callers |
Medium |
| 6 | service-config/provider-flags.ts |
the ~30 is* derivations ~3212–3260 → pure deriveProviderFlags(serviceType, provider, service) |
Low |
| 7 | service-config/sections/TranslationSections.tsx |
Google S2ST ~3811, OpenAI Realtime Translate ~3847, Google STT Translation ~3880, legacy translation ~5358–5659 | Low |
| 8 | service-config/sections/VoiceEnableBlocks.tsx |
ADK/OpenAI-Realtime/Nova voice toggles ~4266–4467 + TTS voice pickers ~4472–4538 | Low |
| 9 | service-config/sections/ElevenLabsVoiceSection.tsx |
~4759–5110 (static/dynamic voice UI; condition-group editor mutates nested config arrays via closures — move the closures intact) | Medium |
| 10 | service-config/sections/PromptSection.tsx + service-config/prompt-storage.ts |
greeting + prompt editor ~4540–4660 + the no-prompt fallback (its guard is the exact negation, so the three move together); messagesToPrompt/promptToMessages → the separately-tested prompt-storage.ts. handleTemplateVariablesChange stays in the caller (it calls local setTemplateVariables) and is passed as a prop |
Medium |
Behavior-preserving zones (move verbatim, never restructure):
- The outer ServiceConfigForm's form.watch subscription (~467+) and its ref set
(onChangeRef, prevServiceRef, initialStageNameRef, userEditedStageNameRef,
reservedIdsRef, prevProviderRef) — implements the stage-id-derived-from-label
invariant and guards against collab Y.Doc remount churn. Stays in the outer component,
untouched.
- fetchCached 60 s-TTL semantics in the integration fetches (collab-remount reason).
- warmTransferContext memo identity — it threads through renderToolsSelector →
DefinitionDrivenFields → ToolSelector.
- The section='prompt'|'properties'|'all' gating crosscuts everything: pass the gate down to
each extracted section or apply it at the call site — identical rendering either way.
Verify per commit: typecheck + npm test. Final browser check: open an agent stage
(Properties + Prompt tabs), change provider (e.g. OpenAI→Google), confirm fields re-render;
rename a stage label and confirm the id updates; play a TTS voice preview; open a stage inside
a parallel group via ParallelGroupEditDialog.
Progress — T5.1 COMPLETE, all ten extractions done. ServiceConfigForm.tsx 5,774 → 2,505
lines (−57%); 4,569 lines now sit in components/service-config/, including 379 lines of unit
test where the file previously had none. Every step was typecheck-clean (npx tsc -p
tsconfig.typecheck.json --noEmit, exit 0) with the suite green; the suite grew 535 → 582
passing.
How the JSX moves were proven verbatim. For rows 7–10, each hunk was captured to /tmp
before editing, the new module was assembled by mechanical sed dedent rather than retyped, and
the new module's body regions were then diffed back against the captures: 462 lines identical for
7, 240 for #8, 341 for #9, 83 for #10 — 1,126 moved JSX lines proven byte-identical. Do this¶
rather than eyeballing it; the diff caught a real off-by-one in #8 twice (a body range that
swallowed the ) closing the return, and a re-indent that added four spaces the captures
already had). The enabling trick is prop aliasing: name props idiomatically at the boundary
(onFieldChange, definition, voices, onPlayPreview) and alias them back in the destructure
(onFieldChange: handleFieldChange, definition: serviceDefinition, …) so the moved bodies need
no edits at all and the diff is meaningful.
Four rows did not come out move-only, and the divergence is the useful part:
- #5
use-integration-options.tsis a behaviour change, deliberately. The two effects it merged disagreed: the panel had an in-flight cancel guard and the outer form did not. The hook adopts the guard unconditionally, which fixes a real race — change provider while a fetch is in flight and the older response lands last, populating the picker with the previous provider's integrations. Nothing looks wrong, because the values are real. Two other disagreements were parameterised rather than decided (cacheKeyPrefix,clearOnError) so both callers keep their exact prior behaviour; the rationale for each is in the module header. No unit test backs the guard: ai-manager has no React renderer (no testing-library / jsdom / happy-dom, no*.test.tsxanywhere), so hook behaviour is only reachable through the browser check above. Adding a renderer is its own decision, not a rider on this extraction. - #6
provider-flags.tsis the first piece of this file that is unit-testable at all — pure(serviceType, provider, service)→ flags, soprovider-flags.test.ts(38 assertions) now pins the parts that were previously only verifiable by clicking: theisS2SWithoutPromptmembership (which decides whether an author gets a prompt box), the legacy service spellings that stored drafts still carry (OpenAI Realtime API,Google ADK), the two flags that deliberately ignoreserviceType(isGoogleCES,isBedrockAgent), and a no-flag-is-on-by-default sweep that catches an inverted condition. Verified by mutation: dropping the legacy spelling, adding aserviceTypeguard toisGoogleCES, and widening the ElevenLabs HTTP split each fail the suite.isLLMBackedServiceTypemoved here too (it was a second copy in the form). - #8/#9 could not thread preview state without changing behaviour, so they take it as props.
useTtsPreview(config, integrationId)stays called once inConfigFieldsEditorand itsisPlayingPreview/isLoadingPreview/onPlay*values are passed down. Re-calling it insideTtsVoicePickerSectionorElevenLabsVoiceSection— the obvious "cleaner" shape — builds a secondAudioelement and a second copy of the playing/loading state, so the two Play buttons on screen disagree and both previews play at once. Same foruseElevenLabsVoices(a second copy gets its own 300 ms debounce and its own result list). This is whyTtsVoicePickerSectionends up with 12 props: the alternatives are avariantenum (a rewrite) or duplicated hook state (a behaviour change). - #10 split the pure half out first.
messagesToPrompt/promptToMessageswent toservice-config/prompt-storage.tswith 11 unit tests before the JSX moved, which is what found the null crash below. The row also absorbed the no-prompt fallback block, which the plan didn't list: its guard(isAgentAssist || isS2SWithoutPrompt)is the exact negation of the greeting/prompt guard, so the three form one cohesive section and splitting them across commits would have left a window where the partition wasn't obviously total.
Three latent problems this stack surfaced. Two are fixed, each in its own commit after the move-only stack rather than folded into it; the third is a product question, not a refactor.
messagesToPromptthrew on anullarray entry — FIXED.typeof null === "object", so thetypeof msg === "object" && msg.contentguard admitted it and the property read threw during render, blanking the entire stage inspector rather than skipping one entry.config.messagesis untyped JSONB written by the generation MCP'ssave_agent_draft_config, agent import, and the external management API, so[null]is reachable without anyone hand-editing the DB. Found by the unit test written for the extraction, which is the whole argument for pulling pure logic out of a.tsxin a repo with no React renderer. Fixed bymsg &&; the test that pinned the throw now pins the dropped-entry behaviour.handleElevenLabsSearchclosed over the first render'sintegrationId— FIXED. ItsuseCallbackdep array was[]while the debounced body readintegrationId, so every search reused theundefinedfrom the first render, took the no-integration early return, and reported "no voices" for a stage whose integration was plainly connected — while the initial voice load, a separate and correctly-dependent effect, worked fine. Preserved verbatim through #3 to keep that commit move-only, then fixed by adding the dep. The module header now records this as the one intentional deviation in that file.- Two different
isVoicePipelinedefinitions share the name in one file —NodeCapabilitiesAndBehaviorsusespipelineType !== 'text_to_text' && agentType !== 'text_chat',ConfigFieldsEditorusespipelineType === 'speech_to_speech' || pipelineType === 'speech_to_text'. They disagree wheneverpipelineTypeis absent. Both moved verbatim; which one is right is a product question, not a refactor.
The reusable lesson for T5.2–T5.5. Both bugs above are the same shape: state shared in a way
the call site can't see. A god file hides that — a 4,000-line component's closures all read the
same config and the same first-render props, so a stale dep array or a double-spread is invisible
locally and produces a plausible wrong result rather than an error. Extraction is what forces each
dependency to be named at a boundary, which is why it finds bugs even when every moved line is
byte-identical. Expect the same in ToolSelector (the functional-setDraft invariant is exactly
this failure, already documented) and budget a commit or two per file for what surfaces.
T5.2 ToolSelector.tsx (2,769 → ~700-line shell) — DONE 2026-08-12 ← T4.2; pairs with T2.3¶
File: apps/ai-manager/components/ToolSelector.tsx. Importers: SubagentEditDialog,
DefinitionDrivenFields, CopilotEditor, SmartReplyProcessorConfig, SkillSelector,
WorkflowLLMConfig, WorkflowGraphTab, editor/SubagentConfigForm.
| # | New file | Moves | Risk |
|---|---|---|---|
| 1 | tool-selector/inline-tool-helpers.ts |
pure helpers ~157–361 (createLocalId, sanitizeToolName, normalize/canonicalize, createInline*Tool, …) |
Low |
| 2 | tool-selector/ToolSettingsPanel.tsx |
~2322–2769 (OperationEnablement + panel; fully self-contained state) |
Low |
| 3 | tool-selector/SelectedToolRow.tsx |
~1180–1419 + ToolIcon, summarizeBuiltInConfig, href helpers |
Low |
| 4 | tool-selector/InlinePythonToolDialog.tsx |
~1672–1849 | Low |
| 5 | tool-selector/InlineKnowledgeToolDialog.tsx |
~1851–2146 incl. knowledge helpers (upload/secrets/reindex save flow moves intact) | Medium |
| 6 | tool-selector/InlineApiToolDialog.tsx |
~2152–2313 (preserve manualOnly forcing) |
Low |
| 7 | tool-selector/BuiltInToolConfigPanel.tsx |
~1421–1670 | Medium |
| 8 | tool-selector/use-tool-references.ts |
fetch effect ~560–587 → useToolReferences(open, hasSelectedConfigured) |
Low |
Behavior-preserving zones:
- Functional setDraft(prev => …) in all three dialogs (~1697/1701/1716, ~1939–1957,
~2186–2275) — CLAUDE.md invariant; never convert to setDraft({...draft}).
- Capability-ref hiding: hiddenCapabilityToolIds (~632–636), the skip at ~663, and the
re-inclusion when maxSelections === 1 in handleToggle (~797) and handleSaveInlineTool
(~870). All four sites move together or stay.
- Knowledge gating: useKnowledgeAvailability (~551), inline-tools filter (~620–625),
create-guard (~849), picker menu gate (~968) — all four preserved.
- Atomic save-and-select: handleSaveInlineTool's two-arg
onInlinePythonToolsChange(tools, selectedToolIds) and the onChange(toolIds,
builtInConfigs?) two-arg contract — consumers depend on second-arg semantics.
- External Consult adapter in BuiltInToolConfigPanel (~1493): the rename is UI-only; storage
keys are unchanged (invariant).
- Re-export BUILTIN_PREFIX and WarmTransferToolContext from the original module path.
Verify: typecheck + npm test; browser: add/remove a built-in tool with config, create each
of the three inline tool kinds, save, reopen; with a knowledge-disabled tenant, confirm
knowledge options are hidden. T2.3's E2E spec pins this permanently.
Progress — T5.2 COMPLETE, all eight extractions done. ToolSelector.tsx 2,769 → 744 lines
(−73%, past the ~700 target's intent); 2,924 lines now sit in components/tool-selector/, including
495 lines of unit test across two files where the component had no test coverage of any kind.
Typecheck exit 0 and eslint clean on the shell and the whole directory at every step; the suite grew
582 → 633 passing across 25 suites.
Every one of the eight moves was proven verbatim by the T5.1 capture-then-diff method, and the only
diffs were the intended export keywords. The mechanical recipe, now stable enough to reuse
verbatim for T5.3–T5.5:
sed -n 'START,ENDp' components/ToolSelector.tsx > /tmp/x_capture.txt # BEFORE editing
cat /tmp/x_header.txt /tmp/x_capture.txt > components/tool-selector/New.tsx
# add `export` with a Python `assert count == 1` + str.replace, never by hand
HDR=$(wc -l < /tmp/x_header.txt); tail -n +$((HDR+1)) components/tool-selector/New.tsx > /tmp/x_new.txt
diff /tmp/x_capture.txt /tmp/x_new.txt
Two deviations from the row order above, both forced rather than chosen:
- Row 7 had to precede row 3.
SelectedToolRowrendersBuiltInToolConfigPanel, so extracting the row first would have made the panel import back intoToolSelector.tsxwhile the row imported the panel from it — a cycle. Dependency order beats plan-row order; check the render tree before sequencing, not after. - Row 3 split into three modules.
ToolIconis rendered by both the picker list and the selected row, andsummarizeBuiltInConfig+ the href helpers are called by the shell as well, so folding them intoSelectedToolRow.tsxwould have made the shell import from a component it renders. They becameToolIcon.tsxandtool-row-helpers.ts(the latter pure, hence testable).
A shared-type cycle needs its own module, not a re-export from the shell. ToolOverrideConfig
and WarmTransferToolContext were declared in ToolSelector.tsx and read by three sections. A
section importing a type from the shell that imports the section is the same cycle as above, so
both types moved to tool-selector/types.ts and the shell re-exports WarmTransferToolContext
(alongside BUILTIN_PREFIX) to keep every existing caller's import path working. Do this first in
T5.3/T5.4 — those files have far more shared types than this one did.
Two reusable tools/lessons for the remaining rows:
/tmp/xtools/prune-unused-imports.py— written for this task and worth keeping. It parsesnpx eslint --format json <file>, collects the@typescript-eslint/no-unused-vars"is defined but never used" names, and removes only those specifiers from the leading import block, dropping statements left empty. Run it iteratively: one pass can expose another (removing a component drops its icon import, which drops the icon package). Every extraction from a god file leaves a dozen now-unused imports behind, and pruning by hand is where a wrong deletion gets in.- eslint's scope analysis is authoritative for "is this import used" — a regex is not. A guard
script asserting
\bExternalConsultForm\bappears exactly once matched the quoted module path'@/components/tools/external-consult/ExternalConsultForm'too, and fired a falseAssertionError. The assert ran before any write, so nothing was damaged; the fix was to delete the redundant regex check and trust eslint, verified after by tsc + eslint.
All four behaviour-preserving zones were verified intact after the stack: capability-ref hiding
(hiddenCapabilityToolIds + the picker skip + both maxSelections === 1 re-inclusions), knowledge
gating (all four sites), the atomic two-arg onInlinePythonToolsChange(next, nextSelected) save, and
12 functional setDraft((prev) calls with zero setDraft({ spreads across the three dialogs.
The first test coverage found three of my own wrong assumptions before the gates did, all caught
by grepping the source rather than trusting the name: TOOL_TYPES.PYTHON is 'python_sandboxed',
not 'python'; sanitizeToolName('-dash') → 'tool_-dash' (a hyphen is kept, only underscores are
stripped); and sanitizeToolName('_private') → 'private' (the leading underscore is stripped).
The last two are long-standing and deliberate — a tool name is a call target — so the tests document
the asymmetry rather than "fixing" it, and the literal 'python_sandboxed' is spelled out in the
test file so a rename of the constant fails the tests instead of silently moving with them. No
product bug surfaced in this file, unlike T5.1: the one that would have (the setDraft spread) had
already been found and fixed.
T5.3 FlowEditor.tsx (5,236 → core + modules) — DONE 2026-08-12 (browser pass NOT run) ← T4.2, T3.2 (shape round-trip test)¶
File: apps/ai-manager/components/FlowEditor.tsx.
Phase A — pure functions (low risk, one commit each):
| New file | Moves |
|---|---|
flow/pipeline-converters.ts |
pipelineToFlowElements (~788–1379), flowElementsToPipeline (~1419–1555), flowElementsToTranscriptBranch, pipelinesEqual, collectBranchChain, decomposeSwimlaneGroupForCanvas, isSwimlaneGroupEntry, pipelineStructureSignature, getEdgeType — with all comments verbatim (pipelineStructureSignature ~341–357 encodes the "id/provider excluded so renames don't rebuild the canvas" invariant) |
flow/junction-overlay.ts |
JunctionSpec/JunctionData/computeJunctions (~395–687). Invariant: junction diamonds are a view-only overlay — must remain excluded from flowElementsToPipeline and from collab (comments at ~385, ~3350) |
flow/frame-flow.ts |
computeFrameFlow (~688–787). Invariant: edge color = propagated frame flow, view-only |
flow/canvas-history.ts |
cloneHistoryValue/cloneGraphForHistory/graphHistoryKey (~162–260) + a useCanvasHistory hook wrapping the state at ~1652–1754 incl. undo/redo keyboard handling |
Phase B — self-contained UI (low risk):
| New file | Moves |
|---|---|
flow/useInjectionPromptModal.ts |
modal state ~2293–2464 + its JSX |
flow/CanvasViewOverlay.tsx |
CanvasViewBar wiring + reveal pills (~5095–5160) + hidden-count memos (~3068–3092). Invariant: Canvas-View show/hide is view-only, never persisted |
flow-kernel/useCanvasRecenter.ts |
the rAF-debounced ResizeObserver recenter (~4993–5018) — shared with WorkflowGraphTab (its copy at ~2220–2272 says "Mirror FlowEditor behavior"); adopt in both |
Phase C — the sync engine (high risk, one single unit, last):
flow/usePipelineSync.ts: the sync-in effect (~3397–3618, incl. theoutput_lanesleaf-patch effect) + the sync-back effect (~3754–4884) + all 8 guard refs (isUpdatingFromExternalRef,lastSyncedPipelineRef,lastSyncedStructureRef,isInitialMountRef,hasInitialCenteredRef,configRef,isSyncingNodesRef,layoutDoneRef). These two effects share the ref web as a handshake — never split them across modules. Do this only after T3.2's collab shape round-trip test exists, and test with two browser windows in the same collab room (edit in one, confirm the other converges, no rebuild loops).
Do NOT attempt in this plan: unifying FlowEditor's sync engine with WorkflowGraphTab's
(different invariants: leaf-patch vs style-merge), or migrating FlowEditor onto
flow-kernel/FlowCanvas (a good follow-up, out of scope).
Cross-file contract to preserve: the agent page's handleStageChange depends on the
node-N ↔ pipeline[N] positional mapping the converters produce (page.tsx ~1516–1526).
Verify per commit: typecheck + npm test (components/flow/useLayout.test.ts and
lib/collab/pipeline-shape.test.ts are in the suite). Final browser check: build a pipeline
with a parallel group + a router; toggle Canvas View; undo/redo; drag-connect an edge; verify
junction diamonds and edge colors render; two-window collab convergence.
Progress — T5.3 code COMPLETE, all three phases done; the browser check is NOT.
FlowEditor.tsx 4,542 → 1,646 lines (−64%); 3,664 lines now sit in components/flow/,
including 65 unit tests across four new files. Every move proven verbatim by the T5.2 recipe
above. Final gates: npx tsc -p tsconfig.typecheck.json --noEmit exit 0 with no output,
npx eslint exit 0 (7 pre-existing warnings, no errors), suite 633 → 698 passing across
25 suites.
| Phase | New file | Lines | Tests |
|---|---|---|---|
| A | flow/pipeline-converters.ts |
936 | 24 |
| A | flow/junction-overlay.ts |
307 | 18 |
| A | flow/frame-flow.ts |
145 | 12 |
| A | flow/canvas-history.ts |
246 | 11 |
| B | flow/useInjectionPromptModal.ts |
140 | — |
| B | flow/CanvasViewOverlay.tsx |
115 | — |
| B | flow/useCanvasRecenter.ts |
128 | — |
| C | flow/usePipelineSync.ts |
1,647 | — |
⚠️ The two-window collab verification this section requires was not run — there is no browser or running stack in the environment the extraction was done in. Nor was the final browser check (parallel group + router, Canvas View toggle, undo/redo, drag-connect, junction diamonds, edge colors). The evidence behind "no behaviour change" is therefore structural only: byte-verbatim moved bodies plus preserved hook-call order. That is strong evidence for a move-only refactor and no evidence about the sync engine's live behaviour. Run both before this ships.
Phase C is five exported hooks in one file, not one hook. The plan says "never split them
across modules", and they aren't — but a single usePipelineSync() would have been the one
thing worse than splitting the file: the four effects are interleaved with useLayout() and
the render memos in the shell, and effects flush in hook-call order, so combining them
reorders sync-back ahead of layout. That is the exact class of change that produced the
"Maximum update depth exceeded" bug this section's preamble is about. So: one file
(usePipelineSyncGuards + four effect hooks), each called at the line its useEffect
formerly occupied. The 8 guard refs listed above are 7 — hasInitialCenteredRef doesn't
exist in the current file — and one of the 7, layoutDoneRef, is dead: declared, written
once, read nowhere, here or in useLayout. It moved anyway for move-only fidelity, and is
documented in the new file's header rather than deleted.
Extraction escalates the React Compiler lint rules on code you did not touch — budget for
it in T5.4 and T5.5. The compiler bails out of a very large component, so shrinking one
makes it start analysing lines that were always there. Four dormant findings became errors:
a preserve-manual-memoization violation in handleConfigureInjection's empty deps array,
render-phase ref writes in what became useCanvasRecenter.ts and in the shell's
externalOnRequestAddRef, and one genuine false positive on the initialElements memo (the
rule follows addBranch into a setTimeout-deferred openBranchSidebarRef.current read and
cannot prove pipelineToFlowElements doesn't invoke the callbacks during render — it stores
them in node data). Always git show HEAD:<path> before assuming you caused one. Fix
honestly first — real deps, ref write moved into an effect — and suppress only when the rule
is provably conservative, one line, reasoning inline.
Row 3 of Phase B did not adopt the shared hook in both canvases, deliberately.
useCanvasRecenter landed in flow/, not flow-kernel/, because the two policies are not
the same despite WorkflowGraphTab's comment claiming to mirror FlowEditor: the workflow
canvas translates the viewport by half the width delta (preserving the author's zoom), while
FlowEditor calls fitView with no selection (recomputing it). Unifying them changes the
behaviour of one canvas and is a product decision, not a refactor. The false comment is now
corrected in both places. Same for the other two out-of-scope items above, still out of
scope: sync-engine unification and the flow-kernel/FlowCanvas migration.
T5.4 WorkflowGraphTab.tsx (4,488 → core + panels) — DONE 2026-08-12 (browser pass NOT run) ← T4.2, T3.2¶
File: apps/ai-manager/components/workflow-editor/WorkflowGraphTab.tsx.
| # | New file | Moves | Risk |
|---|---|---|---|
| 1 | workflow-editor/node-defaults.ts |
defaultLabelForNodeType, defaultConfigForNodeType, PHASE_COLOR_OPTIONS, slugForId, uniquePhaseId (~271–379) |
Low |
| 2 | workflow-editor/panels/NodeEditor.tsx |
NodeEditor + KeyValueEditor + CodeNodeEditor + ManifestFallbackPanel + RouterConfig + RouterOutputLabelInput + OCRNodeEditorBridge (~2865–4233). Keep the onRenameRouterOutput signature intact — it performs an atomic edge rewrite |
Low-med |
| 3 | workflow-editor/panels/WaitConditionEditor.tsx |
durationToDisplay, WaitConditionEditor, DurationInput (~4234–4488) |
Low |
| 4 | workflow-editor/panels/ResizableBlockPanel.tsx |
~3014–3119 | Low |
| 5 | workflow-editor/NestedSubworkflowEditor.tsx |
~465–625. Comment at ~482: the parent owns Yjs autosave; the draft-vs-commit boundary must not change | Low-med |
| 6 | workflow-editor/workflow-config-normalize.ts |
withRouterOutputsMigrated (~144–253) + the onChange composition (~658–683: router migration → foreach body derivation → dedupConfigLists) |
Med-high |
| 7 | workflow-editor/useWorkflowPhases.ts |
phase state/dialogs/menus (~2167–2444) + updatePhase |
Medium |
| 8 | workflow-editor/useWorkflowGraphSync.ts |
config→flow merge + drag-stop persist + resize (~943–1109). The style-merge comment block (~955–995) encodes an opacity/dimension invariant — verbatim | Medium |
Behavior-preserving zones:
- The onChange normalization wrapper is the Yjs-dedup firewall — collab round-trips can
duplicate keyed lists; every outbound change must keep passing through it, in the same order.
- suppressedNodeDeleteEdgeIdsRef handshake spans handleEdgesChange (~859) and
handleDeleteElements (~1455–1487) — these move together or not at all.
- Router config: the canonical shape is config.outputs=[{id,label,expression}] only (see
cross-cutting invariant in CLAUDE.md) — the migration helper exists for old docs; don't
"simplify" it away.
- WorkflowValidationContext.Provider (~964) is consumed by deep descendants and by
save/publish in the page — the provider stays where it is.
Verify: typecheck + npm test (lib/workflow-validation.test.ts,
lib/workflow-openapi.test.ts in suite); browser: create workflow, add router + foreach,
rename a router output (edges follow), collapse/expand foreach, drill into a subworkflow,
delete a node (edges stitch), phases render/move. T2.3's E2E pins the golden path.
Progress — T5.4 code COMPLETE (all 8 rows, plus two the plan didn't have); the browser check is NOT.
WorkflowGraphTab.tsx is 4,687 → 1,963 lines (−58%), with 3,309 lines in ten new modules and 30 new
unit tests. npx tsc -p tsconfig.typecheck.json --noEmit exits 0; npx eslint components/workflow-editor/
exits 0 (26 warnings, all pre-existing and all showPhases-dep or set-state-in-effect); the suite is
698 → 728, 0 failures.
| # | Module | Lines | Tests |
|---|---|---|---|
| 1 | workflow-editor/node-defaults.ts |
138 | 14 |
| 2 | workflow-editor/panels/KeyValueEditor.tsx |
187 | — |
| 3 | workflow-editor/panels/ResizableBlockPanel.tsx |
130 | — |
| 4 | workflow-editor/panels/WaitConditionEditor.tsx |
314 | 4 |
| 5 | workflow-editor/panels/NodeEditor.tsx |
1,414 | — |
| 6 | workflow-editor/NestedSubworkflowEditor.tsx |
209 | — |
| 7 | workflow-editor/workflow-config-normalize.ts |
163 | 12 |
| 8 | workflow-editor/useWorkflowPhases.ts |
172 | — |
| 9 | workflow-editor/panels/PhaseDialogs.tsx |
249 | — |
| 10 | workflow-editor/useWorkflowGraphSync.ts |
333 | — |
Two rows are not in the table above. KeyValueEditor needed its own module because two siblings
render it (NodeEditor and WaitConditionEditor), which the plan's row 2 would have buried inside one of
them; and planned row 7 split into a hook (useWorkflowPhases) plus presentational chrome
(PhaseDialogs) because the phase code was three blocks ~1,500 lines apart in one component and the
dialogs are ~250 lines of pure JSX. Build the identifier → line-number graph before sequencing — the
plan's row order is not a dependency order, and it also missed that WaitConditionEditor must precede
NodeEditor.
⚠️ The browser verification in Verify above was NOT run — no dev server was started in this session.
The evidence for all ten extractions is structural: each moved body was diffed byte-for-byte against a
capture taken before the cut (uniform re-indentation only, on the three PhaseDialogs regions), every
dependency array moved unchanged, and the effect order in the component is unchanged. As with T5.3, no
unit test reaches the canvas.
Three constraints the plan set were honoured, and one it didn't anticipate. The onChange
normalization order is preserved exactly (now named normalizeWorkflowConfig, called from both the
outbound wrapper and the load migration instead of duplicated). The suppressedNodeDeleteEdgeIdsRef
handshake did not move — useWorkflowGraphSync returns the raw onEdgesChange and the shell keeps
both the ref and handleEdgesChange, next to handleDeleteElements. WorkflowValidationContext.Provider
stayed put. The unanticipated one: NestedSubworkflowEditor imports WorkflowGraphTab while
WorkflowGraphTab imports it — a genuine cycle, safe only because both are hoisted export functions
and neither is read at module-evaluation time. If a later change makes either a const arrow or a
memo(...) wrapper, the drill-in canvas goes blank with no build error.
One non-move edit, disclosed: SENTINEL_START/SENTINEL_END were declared identically in
WorkflowGraphTab.tsx and flow/workflowFlowConverters.ts. The converters now export them and both
readers import; otherwise useWorkflowGraphSync would have been a third copy of the same two strings.
withRouterOutputsMigrated had zero test coverage before this pass despite being the only thing
standing between a legacy condition_field/routes router and a runtime that always routes
__default__. It has 8 tests now, including the out_else collision the migration itself creates.
T5.5 Agent editor page (3,062 → ~1,200) — DONE 2026-08-12 (browser + collab pass NOT run) ← T5.1, T5.3¶
File: apps/ai-manager/app/(main)/agents/[agentId]/page.tsx (~55 useState hooks).
| # | New file | Moves | Risk |
|---|---|---|---|
| 1 | lib/pipeline-stage-patch.ts |
handleStageChange's matching logic (~1480–1600) as pure patchStageInPipeline(pipeline, {priorId, snapshot, updated, selectedNodeId}) + unit tests for all three fallback matchers |
Med-high |
| 2 | components/editor/AgentPageHeader.tsx |
toolbar ~2255–2410 (keep AutosaveIndicator/PresenceStack props) | Low |
| 3 | components/editor/AgentPageDialogs.tsx |
dialogs ~2819–3062 | Low |
| 4 | hooks/useCcaasRouting.ts |
~146–190, ~402–411, ~877–911 | Low |
| 5 | hooks/useAgentTester.ts |
chat/voice/translation tester state + iframe/embed-token memos (~1007–1081, ~413–432, ~1160–1360) | Low-med |
| 6 | hooks/useSubagentAccessor.ts |
activePipelineAccessor (~1361–1479) |
Medium |
| 7 | hooks/useAgentVersioning.ts |
load/version/publish/versionSelect (~482–583, ~912–988) | Medium |
Behavior-preserving zones:
- patchStageInPipeline: the three fallback matchers exist because template stages lack ids
until label-derived; a regression silently drops edits (comment ~1493: no autosave
safety net). Write the unit tests before extracting (characterization tests).
- Publish drains collab-flush before POST (~923–937); flushCollabBeforeTest feeds the
tester's onBeforeCall (~2038). Ordering is load-bearing in both.
- CCaaS selection is kept out of config because the Y.Doc allow-list would drop it
(comment ~401) — the hook must keep it in local state + its own API call, atomic with publish
(ccaasIntegrationIds in the publish body).
- Selection/inspector/Canvas-View state is deliberately local (not config) so toggles don't
broadcast via collab (comment ~250–253).
- enableYjs={versionStatus === 'draft'} (~2204) ties versioning to collab wiring — keep the
versioning hook returning versionStatus and the page owning the wiring.
Verify: typecheck + npm test; browser: full cycle — load agent, edit stage in inspector,
rename stage, save, publish with CCaaS availability selection, switch versions, run chat
tester. Two-window collab convergence.
Progress — T5.5 code COMPLETE (all 7 rows); the browser + two-window collab checks are NOT.
page.tsx is 3,032 → 2,294 lines (−24%), with 1,589 lines in seven new modules and 21 new unit
tests. npx tsc -p tsconfig.typecheck.json --noEmit exits 0; eslint exits 0 errors across the page and
all seven modules (37 warnings — the moved anys, the two exhaustive-deps ones documented below, and
9 pre-existing unused-vars in the page that predate this pass); the suite is 728 → 749, 0 failures.
| # | Module | Lines | Tests |
|---|---|---|---|
| 1 | lib/pipeline-stage-patch.ts |
145 | 21 |
| 2 | components/editor/AgentPageHeader.tsx |
265 | — |
| 3 | components/editor/AgentPageDialogs.tsx |
359 | — |
| 4 | hooks/use-ccaas-routing.ts |
170 | — |
| 5 | hooks/use-agent-tester-embeds.ts |
153 | — |
| 6 | hooks/use-active-pipeline.ts |
203 | — |
| 7 | hooks/use-agent-versioning.ts |
294 | — |
The plan said three fallback matchers; there are four. The fourth is config.output_lanes, where a
second audio branch is serialized as a lane processor and never reaches config.pipeline at all — and it
keys on the stable service id, not the React Flow node id, because a freshly-added lane stage's node
id is node-<uuid> until a round-trip. Anyone auditing that path by the plan's count would conclude one
matcher was dead. All four are pinned, plus the precedence between them (identity is skipped once a
priorId exists; the positional arm refuses a slot holding a parallel group).
useMemo may move; useEffect may not. Hook order only has to be stable, so relocating a
useState/useMemo/useCallback is free — but a hook that swallows an effect changes which effect runs
first unless it is called at the position of the first line it absorbed. Both effect-carrying hooks
(rows 5 and 7) are called exactly there, and only non-effect hooks ended up below them. Row 7 forced one
consequence: canEditCurrentDraft (a plain const reading versionStatus) had to move down with the
hook, which is safe only because its earliest reader is ~500 lines further on. Check that before moving a
derived const, not after.
Row order is not dependency order — again. Row 4 (use-ccaas-routing) was done before row 3
(AgentPageDialogs) so the publish dialog could take the hook's named members instead of raw state, and
row 7 could only be sequenced after confirming useCcaasRouting is called above the versioning hook's
insertion point (ccaasPublishPatch would otherwise be in its TDZ). Same lesson as T5.4.
Naming deviated from the plan, deliberately. hooks/ is kebab-case use-*.ts in 15 of 16 existing
files, so the plan's useCcaasRouting.ts / useAgentTester.ts / useSubagentAccessor.ts became
use-ccaas-routing.ts / use-agent-tester-embeds.ts / use-active-pipeline.ts. The last rename is not
cosmetic: useSubagentAccessor describes nothing the module does — it returns the active pipeline
accessor and the selection derived from it, with no subagent-specific logic at all.
Two exhaustive-deps warnings in use-agent-versioning.ts are the correct answer, not debt.
loadPipeline's deps stay [router, t] while eight page setters come in as params. Adding them would
satisfy the linter and introduce a real hazard: the day a caller passes an inline arrow instead of a raw
useState setter, loadPipeline rebuilds every render, the load effect that depends on it refires, and
the agent re-fetches forever. Omitting them captures nothing stale, because useState setters are
referentially stable. The invariant is documented in the module header: pass raw setters.
Everything the plan's behavior-preserving zones asked for is intact. Publish still drains
collab-flush with final: true before the publish POST (both now inside row 7, adjacent, so the
ordering is local and visible). flushCollabBeforeTest stayed in the page — it must be defined before
handleStartChat, which is upstream of row 5's call site. CCaaS selection is still local state with its
own API call, atomic with publish via ccaasPublishPatch(); the patch returns {} when untouched, which
is the interlock that keeps an untouched publish from reading as "revoke every integration". Selection /
inspector / Canvas-View state is still page-local. enableYjs={versionStatus === 'draft'} still lives in
the page, with the hook returning versionStatus.
Also left in place on purpose: the four thin modals (Activity, Share, Export picker, AgentCore) —
extracting a <Dialog> with two children buys nothing; and the showPublishDialog reset effect, because
it resets gradual-rollout controls and CCaaS state, so splitting it would reorder effects.
One dead-code finding, disclosed and not fixed. The page computed voiceTranscriptSrc and passed it
to nothing — its only consumer was a debug console.log. PhoneCallTest re-derives the same URL from the
four parts it is handed. Row 5's hook still returns it (it is the correct URL and the duplication belongs
downstream) but the page no longer destructures it. Collapsing the two is a behaviour change, not a move.
Separately, JsonTreeEditor and handleTreeChange are dead together in the page — a whole editor
surface nothing renders. Both are follow-ups, out of scope for a move-only pass.
⚠️ The browser verification and the two-window collab convergence check in Verify above were NOT
run — no dev server was started. Evidence for all seven rows is structural: every moved body was diffed
byte-for-byte against a capture taken before the cut (byte-identical for row 7, uniform re-indent only
elsewhere), every dependency array moved unchanged, and effect order in the page is unchanged. The 21 new
tests cover the matchers only; nothing in npm test renders this page.
2,294 is not the target ~1,200, and the gap is not more rows of this kind. What remains is ~130 lines
of imports, ~1,330 lines of state + 25 handlers (handleStageChange's state writes, handleSave, the
subagent-navigation cluster, four export paths, the lane/swimlane writers), and ~830 lines of render.
Reaching 1,200 means splitting the render tree and the handler cluster — work the plan scoped no rows for.
Every row it did scope is done.
T5.6 universal_builder.py (7,252 → facade + builders/universal/ package) — DONE 2026-08-13 (live-call pass NOT run)¶
File: apps/ai-orchestrator/lib/pipecat/pipeline/builders/universal_builder.py. One class,
UniversalPipelineBuilder, 14 method groups. Pattern precedent: data_branch_builder.py /
callback_factories.py / binding_utils.py were previous extractions — follow their style
(delegate objects taking the builder instance / plain functions taking explicit args).
Preflight constraint (read first): preflight.py does not instantiate this builder —
it shares get_service_mapping (service_constants.py), ServiceRegistry.validate_service_config,
get_api_key, TRANSLATION_SERVICE_LOOKUP, and build_copilot_steps. Anything extracted must
keep resolving stages through get_service_mapping/ServiceRegistry or preflight silently
drifts from the live build. Intra-class extraction is otherwise preflight-safe.
Extraction order (one commit each, pytest tests -q between):
| # | New module | Moves | Risk |
|---|---|---|---|
| 1 | universal/normalization.py |
Group B (~460–879: _normalize_pipeline_v2, _inject_internal_components, _harvest_greeting, parallel-entry detection) + the template helpers (~204–240) as pure functions. _harvest_greeting writes runtime_context["message"] — grpc_provider.py:903 reads that exact key |
Low |
| 2 | universal/config_translation.py |
Groups C+D (~880–1566): _build_service_specific_config (per-provider if/elif ~1174–1275), _translate_service_config, provider maps, remote-analysis config → pure functions (svc, provider, svc_type, fmt, sample_rate, runtime_context) |
Low |
| 3 | universal/payments.py |
Group H (~3537–3781) incl. the per-provider handoff-TTS chain (~3676–3759). Depends on runtime_context["_payment_parallel_unsupported"] being set in build_processors first — document the ordering in the module docstring |
Low-med |
| 4 | universal/post_llm.py |
Group F (~1836–2560): IVR navigator, CES bindings, speculative speech, TTS cacher, interruption/TTS/transcript-sync gates, publishers, think-filter. Insertion order into the chain is load-bearing — extract as functions called in the same sequence; keep the lazy imports lazy | Medium |
| 5 | universal/tool_wiring.py |
Groups G+I (~2561–3536 + ~3782–4672): _resolve_tool_schemas, _register_tool_callbacks, _ToolWiringContext, locally_handled_tool_names, _mark_external_flow_activity → a ToolWirer class holding runtime_context + session managers. The FRAME_HANDLED_TOOL_NAMES MCP strip (~4057) is invariant-guarded by test_mcp_local_tool_strip.py |
Medium |
| 6 | universal/llm_context.py |
Group J (~4673–5178) — extract with/after #5 (consumes _ToolWiringContext) |
Medium |
| 7 | STOP. Do not extract Groups L (parallel/branch, ~5370–6243) or M (chains, ~6244–6976) | — |
Groups L/M stay put because: (a) they implement the parallel-group-loses-input-chain
documented known bug — a planned fix will reshape this exact seam, so extracting first is
wasted/risky work; (b) _insert_input_chain is called only from _build_linear_pipeline
(documented invariant), and S2S recorder placement comments (~6296–6300, ~6879–6881) implement
the "silent caller track" invariant.
Cross-cutting cautions:
- self.runtime_context is a mutable blackboard (~190 references; keys like
_pipeline_config, universal_audio, branch_manager, conversation_recorder, message,
allow_interruptions written in one phase, read in another). Extracted code takes the
dict itself, never a snapshot/copy.
- ~60 method-local deferred imports exist for circular-import avoidance — keep them
method-local in the new modules; after each commit run a bare
python -c "from lib.pipecat.pipeline.builders.universal_builder import UniversalPipelineBuilder"
(with the app's import path) to catch import cycles.
- ServiceAssembly and _ToolWiringContext are consumed by data_branch_builder.py and
tests — re-export both from universal_builder.py.
- test_payment_mode_controller.py builds via object.__new__(UniversalPipelineBuilder) —
method moves will break it; update that test as part of commits #3/#5, preserving what it
asserts.
- Two-phase ordering: tool resolution (Phase 1) must precede service creation (OpenAI Realtime
needs tools at session creation).
Safety net: test_universal_builder_template_config.py, test_pipeline_frame_flow.py (full
build order), test_mcp_local_tool_strip.py, test_payment_mode_controller.py,
test_copilot_builder.py, test_preflight_translation_mapping.py, test_preflight_copilot.py.
Coverage is weakest exactly where we are not touching (parallel internals, _build_llm_context)
— for #6, add 2–3 characterization tests of _build_llm_context output before moving it.
Verify per commit: pytest tests -q full suite; final: place a real test call through a
vanguard voice agent (cascade + one S2S provider) and one copilot session; confirm transcripts
+ recording land.
Progress — T5.6 COMPLETE: all six extractions done, row 7's STOP honoured, suite green.
universal_builder.py is 7,252 → 3,311 lines (−54%), with 4,822 lines in six new modules under
lib/pipecat/pipeline/builders/universal/. .venv/bin/python -m pytest tests -q is 1,272 passed, 2
skipped (baseline 1,268 + row 6's 4 characterization tests), 0 failures.
| # | Module | Lines | Moves |
|---|---|---|---|
| 1 | universal/normalization.py |
497 | Group B + the template helpers |
| 2 | universal/config_translation.py |
782 | Groups C+D |
| 3 | universal/payments.py |
319 | Group H incl. the per-provider handoff-TTS chain |
| 4 | universal/post_llm.py |
476 | Group F |
| 5 | universal/tool_wiring.py |
2,084 | Groups G+I (tool phases 1–2) |
| 6 | universal/llm_context.py |
664 | Group J (phase 3) |
universal_builder.py is still the only entry point — every module is imported back into it and the
class keeps a thin delegating method wherever a call site outside the moved block exists, so no caller,
test, or subclass changed. Where the only caller moved too, no method was left behind:
_build_summarization_assistant_params had exactly one caller in the repo and it was inside
_build_llm_context itself, so it went with it. Every extracted function takes builder as an explicit
first parameter (the data_branch_builder.py precedent) except the ones that never read builder state,
which became plain functions. runtime_context is passed as the dict itself everywhere, never copied.
Move-only was proven mechanically, not eyeballed, and the recipe is worth reusing for any Python god
file. Per row: snapshot the target to /tmp before the first edit (the working tree carries ~800
uncommitted insertions, so git show HEAD:… is the wrong baseline); extract by 1-indexed line slice with
asserts on both boundary lines; dedent by exactly 4 and prove the dedent reversible; apply each self.X →
builder.X rename through a helper that asserts the occurrence count first; then re-derive forward —
take the pristine slice, apply only the enumerated transformation, and assert the result appears verbatim
in the new module. Span accounting closes the loop: enumerate the covered line ranges and assert every
uncovered line inside the cut span is blank. Two checks earned their keep. (a) A flake8 baseline captured
before the cut, diffed after as the union of (post-cut target + new module) with filenames and line
numbers stripped — that is what proves "no new finding" and, in rows 5–6, that no pre-existing finding
was silently lost either. Row 6 lost exactly one, F811 redefinition of unused 'LLMMCPServerConfig', and
it was investigated rather than accepted: the name was imported twice and both uses were inside the moved
block, so both bindings were orphans and the F811 disappearing is the correct consequence. (b) Derive the
new module's imports from evidence — write the file with a placeholder header, let flake8 --select=F821
enumerate the undefined names, and map each back to its exact import statement in the source. Row 6's
34-name import block linted clean on the first run.
Orphan-import policy, settled in rows 5–6: delete exactly the names the move orphaned; keep imports
that were already dead beforehand (resolve_variable_bindings, is_transfer_enabled,
ORIGIN_CONSULT_PROMPT). They are baseline findings, and removing them is a different change. And cut
later blocks first — an assert that fires before write_text() corrupts nothing.
A surviving self in moved text is usually prose, and renaming it can make a true comment false.
# \self._db_pool` is never setnames the builder's own attribute and was renamed with the code;# if not self.start:describes *pipecat's*UserTurnStrategies.post_initand# `llm.register_function(name, self._tool_wrapper)`` describes pipecat's call, so renaming either would
have made it wrong. Row 6 pins its four survivors with an equality assert on the code below the docstring.
Row 7's STOP was honoured — groups L (parallel/branch) and M (chain assembly) are untouched, for the
two reasons the plan gave, and both the module table in
docs/04-processes/universal-pipeline-builder.md and the new invariant in
apps/ai-orchestrator/CLAUDE.md now say so explicitly, so a later agent doesn't "finish the job" and undo
the decision. The plan's other constraints held: ServiceAssembly and _ToolWiringContext are still
importable from universal_builder.py; the ~60 method-local deferred imports stayed method-local, with a
bare-import cycle check after every row; _harvest_greeting still writes runtime_context["message"] for
grpc_provider.py:903; and the three-phase tool ordering is unchanged.
One hard blocker found, and it is not specific to this file: test_consult_tool_registration.py:191
calls inspect.getsource(…_insert_post_llm_processors) and asserts on the text. A test that reads source
pins the method to its file, so any extraction touching it fails in a way that looks like a behaviour
regression. Neither row-6 method is reached by it; a future row that is must rewrite that assertion first.
The plan asked for "2–3 characterization tests of _build_llm_context before moving it" —
tests/test_llm_context_build.py has 4, written pre-move and passing unchanged post-move. The fourth is
the one worth keeping: dtmf_tool_enabled reads the stage's tool refs while a2ui_tool_enabled reads the
pipeline-level config off runtime_context — adjacent lines, two different dicts, which is exactly the
pair a careless move swaps.
⚠️ The live-call verification in Verify above was NOT run — no vanguard call was placed and no copilot session was started. Evidence is the full suite plus the per-row proofs above.
T5.7 Registration-surface drift guards (follow-up to T5.6) — DONE 2026-08-13¶
The "seven places" provider registration (3 ai-manager + 4 ai-orchestrator, per
docs/04-processes/universal-pipeline-builder.md) plus the shadow if/elif surfaces inside
universal_builder.py (_build_service_specific_config, payment-handoff TTS, remote-analysis
map, S2S special-casing in build_processors) can drift by omission. Add a drift test on the
orchestrator side: for every (provider, service) in SERVICE_LOOKUP_V2, assert
config_translation produces a config (or the provider is in an explicit exemption list with a
reason), and assert every TRANSCRIPTION_CHOICES entry (ai-manager lib/copilot-config.ts)
has a SERVICE_LOOKUP_V2 row — the ai-manager side already has the mirrored-list test pattern
from T1.1's failures; extend it rather than inventing a new mechanism.
Verify: new tests pass; deliberately removing one provider row makes them fail.
Progress — T5.7 COMPLETE: the guard is in, and its first run found SIX pre-existing defects.
apps/ai-orchestrator/tests/test_service_registration_drift.py is 11 tests (8 guards + 3 sensitivity
checks). .venv/bin/python -m pytest tests -q is 1,283 passed, 2 skipped; ai-manager npm test is
755 pass, 0 fail. Every defect below was live on HEAD before this row — none was introduced by
T5.1–T5.6 — and every one of them was silent.
The plan's second assertion already existed. lib/copilot-config.test.ts (written for T1.1) parses
service_constants.py as text and asserts every TRANSCRIPTION_CHOICES entry has a SERVICE_LOOKUP_V2
row. So this row extended that pattern in the other four directions rather than duplicating it, and used
the same mechanism the plan asked for: read the other language's source as text and regex it. A
mirrored copy of either table would simply be an eighth place to forget. Each parse carries a guard
assertion (categoryAliases contains s2s_translation, >40 definitions, >30 PROVIDER_OPTIONS entries,
('deepgram','stt','Nova-3') present) because a regex that silently matches nothing makes every
downstream check vacuously true — the specific failure mode T1.1 hit.
| Join | Guard | What drift looks like |
|---|---|---|
| provider gate ⊇ lookup providers | test_every_lookup_provider_is_in_supported_providers |
Unsupported service_provider one line above a correct mapping |
lookup → ServiceRegistry |
…_service_resolves_in_the_service_registry (×2 tables) + an explicit OCR-family test |
Service not found in registry, or nothing at all |
| lookup → buildable | test_every_lookup_row_builds_a_pipeline_step (real builder, empty stage config) |
a service_type PipelineServiceStep rejects |
| authoring surfaces → lookup | …_service_definition_… + …_provider_options_… |
publishes clean, dies at build |
The six defects. (1) All four OCR providers were unregistered, everywhere. _load_ocr_providers
imported a CustomHTTPOCRWrapper that ocr_services.py has never defined, and _try_import returns {}
for the whole family when any one name fails — so Textract, Azure Document Intelligence, Google
Document AI and LLM Vision were all absent from ServiceRegistry. The only evidence was one line logged at
DEBUG, while _try_import's own docstring promised a warning; it now logs at WARNING. Nothing else
notices, which is why this survived: the wrappers construct lazily and get_translation_service_mapping
never raises. (2) azure was missing from SUPPORTED_PROVIDERS, and (3) palabra was missing from it
and keyed on a stage type the editor cannot write (s2s, where registry.ts's categoryAliases makes a
translation-category definition author as s2s_translation) — two independent reasons one row was dead.
(4) The canonical ("trueserve","ocr","LLM Vision") row — the one an OCR stage added today actually hits —
did not exist; only four legacy (anthropic|openai|google|amazon, "…Vision") aliases no service definition
can produce. (5) Four definition names matched no row, because there are two authoring vocabularies for
one service: the editor persists the PROVIDER_OPTIONS name ("Nova-3") while the generation MCP hands the
model definition.service verbatim ("Nova", "Media Translate", "Text Bridge", "Phrase Buffer"), so
a generated agent failed where the same service authored by hand built fine. (6) The editor offered three
combinations the runtime refuses — openai/stt/Whisper, elevenlabs/stt/Scribe, and a retired
trueserve/ocr/Custom HTTP — and validateServiceCombination reads PROVIDER_OPTIONS, so those agents
passed validation and then failed to build.
Fix-vs-remove was decided per finding, from the product verdict, not from what made the test go green.
The unwrapped VAD-segmented STT tier (Whisper, non-realtime Scribe) is a deliberate omission — one audio
buffer plus one speaking flag attributes every utterance to a single speaker, silently breaking per-side
runs_on — so the editor entries were removed rather than wrappers invented. custom_ocr_http is in
ai-manager's RETIRED_INTEGRATION_TYPE_IDS with no provider under libs/ocr/trueserve_ocr/providers/, so
its lookup row went too. Fully-wired-but-unreachable services (Azure OCR, Palabra, the four definition
names) were made reachable. Each removal and each alias carries a comment saying which it is and why.
No static exemption list, on purpose. The plan allowed "an explicit exemption list with a reason", but
the real rule turned out to be structural: an editor-writable name may miss SERVICE_LOOKUP_V2 only if
TRANSLATION_SERVICE_LOOKUP resolves it (two tables, and the translation one never raises — it falls
through to the stage's own service name). Encoding that cannot rot the way a hand-kept list of expected
misses does, and it needed zero exemptions.
Three deliberate non-changes, recorded in comments so they aren't "fixed" blind. Palabra's
service_type stays s2s_llm (re-pointing it at s2s_translation changes pipeline placement and is only
verifiable on a live Palabra call); no palabra row was added to TRANSLATION_SERVICE_LOOKUP (it would mean
choosing a phrase/token mode blind); the four legacy …Vision OCR aliases stay for hand-written configs.
The sensitivity requirement is pinned inside the suite, not demonstrated once by hand — three tests
drop a lookup row / remove a provider from the gate / unregister a service and assert the guards go red.
One of them documents a real subtlety: dropping all deepgram SERVICE_LOOKUP_V2 rows leaves Nova-3 and
Flux resolvable through TRANSLATION_SERVICE_LOOKUP, so the failure names Aura, not Nova-3.
CI: no paths: filter, and now for a second reason. The guard lives in ai-orchestrator's suite but
reads ai-manager's sources, so a filter on apps/ai-orchestrator/** would skip it on exactly the
ai-manager commit that adds an unmappable dropdown entry. The rationale comment in
.github/workflows/test.yml was extended to say so.
⚠️ Two of the shadow if/elif surfaces the row named are still uncovered, and both fail silently, so
they are worth their own follow-up: the payment-handoff private-TTS chain (universal/payments.py — a
five-arm provider if/elif whose fallback needs a downstream TTS stage, so a new TTS provider means an S2S
secure capture with no private prompts) and map_remote_analysis_llm_provider (universal/config_translation.py
— returns the provider verbatim for anything unmapped and default_remote_analysis_model then falls back
to gpt-4o-mini, so a new LLM provider gets a wrong-vendor model rather than an error). The S2S
special-casing in build_processors is untested for the same reason row 7 of T5.6 was stopped: it lives in
the parallel/chain-assembly groups.
Docs updated in the same change: docs/04-processes/universal-pipeline-builder.md gained a Drift
guards subsection (the four-join table, the no-exemption-list rule, and both first-run findings); the
full "why" is a new invariant in apps/ai-orchestrator/CLAUDE.md with a one-line cross-reference in
apps/ai-manager/CLAUDE.md (it owns two of the seven places) and claims in the root index. userguide/
was checked and needs nothing: integrations/adding-an-integration.md already documents Custom HTTP OCR as
retired, agents/copilots.md lists only the offered STT tiers, and agents/pipeline-editor.md says OCR
belongs in workflows.
Sequencing summary¶
Week 1 T1.1 → T1.2, T1.3, T4.1, T4.2 (CI gate live, dead code gone)
Weeks 2–3 T2.1 → T2.2, T2.3 ; T3.1 → T3.2 (E2E specs, tiers, shape tests)
Week 4 T2.4 ; T5.1, T5.2 begin (E2E in CI; low-risk extractions)
Weeks 5–8 T5.3, T5.4 (after T3.2) ; T5.6 #1–4 (editors + builder, guarded)
Weeks 9–12 T5.5 ; T5.6 #5–6 ; T5.7 (page, tool wiring, drift guards)
Parallelizable at any time: T1.3, T3.1, T4.1, T4.2. Never parallelize two T5 tasks that touch the same file. T5.3 Phase C and T5.5 both need two-window collab verification — schedule them when the vanguard stack is available.
Out of scope (explicitly deferred)¶
- Unifying FlowEditor/WorkflowGraphTab sync engines; migrating FlowEditor to
flow-kernel/FlowCanvas. - Extracting
universal_builder.pygroups L/M (blocked on the parallel input-chain fix). - Voice-call E2E automation.
- conversation-api MinIO→boto3 swap (tracked in
docs/07-infrastructure/cloud-managed-swap.md). - Reducing the 253
: anyusages in ai-manager (do opportunistically inside T5 moves only when the type is already known — no new type design).