Fixes a type-assertion panic when a workspace has an empty role string.
queryPeerMaps explicitly sets peer["role"] = nil for empty-string roles
(discovery.go:340), and filterPeersByQuery did p["role"].(string) without
guarding for nil. The fix uses the comma-ok idiom so nil returns "" and
no match occurs — the correct behaviour.
Test files added (all pure functions, no DB/side effects):
- discovery_filter_test.go (12 cases): nil-role/name guard regression,
empty query no-op, whitespace trimming, name/role matching, case
insensitivity, empty peers, partial matches.
- org_helpers_walk_test.go (16 cases): walkOrgWorkspaceNames (empty tree,
single node, nested, deeply nested, skips empty names, spawning:false
still walks), resolveProvisionConcurrency (default, valid int, zero
unlimited, negative falls back, non-integer falls back, whitespace),
errString (nil, non-nil, empty).
- delegation_extract_response_text_test.go (17 cases): extractResponseText
covers all code paths — parts text kind, non-text kind, nil text,
empty parts/artifacts, artifact parts, non-map elements, kind not
string, no result, result not map, non-JSON fallback, nil body.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two pre-existing canvas test failures:
1. canvas/src/components/tabs/FilesTab/tree.ts:getIcon()
FILE_ICONS keys are lowercase (".json") but the extension was looked
up as-is (".JSON"). Result: FILE_ICONS[".JSON"] → undefined → fallback
"📄" instead of "{}".
Fix: lowercase the extension before FILE_ICONS lookup. Also added ?.
null-coalescing on split().pop() to handle filenames without extension.
2. canvas/src/store/__tests__/canvas-topology-pure.test.ts
sortParentsBeforeChildren test expectation was wrong: it assumed orphan
would come after root, but when parentId references a missing node
the orphan keeps its input order (orphan, then root). Updated the
expectation and corrected the comment to match the actual behaviour.
Closes#697.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
mc#687 root-cause finding from mc#424: the EIC diagnose smoke was
reading diagnoseStep.error (Go error string) and discarding
diagnoseStep.detail (subprocess stderr). The actionable signal — e.g.
AccessDeniedException: ... is not authorized to perform:
ec2-instance-connect:OpenTunnel
— lives in detail. Reading only .error produced:
exec: process exited with status 1
which was uninformative and caused a 21h outage investigation.
Fix: extract .detail (subprocess stderr) as primary output; append
Go error string in parentheses when both fields are populated.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds isolated tests for DropTargetBadge — the floating drag-target affordance.
Render-condition coverage:
- Renders nothing when dragOverNodeId is null
- Renders nothing when dragOverNodeId node has no store match
- Renders nothing when getInternalNode returns undefined
- Renders badge with correct name when all inputs are valid
- Badge text follows 'Drop into: <name>' format
- Badge contains exact target name from store
- Renders nothing when target name is null (empty data.name)
Ghost visibility (slot rect inside parent bounds) is deferred to
integration tests that render the full canvas — flowToScreenPosition
coordinate arithmetic is better covered there.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds isolated tests for the pure tree-traversal core of
useOrgDeployState. The buildDeployMap function handles:
- Root / leaf identification via parent-chain walk
- isDeployingRoot: true when any descendant is "provisioning"
- isActivelyProvisioning: true only for the node itself
- isLockedChild: true for non-root nodes in a deploying tree
- isLockedChild: also true for nodes in deletingIds (cross-cutting)
- descendantProvisioningCount: non-zero only on root nodes
- O(n) single-pass walk verified on 50-node tree
Also exports buildDeployMap for direct unit testing (was internal).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
extractResponseText in delegation.go had no unit tests. It extracts text
from A2A JSON-RPC response bodies by walking result.parts and
result.artifacts[*].parts arrays. Tests cover: non-JSON fallback, valid
JSON with no result, result is not a map, parts with text kind, parts
with non-text kind (image skipped → raw body), multiple parts (returns
first text), artifacts with nested text parts, artifacts with non-text
kind, empty parts/artifacts arrays, and empty text string.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds comprehensive Go test coverage for the pure canvas-grid layout helpers
in org.go. Mirrors the TypeScript tests in canvas-topology-pure.test.ts
(CHILD_DEFAULT_WIDTH=210/HEIGHT=120 vs Go's 240/130, tested independently).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors PR#680's OFFSEC-001 contract hardening from the commit-memory
path to the recall-memory path (issue #681).
Before: only asserted resp.Error != nil — a future regression that
returned the raw err.Error() would still pass the test.
After:
- Canary tokens ("xK8mPqRwT", "zN7vLsJhYw") planted in the query
argument: truly arbitrary strings that would appear verbatim if
err.Error() were returned directly. Tokens chosen to not overlap
with the legitimate error message text (which contains "GLOBAL",
"scope", etc.) — which would always appear and make them useless
as sentinels.
- Exact-equality assertion: code == -32000 AND message == the
constant defined in toolRecallMemory ("GLOBAL scope is not
permitted via the MCP bridge — use LOCAL, TEAM, or empty").
- Defence-in-depth strings.Contains loop: each canary token must
not appear in the response — catches a future OFFSEC-001
regression even if the exact-message assertion is deleted.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
React error #185 (Maximum update depth exceeded) on mobile chat tab.
Root cause: useCanvasStore((s) => s.agentMessages[agentId] ?? []) used
a `?? []` fallback in the selector. Zustand uses Object.is for selector
equality. When agentMessages[agentId] is undefined (initial state), the
fallback creates a NEW [] reference on every store update. Zustand sees
this as a state change and re-renders the component. The component reads
from the store again, gets another new [] reference, and the cycle
repeats until React hits the depth cap.
Fix: remove `?? []` from the selector (returns undefined when no messages)
and move the fallback to the useState initializer:
storedMessages = useCanvasStore(selector) // returns undefined | T[]
[messages] = useState(() => (storedMessages ?? []).map(...))
The useState initializer only runs once on mount, so the `?? []`
there is safe — it creates the initial state once, then messages are
managed via setMessages.
Fixes issue #651.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implements the Claude Design handoff (Molecules AI Mobile.html) as a
viewport-gated React tree under canvas/src/components/mobile/. < 640px
renders the new shell instead of the desktop ReactFlow canvas.
Six screens, all bound to live store data:
- Home (agent list + filter chips + spawn FAB)
- Canvas (mini-graph with pinch-to-zoom + pan + reset)
- Detail (status pills, tabs: Overview / Activity / Config / Memory;
Activity hits /workspaces/:id/activity)
- Chat (textarea composer, IME-safe Enter, sendInFlightRef guard;
bootstraps from agentMessages so the prior thread shows on entry)
- Comms (live A2A feed via /workspaces/:id/activity + ACTIVITY_LOGGED)
- Spawn (bottom sheet; fetches /templates so users pick what's actually
installed on their platform)
Plus a Me tab for mobile theme/accent/density.
Design system (palette.ts + primitives.tsx) ports tokens 1:1 from the
handoff: cream + dark palettes, T1-T4 tier chips, status dots with
halo, JetBrains Mono for IDs/timestamps. Inter + JetBrains Mono are
self-hosted via next/font/google so CSP `font-src 'self'` is honoured.
URL routing: routes sync to ?m=<route>&a=<id>; popstate restores route;
deep links seed initial state. /?m=detail without ?a collapses to home.
Accent override flows through React context (MobileAccentProvider) —
not by mutating the static MOL_LIGHT/MOL_DARK singletons.
SSR flash: isMobile is tri-state; loading spinner stays up until
matchMedia resolves so mobile devices never paint the desktop tree.
Desktop responsiveness fixes (separate but ride along):
- Toolbar: full-width with overflow-x-auto on mobile, logo text + count
hidden < sm, divider/border collapse to sm: only.
- SidePanel: full-screen on mobile via matchMedia, resize handle hidden.
- Canvas: MiniMap hidden < sm (was overlapping the New Workspace FAB).
Tests (51 total, 33 new):
- palette.test.ts (12) - normalizeStatus, tierCode, light/dark parity
- components.test.ts (10) - toMobileAgent field mapping + classifyForFilter
- MobileApp.test.tsx (12) - route stack, deep links, popstate, tab bar
hidden on chat, spawn overlay
- SidePanel.tabs.test.tsx (18) - regression-clean
Verified: tsc --noEmit clean across mobile/, page.tsx, layout.tsx.
Not yet verified: live phone browser (needs CP backend hydrated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tests canvas/src/lib/hydrate.ts: hydrateCanvas() with exponential backoff retry.
Cases:
1. Success on first attempt → { error: null }
2. Viewport fetch failure is non-fatal → store still hydrates
3. Success after 1 retry → onRetrying(1) called once, result { error: null }
4. onRetrying called correctly on each failed attempt
5. All attempts fail → error message after MAX_RETRIES
6. onRetrying called MAX_RETRIES-1 times before final exhausted attempt
7. Total elapsed time ≈ sum of exponential delays (1s + 2s = 3s)
Each attempt makes 2 parallel api.get calls (workspaces + viewport); mocks
set up per parallel-call to avoid Promise.all consuming wrong mock slots.
Issue: #701
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Line 443 of mcp.go concatenated user-controlled req.Method into the
JSON-RPC -32601 error message, allowing an agent or canvas client to
inject arbitrary strings into the response via the method field.
Fix: replace "method not found: " + req.Method with the constant
"method not found" — matching the OFFSEC-001 scrub contract applied
to the InvalidParams (line 428) and UnknownTool (line 433) paths.
Test: extend TestMCPHandler_UnknownMethod_Returns32601 with two new
assertions:
1. resp.Error.Message == "method not found"
2. defence-in-depth check that the sent method name never appears
in the response (strings.Contains guard)
Issue: #684
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two bugs in the test suite for SearchDialog.tsx:
1. Zustand-compatible mock: the old vi.fn-only mock updated
mockStoreState.searchOpen directly without notifying Zustand's
useSyncExternalStore subscriber, so the Cmd+K test opened the
dialog but the component never re-rendered (body stayed <div />).
Fix: add subscribe() + getState() to the mock so React flushes
the re-render when setSearchOpen fires. Also add act() wrapper
around the keydown event for additional safety.
2. Stale React state: fireEvent.change did not reliably flush the
onChange → query state update before ArrowDown fired, causing the
component to read stale filtered/nodes state. Fix: manually set
input.value, fire onChange inside act(), then call rerender() to
force the component to see the new query before keyboard events.
Affected tests:
- "clears the query when Cmd+K opens the dialog" (was: body=<div />)
- "Enter selects the highlighted workspace" (was: selected n2 not n1)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>