.agents/skills/agent-testing/references/probe-mock-patterns.md
Purpose: the accumulated, verified recipes for probing app/runtime state and mocking / faulting network in agent-testing. Every time a probe or a mock is blocked, bypassed, or needs a workaround during a run, add an item here recording what does NOT work and what DOES. Read this before any run that needs to force an error state or inspect runtime state. Each item: Situation / Doesn't work / Works.
Separate the observation from the explanation. A full audit of this file (source read + live re-run against
agent-browser 0.26.0and the dev server) found that several items' observations were real but their stated mechanism was invented after the fact — a symbol that never existed (getRetryScopeId), an error code the code rejects (interrupted), aPATHclobber the script cannot perform. Two "doesn't work" items (A1, A2) turned out to work fine; the original runs had measured the wrong signal. A wrong mechanism is worse than none: it sends the next reader to fix the wrong thing. When you add an item, citefile:linefor any mechanism claim, and if you only saw a symptom, write "cause not established" instead of guessing.
network route --abort DOES intercept this app's TRPC — the old note measured the wrong thingRe-tested end-to-end against the running dev server. The previous claim ("blocks nothing — 815 requests still returned 200") was wrong three times over:
**/trpc/** matches nothing. Only an absolute-URL prefix with
a trailing * matched reliably; **/x, **/* and */trpc/* all failed on a nested path.
Use route "<origin><path-prefix>*" --abort — the trailing * is required to cover TRPC's
?input=… query string.route "http://localhost:<port>/trpc/lambda/user.getUserState*" --abort installed, the
fetch really is killed — yet the store action still resolves:
with route: fetchOutcomes ["REJECTED_Failed to fetch"] storeActionOk 1 storeActionErr null
no route: fetchOutcomes ["resolved_200","resolved_200"] storeActionOk 1
window.fetch and
record each request's own outcome (snippet in A2).network requests under-reports. In a window where the page-realm wrapper counted
thousands of fetches (one of them TRPC), agent-browser network requests listed 1 request
and 0 TRPC. It is not ground truth for "did the request happen".window.fetch override DOES see this app's TRPCfetch reference at client-creation".
It does not: packages/trpc/src/client/lambda.ts:104-108 passes a wrapper that calls the
ambient fetch per request (consumed by httpBatchLink/httpLink, lines 148-149), so
a later window.fetch = … reassigns exactly the binding it resolves.window.__LOBE_STORES.user().refreshUserState(), and the override sees the TRPC request.
agent-browser eval shares the page realm — confirmed separately on a static page whose own
script calls ambient fetch (REAL_FETCH_200 before the override, THREW_OVERRIDDEN after).if (!window.__W) {
window.__W = 1;
window.__R = [];
const of = window.fetch;
window.fetch = function (...a) {
const p = of.apply(this, a);
if (String(a[0]).includes('/trpc/'))
p.then(
(r) => window.__R.push('resolved_' + r.status),
(e) => window.__R.push('REJECTED_' + e.message),
);
return p;
};
}
__W guard matters: re-running the snippet in a second eval wraps the wrapper and
the next call dies with Maximum call stack size exceeded.agent-browser at the Debug Proxy URL instead of the local origin, the SPA
runs in an iframe and a top-frame override lands in a different realm. That — not the client
— is what a genuine bypass would look like.set offline shows Chrome's page, not the component erroragent-browser set offline on trips
Chrome's document-level offline interstitial ("Reconnect to Wi-Fi"), not the
app's AsyncError. Its "Reload" button also false-matches error-copy greps.
(Offline also breaks lazy route-chunk loading → hard-nav fallback → Chrome page.)set offline on + reload): document.body.innerText comes back as
按空格键即可开始游戏 … 重新连接到 Wi-Fi 网络 … ERR_INTERNET_DISCONNECTED. The dino-game
and Wi-Fi copy are exactly the kind of text a naive error-copy grep matches.git checkout -- to revert:
// src/services/task.ts (the method useFetchTaskList's fetcher calls)
list = async (params) => {
// [AGENT-TEST] REMOVE
if (true) throw Object.assign(new Error('injected'), { data: { httpStatus: 500 } });
return lambdaClient.task.list.query(params);
};
data.httpStatus: 500 → the error is retryable, so AsyncError shows the
Retry button AND the status-specific copy (response.500). Use a status the
UI treats as retryable if you want the Retry button visible.git checkout -- <service files>; grep AGENT-TEST to confirm no
residue.Network.setBlockedURLs — blocks at the network stack (below fetch, so it
DOES catch TRPC). Needs a raw CDP ws (see C2 for getting the endpoint).AutoSaveHint failed tag + saveToast),
inject the throw into the client service mutation method, not a fetcher:
// src/services/task.ts — the method the store action calls (updateTask → taskService.update)
update = async (id, data) => {
// [AGENT-TEST] REMOVE
if (true)
throw Object.assign(new Error('injected save failure'), { data: { httpStatus: 500 } });
return lambdaClient.task.update.mutate({ id, ...data });
};
runMutation
(src/store/utils/runMutation.ts:82-88) calls setStatus('failed') then onError; the task
detail action supplies internal_setTaskSaveStatus, so the state lands in
taskSaveStatusMap[id] (a per-task map, not a scalar taskSaveStatus) and saveToast
fires. Retryability comes from normalizeAsyncError, whose only non-retryable statuses are
401 and 403 (src/libs/swr/normalizeError.ts:26) plus an explicit
meta.shouldRetry === false. So httpStatus:500 ⇒ retryable ⇒ the toast shows a Retry
action; 401/403 suppress it.git checkout --
on a service file makes Vite do a full reload (not just HMR), so the SPA resets to
a blank Main Layout (Case 1 trap: a blank shell with only the Debug ID tag).
After reverting, re-navigate (agent-browser open .../task/<id>) and re-fetch
element refs before continuing the recovery test.overloaded guide card), no network fault or real agent run is
needed — inject the error straight into the chat store, in-memory, no DB write:
// agent-browser --cdp <port> eval --stdin
var c = window.__LOBE_STORES.chat();
var id = 'tmp_probe';
// 1. create a temp assistant message (in the ACTIVE conversation)
c.internal_dispatchMessage({
id,
type: 'createMessage',
value: { role: 'assistant', content: 'partial work UNIQUE_MARKER', provider: 'claude-code' },
});
// 2. attach the error whose `body.code` drives the card
c.internal_dispatchMessage({
id,
type: 'updateMessage',
value: {
error: {
type: 'AgentRuntimeError',
message: 'Overloaded',
body: {
agentType: 'claude-code',
code: 'overloaded',
message: 'Overloaded',
},
},
},
});
internal_dispatchMessage({ id, type: 'deleteMessage' }).code must be one of four values, or you get no guide card.
isHeterogeneousAgentStatusGuideError (src/features/Conversation/Error/heterogeneous.ts:13-31)
requires agentType ∈ {claude-code, codex} and code ∈ {auth_required,
cli_not_found, overloaded, rate_limit}. Anything else falls through to the generic
error path. The switch
(src/features/Electron/HeterogeneousAgent/StatusGuide/index.tsx:28-44) maps AuthRequired →
AuthRequiredState, RateLimit → RateLimitState, Overloaded → OverloadedState,
default → CliInstallState. There is no InterruptedState, and interrupted is not a
HeterogeneousAgentSessionErrorCode at all (it is a completionReason / run-status value
elsewhere) — an earlier version of this note used it as the example, and it would have
rendered nothing of the sort.internal_dispatchMessage is the optimistic
in-memory path (same as optimisticCreateTmpMessage). Nothing hits the DB, and a
reload clears it. Safe even against a real account's synced data — do it in an
isolated electron-dev.sh start <id> instance (copied login) to be extra safe.src/features/Conversation/Error/index.tsx:260-262 computes
retryScopeId ?? getDisplayMessageById(data.id)?.parentId, and useHeterogeneousAutoRetry
gates on !!scopeId. A tmp message has no parentId, so the scope is undefined → the card
renders in its manual state (retry button, no countdown) and does not auto-fire
(won't spawn a real CLI). (There is no getRetryScopeId helper — an earlier version of this
note invented one. The mechanism is a plain parentId lookup, not an ancestor walk.) To
exercise the countdown live you'd need a real user→assistant chain; otherwise cover it with
the component RTL test.messagesMap landing is unreliable to assert; the render is the truth.
internal_dispatchMessage (no explicit context) targets the active conversation and
the message may not show up where a naive Object.keys(messagesMap) scan looks, yet
it still renders. Verify by the DOM, not by the store map.document.body.innerText keyword match false-positives in long chats.
The conversation's own text (and the right-hand diff/review panel) frequently contains
your assert strings (e.g. 连接中断, 重试, Retry). Match on a unique injected
marker instead, scrollIntoView its node, then open the screenshot with Read to
confirm the card (Case 1 rule). Find + scroll to the node:
var w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null),
n,
hit;
while ((n = w.nextNode())) {
if (n.nodeValue.indexOf('UNIQUE_MARKER') >= 0) {
hit = n;
break;
}
}
var el = hit.parentElement;
for (var i = 0; i < 6 && el.parentElement; i++) el = el.parentElement;
el.scrollIntoView({ block: 'center' });
loadMoreError inline retry row. The
IntersectionObserver won't fire because the seed data is a short list that fits
the viewport without scrolling (virtua renders no scrollable overflow), so
scrollTop = scrollHeight scrolls nothing and loadMore is never called.scrollHeight <= clientHeight), and virtua's sentinel never
intersects.AgentTopicManager PAGE_SIZE = 30 → 2,
then an agent with 3 topics loads page-1 = 2, hasMore = true.catch): window.__LOBE_STORES.<store> is a bound hook — CALL it to
get live state + actions (.getState/.setState are NOT exposed, C1).
// agent-browser --session <s> eval
var c = window.__LOBE_STORES.chat();
await c.loadMoreAgentTopicsView(); // hits the injected getTopics(current>0) throw
// → real catch sets agentTopicsViewMap[key].loadMoreError → inline AsyncError row renders
params.current > 0 so page-1 loads
and page-2 fails) with a call counter to prove the observer gate does NOT loop:
(globalThis).__loadMoreCalls = (…||0)+1 inside the throw; after the failure, wait a
few seconds and assert window.__loadMoreCalls stays 1 (no runaway re-trigger).!loadMoreError gate under real scroll (the gate
lives in the component's IntersectionObserver callback). To exercise the gate live you
need a real scrollable list — seed > PAGE_SIZE visible-source topics on one agent.来源: 对话) by default. The store's default
filter is triggers: ['chat'] (src/features/AgentTopicManager/store.ts:49-51; the code calls
it the trigger filter, the UI labels it 来源 /source), so topics with a non-chat trigger
show 0 even though the agent owns them in the DB; click 清空筛选 / Clear filters (or
setStatus('all') + clear) to reveal them.[]). On a later failed fetch, AsyncBoundary correctly keeps the
settled content and does NOT show the error (by design: background error must
not blow away loaded content). So your injected failure shows the old content,
not the error.AsyncBoundary itself, not the persisted cache.
src/components/AsyncBoundary/index.tsx:76,93 gate the error branch on
if (error && !hasSettled) where hasSettled = data !== undefined. The persisted cache's
only role is making data non-undefined on first mount.refreshUserState() still resolved successfully — the rejection never surfaced
to the caller. Assume a failure you inject is invisible until you prove otherwise at the
fetch itself.// agent-browser eval --stdin
(async () => {
try {
localStorage.clear();
sessionStorage.clear();
} catch {}
try {
const d = await indexedDB.databases();
await Promise.all(
(d || []).map(
(x) =>
new Promise((r) => {
const q = indexedDB.deleteDatabase(x.name);
q.onsuccess = q.onerror = q.onblocked = () => r();
}),
),
);
} catch {}
try {
const k = await caches.keys();
await Promise.all(k.map((c) => caches.delete(c)));
} catch {}
return 1;
})();
setup-auth.sh web-seed, then open about:blank → open <target>.useClientDataSWR retries failed fetches with exponential backoff.
During retries isLoading is true → you screenshot a skeleton, not the error.errorRetryCount prop. src/libs/swr/index.ts:31-45 supplies a
custom onErrorRetry that bails on retryCount >= 5, with
delay = min(1000 * 2^retryCount, 30_000). SWR pre-increments the counter before calling the
handler (swr/dist/index/index.mjs:493 → (opts.retryCount || 0) + 1), so the handler sees
1..5 and schedules 4 retries at 2s + 4s + 8s + 16s = ~30s cumulative. (The old
"5× / ~31s" reading assumed the counter starts at 0.)meta.shouldRetry=false to skip retries — but that also hides the Retry
button, because normalizeAsyncError sets retryable = false for it.)open; persisted cache survives everythingopen about:blank then open <target> forces a fresh JS context (clears
in-memory SWR) but does NOT clear persisted storage — you still need B1 for a
true cold load.eval reads back undefined after the round-trip while
localStorage survives. Set the marker from eval, never from the page's own script — an
inline script re-runs on the second open and re-creates it, which reads as "the context
was preserved".src/libs/swr/localStorageProvider.ts):
IndexedDB for the big collections and localStorage for small shells. That is why B1's
cold-load recipe clears localStorage, sessionStorage, IndexedDB and the Cache API.window.__LOBE_STORES.<name> has no .getState — CALL it insteadwindow.__LOBE_STORES.page.getState(). The exposed value is neither the
store nor the hook: src/store/middleware/expose.ts:11 assigns
window.__LOBE_STORES[name] = () => store.getState(), a plain arrow function with no
.getState property.window.__LOBE_STORES.page() returns the state snapshot, actions
included. An earlier version of this note said "state isn't readable from it", which was
wrong and contradicted C1b.setState via HMR to drive a REAL identity/scope change (login repro)useCacheScope = ${userId}:${workspaceId}) on the ALREADY-LOADED app, without a
real OAuth flow. Cold-boot repro is timing-flaky under machine load; the faithful,
deterministic path is to flip userId on the live app.window.__LOBE_STORES.user() returns getState() (actions but no
setState); there is no public setUserId action to call.src/store/middleware/expose.ts (HMR) to also
attach setState, then drive the store from eval:
// expose.ts, temporary — REMOVE after: git checkout -- src/store/middleware/expose.ts
const handle = () => store.getState();
(handle as any).setState = (store as any).setState;
window.__LOBE_STORES[name] = handle as any;
expose() only runs at store creation, so reload the renderer once after the edit so
stores re-expose with the handle. Then:
var u = window.__LOBE_STORES.user;
var c = u().user || {};
u.setState({ user: Object.assign({}, c, { id: 'probe_scope_0705' }) }); // → scope flips
// inside the component render, temporary:
if (typeof window !== 'undefined')
(window as any).__DBG = {
data: data === undefined ? 'undefined' : `array[${(data as any).length}]`,
hasError: !!error,
isLoading,
};
agent-browser --session $S eval 'JSON.stringify(window.__DBG)'
documents=[] bug (data looked settled
even on error).agent-browser console means something elseagent-browser console returned both a page-script [log] line and one
emitted later from eval. It works.app-probe.sh errors is a different story: like app-probe.sh auth, it talks to the
Electron CDP endpoint on 9222, not to your agent-browser browser session. Against a web
session it fails with All CDP discovery methods failed for 127.0.0.1:9222 — which is not
"console capture is unreliable", it is "you pointed the probe at Electron".document.body.innerText keyword grep false-positives on fixture textbody.innerText.includes('保存失败'). If the test fixture's own content contains
that substring (e.g. a task literally named " 验证保存失败态的测试任务 "), the grep
matches the fixture, not the state indicator — a false PASS on the error state
even when it never rendered (a Case-1 grep trap).body.innerText:
[...document.querySelectorAll('[class*=Tag],.ant-tag')].map(t=>t.textContent.trim())buttons ([Close, 重试]).
Pick fixture names that do NOT contain any state keyword you'll assert on.screenshot honours a relative path (0.26.0) — but still pass an absolute one.
Re-tested: agent-browser screenshot rel.png saved to the caller's cwd, printed
✓ Screenshot saved to rel.png, and wrote nothing under ~/.agent-browser/tmp/screenshots/.
The old "silently ignored" claim is false. An absolute path is still the right habit — the
cwd of a step in a longer script is easy to lose track of.--remote-debugging-port=0; for raw
CDP, read DevToolsActivePort in the browser user-data-dir. Note there is no top-level
cdp-url command — it is a get subject: agent-browser get cdp-url (which does return
a ws://127.0.0.1:<port>/devtools/browser/… URL). An earlier version of this note reported
agent-browser cdp-url "returned empty"; it returned empty because it is not a command.agent-browser set offline on|off (under set, with
viewport/geo/headers), not a top-level offline command.wait --load networkidle HANGS during a retry loop (network never
idles) and can blow the command timeout — use a fixed wait <ms> instead when
a fetch is stuck retrying. Confirmed with a control: on a page polling every 300ms the
command was still blocked after 20s; on an idle page it returned rc=0 immediately. (macOS
has no timeout(1) — don't try to bound it with coreutils that aren't there.)toast lives in a portal, auto-dismisses in ~5s, and can be
occluded by the dev debug widget. The portal viewport carries aria-label="Notifications"
and base-ui's default timeout is 5000. snapshot -i does NOT reliably surface the
portal's action buttons (the 重试/Retry ref came back empty); read the toast
via eval DOM query instead. The occluding widget is the DevPanel float button
(src/features/DevPanel/features/FloatPanel.tsx, fixed bottom-right, desktop/Electron only —
the SPA disables it). There is no FPS meter in this app, despite what an earlier version of
this note said. Relocate the toast region for a clean shot:
[...document.querySelectorAll('[aria-label=Notifications]')].forEach((r) => {
r.style.cssText +=
';position:fixed!important;top:100px!important;left:360px!important;right:auto!important;bottom:auto!important;z-index:2147483647!important;';
});
button.click() in eval right after re-triggering, or extend the toast
duration.div[role=button], NOT <button>. The per-message action bar
(SingletonMessageActionsBar) is one portal that moves via DOM + a
freeze/commit MutationObserver, so it appears only while hovering and
re-hides on the next commit tick. Gotchas that wasted a run:
host.querySelectorAll('button') returns 0 — the ActionIcon items render as
<div role="button">. Query [role="button"] (or the broad
button,[role=button],[class*=ActionIcon]).S="--session s9224 --cdp 9224"
agent-browser $S hover "#<messageId>" # ChatItem root id = message id
sleep 1.5
# click the LAST role=button in the host = the "…" overflow trigger
agent-browser $S eval '(function(){var h=document.querySelector("[data-singleton-message-action-bar-host]");var b=h&&h.querySelectorAll("[role=button]");if(!b||!b.length)return "no-bar";b[b.length-1].click();return "clicked";})()'
sleep 1
agent-browser $S screenshot /abs/menu.png
agent-browser $S eval '(function(){var t=[];document.querySelectorAll("[role=menuitem],li").forEach(function(i){var s=(i.innerText||"").trim();if(s)t.push(s);});return JSON.stringify(Array.from(new Set(t)));})()'
.click() on the ellipsis DOES open the antd dropdown (it sets
data-popup-open, which also freezes the bar so it won't vanish). The action
labels are localized (分享/多选/删除 = share/select/del).getGroupLatestMessageWithoutTools returns undefined,
the !contentId action-bar path): send a single long tool call (e.g.
用 Bash 工具运行 sleep 20) and, the moment the group's last child is a running
tool, call stopGenerateMessage() in the SAME eval to avoid the race where CC
appends a trailing text summary (which would give it a text last-block and a
defined contentId). Poll-then-stop-atomically:
agent-browser $S eval '(function(){var c=window.__LOBE_STORES.chat();var t=c.activeTopicId;var a=c.messagesMap["main_"+c.activeAgentId+"_"+t]||[];var g=a.filter(m=>m.role==="assistantGroup").pop();var lc=g&&g.children&&g.children.at(-1);var running=Object.values(c.operations||{}).some(o=>o.status==="running");if(lc&&(lc.tools||[]).length&&running){c.stopGenerateMessage();return "STOPPED";}return "wait";})()'
agent-browser screenshot can WEDGE the daemon; eval/get still work.
agent-browser is a daemon with one socket per session, ~/.agent-browser/<session>.sock
(verified: two live sessions ⇒ two sockets; default.sock exists only while the default
session runs). Sessions coexist, so a wedge is scoped to one session, not the whole tool.
Commands on a given socket are serialized. A screenshot RPC that is interrupted — a
mis-invoked flag, a command the harness auto-backgrounds then kills, --full on a giant
page — leaves the socket half-consumed, and every later screenshot fails with
Resource temporarily unavailable (os error 35) / CDP response channel closed
while eval/get url keep working. Not a display-sleep or permission issue.
screenshot left the session healthy (eval, get url, and a follow-up screenshot all
succeeded). Real, but rarer than the note implies — don't assume a wedge without seeing
the error string above.agent-browser close --all (respawns the daemon), or
skip the daemon entirely (D9).scripts/cdp-screenshot.sh [--port 9222] [--out x.png] [--full] [--check]
opens its own ws to the target, does one Page.captureScreenshot, closes
(~60ms). Immune to the D8 wedge, and verified robust when the display is
ASLEEP and when the window is MINIMIZED/occluded (Chromium forces a compositor
frame). Use it for Electron evidence and as a preflight (--check → exit 0 iff a
real, non-black frame was captured). Needs repo node_modules/ws (resolved via
NODE_PATH by the wrapper).screencapture is BLACK when the display is asleep/locked/screensaver.
Distinct from D8/D9: screencapture (and capture-app-window.sh, osascript
grabs) captures the physical framebuffer, so an idle-slept display → a uniformly
black PNG (mean/max=0; a full-screen black frame has a telltale identical byte
size). Permission can be fine. Gate with scripts/check-screen-recording.sh
(checks CGPreflightScreenCaptureAccess + a real-frame blackness probe) and keep
the display awake for the whole run: caffeinate -dimsu & (or caffeinate -u
to wake it). CDP capture (D9) does not have this problem.eval "$(init-dev-env.sh env)" "can clobber PATH". It cannot: the env
subcommand prints only the keys in env_keys() (init-dev-env.sh:178-204), and the string
PATH does not appear anywhere in the script. It also told you to hardcode
http://localhost:20874; SERVER_PORT is randomly allocated per workspace in
20000-40000 (falling back to 3010) and persisted to .records/env/agent-testing-ports.env,
so 20874 was one machine's old port (this workspace is on 21912) and it appears nowhere
else in the repo. Works: read the port from that file, or export just the one var —
export APP_URL="$(init-dev-env.sh env | sed -n 's/^APP_URL=//p')". Whatever broke
awk/head in the original run was never diagnosed.SPA_PORT||9876; Next proxies VITE_DEV_PORT||9876. Pass SPA_PORT=9877 to
init-dev-env.sh dev (it exports VITE_DEV_PORT=$SPA_PORT) so both agree and
you don't fight a worktree already on 9876.Original claim: after setup-auth.sh web-seed, a hard open <app>/agent/<id>/docs
redirects to /signin?callbackUrl=... while / stays authed.
Did not reproduce against the current dev server with a seeded web session
(isSignedIn: true, userId: user_agent_testing_001). Hard-loading /settings/common and
the note's exact shape /agent/<real-id>/docs both landed on the route itself, with no
/signin and no callbackUrl. Either it was fixed, or the original run's cookie state
differed. Don't budget for the bounce; check for it.
Still true and still useful: app-probe.sh auth "false-negatives" here because it talks
to the Electron CDP endpoint on 9222, not to your web browser session — it fails with
All CDP discovery methods failed for 127.0.0.1:9222. Read the auth state out of the page
instead: window.__LOBE_STORES.user() → { isSignedIn, user.id }.
Works: hard-load / (authed), confirm by screenshot (not the app-probe auth
JSON — it false-negatives here, returns isSignedIn:false on an authed page),
then client-side soft-nav with no server round-trip:
agent-browser eval "history.pushState({},'','/agent/<id>/docs'); window.dispatchEvent(new PopStateEvent('popstate')); 'nav'" --session lobehub-dev
react-router picks up the popstate and renders the route in-context with the
already-hydrated auth. Right-panels that render at a layout level do NOT see a
child route's :param via useParams() — read it from location.pathname if
you need it.
D11. ✅ WORKS — catch a BRIEF blank/transient frame (sub-second) that screencast misses.
Verifying a momentary full-screen blank (e.g. a React subtree unmounting to null for
~150–350ms during a scope change): Page.startScreencast emits one frame when it starts and
then only on a VISUAL CHANGE, so a static blank produces no further frames — you see a time
GAP in the manifest, not a blank image. (Measured: 3s of a static page → 1 frame, the
initial one; 6 background flips → 6 frames. The old note said "NO frame", which misses
the initial one and can look like the screencast never started.)
Single timed cdp-screenshot also loses the race (its own ~200ms latency
overshoots) and captureScreenshot can return the prior surface.
document.getElementById('root').innerText.trim().length
at 150/350/600ms after the trigger via one eval returning a Promise. A full unmount
drops it to 0, then it recovers — unambiguous, load-independent. (Fixed vs broken:
6064 → 0 → 5646 vs 6089 → 6002 → 6042 never-0.)userId to a fresh value each tick so a
key={scope} gate stays remounted), while firing raw CDP Page.captureScreenshot every
80ms over the same window. Blank frames come back tiny (~34KB jpeg) vs content (~294KB);
convert + Read one to confirm. (A raw-CDP forced capture DOES render a static frame; the
trick is holding the state, not the capture.)LOBE_IPC_ID shortLOBE_IPC_ID such as lobehub-desktop-dev-manual-selection-2.
The main process builds a Unix socket path under $TMPDIR, and macOS rejects
overlong socket paths.listen EINVAL ... <id>-electron-ipc.sock, before any renderer/CDP evidence is
available.electron-dev.sh start <id> pool path, or keep
manual IPC ids very short, e.g. LOBE_IPC_ID=lhmsel2.init-dev-env.sh setup-db needs Docker (paradedb + redis images), but
Docker Desktop's VM can wedge indefinitely (no route to host to 192.168.65.x, empty
vm/console.log). Verified working substitute:
0090_enable_pg_search / 0093_add_bm25_indexes_with_icu — no-op them
in the worktree (SELECT 1;), everything else applies clean.[Better Auth]: Error: Connection is closed). brew install redis,
redis-server --port 6380 --daemonize yes.s3rver (npm) on 29000 with a CORS config for the bucket. Its presigned-URL
validation only accepts key id S3RVER (secret S3RVER) — allowMismatchedSignatures
does NOT rescue an unknown access key id (403 on the browser preflight). Set the dev
server's S3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEY=S3RVER, S3_ENABLE_PATH_STYLE=1,
S3_PUBLIC_DOMAIN=http://127.0.0.1:29000/<bucket>.pnpm install, not a symlinked node_modulesgit worktree (e.g. .claude/worktrees/<name>), which
starts with no node_modules.node_modules (root and/or apps/desktop) into the
worktree. The workspace links inside it point @lobechat/* at the MAIN checkout's packages/, which
sits on a different branch — the Electron main build then dies with
[MISSING_EXPORT] "X" is not exported by "../../packages/<pkg>/src/...". Renderer source resolves from
the worktree while packages resolve from the main repo, so the two disagree.pnpm install at the worktree root AND cd apps/desktop && pnpm install (standalone,
not in the workspace). ~5 min total, mostly hardlinked from the store.electron-dev.sh start <id> is REUSED by
the retry (CDP already reachable … Skipping start) and can keep serving pre-edit module text. If the
DOM shows markup that contradicts the source, curl the dev server for the file and grep for a token you
just added (curl -s 127.0.0.1:<vitePort>/src/path/File.tsx | grep -c myNewSymbol) before blaming the
code. electron-dev.sh stop <id> then start again to get a clean server.#root for 1–3 minuteselectron-dev.sh start <id>, app-probe.sh auth returns isSignedIn:false,
#root has providers but innerText.length === 0, and the screenshot is just the LobeHub splash. The
main log shows Proactive token refresh failed / invalid_grant.RENDERER_WAIT_S (60s) can expire while Vite is still on
[optimizer] bundling dependencies, which ends in optimized dependencies changed. reloading.until [ "$(agent-browser --session s \
'(function(){return String(document.getElementById("root").innerText.trim().length>50)})()' < port > --cdp < port > eval \
| tr -d '"')" = "true" ]; do sleep 5; done
zsh does NOT word-split unquoted vars — S="--session x --cdp 9226"; agent-browser $S eval
fails with Unknown command. Inline the flags or use an array.history.pushState + popstate
lands correctly, then ~15–20s later location.pathname snaps back to whatever tab the main process
has stored (e.g. /devtools/claude-code). A hard location.href nav is reverted the same way.navigate, and src/features/DesktopNavigationBridge obeys it.// src/features/DesktopNavigationBridge/index.tsx — [AGENT-TEST] REMOVE
useWatchBroadcast('navigate', () => {
void handleNavigate;
});
git checkout -- src/features/DesktopNavigationBridge/index.tsx afterwards; grep -rn AGENT-TEST src/
must come back empty.commit() persists to agencyConfig.workingDirByDevice[deviceId]:
const a = window.__LOBE_STORES.agent(),
d = window.__LOBE_STORES.device();
const did = window.__LOBE_STORES.electron().gatewayDeviceInfo.deviceId;
const entry = { path: '/abs/repo', repoType: 'git' };
await a.updateAgentConfigById(agentId, {
agencyConfig: { workingDirByDevice: { [did]: entry } },
});
await d.updateDeviceCwd(did, entry, { setDefault: false }); // so the entry exists → sourcePath resolves
agent().createAgent({ title }) and delete it afterwards with
session().removeSession(agentId) (there is no deleteAgent on the agent store) plus
device().removeDeviceWorkingDir(did, path) — otherwise the fixture cwd lingers in the real account.svg.lucide.lucide-git-fork. Several git icons live in the
same bar (the dir picker's DirIcon is git-branch, the review toggle is git-compare), so scope the
assertion to the trigger: document.querySelector('[role=button][aria-label="Worktrees"]') svg, and
still open the PNG to confirm (Case 1).next dev (Turbopack) with Build Error Resource path "worker/browser/createWorker.ts" needs to be on project filesystem (chain: layout →
GlobalProvider/Query → trpc client → image/chat store → python-interpreter worker).
Unauthenticated curl 302s BEFORE the client graph compiles, so it false-passes; a fresh
no-lockfile pnpm install can resolve a broken plugin version.E2E=1 (or TEST=1) — defineConfig's isTest skips codeInspectorPlugin,
the only thing it gates. Webpack mode (next dev --webpack) is NOT a viable fallback
(react version mismatch when two next versions are hoisted; zlib-sync unresolved for
discord.js).open queues behind a record-gif looprecord-gif.sh (a screenshot loop) running while you issue
agent-browser open <url> — the daemon socket serializes, the open lands late/out of
order, and your "during navigation" screenshot actually shows the PREVIOUS page (which can
look identical to the expected end state — false read).agent-browser eval 'location.href="<url>"' (fire-and-forget) instead of open. To hold
a streaming loading state on screen, inject a server-side await sleep(8000) before the
slow lookup in the page ([AGENT-TEST], revert) — TTFB stays ~0.3s so loading.tsx renders
while the route hangs, giving a wide static-capture window.NO_PROXY omits localhostcurl --proxy http://127.0.0.1:7890 …) and you get the proxy's 502, not
connection-refused — a "server up but broken" mirage.HTTP_PROXY/HTTPS_PROXY are set here, yet
NO_PROXY="localhost, 127.0.0.1, ::1" exempts exactly the hosts you probe locally.
Measured against a dead port: with and without --noproxy '*', both give curl exit 7 /
code=000 (connection refused). No 502 either way.curl --noproxy '*' is a harmless belt-and-braces habit, but if you actually see
a 502 from a local port, check NO_PROXY before believing this note — the proxy is only in
the path when localhost is missing from it.electron-dev.sh start <id> seeds userData from the golden dev profile,
but the app renders an empty #root (innerText length 0, only the LobeHub watermark) and
app-probe.sh auth returns isSignedIn:false. The instance log shows
Refresh response missing access_token or refresh_token { error: 'invalid_grant' }.start <id> copied the same golden profile and consumed/rotated its refresh token, so the
copy's token is already dead. A blank shell here is an AUTH failure, not a render bug —
read the instance log before chasing the SPA.LOBE_GOLDEN_PROFILE at the packaged app's profile
(~/Library/Application Support/LobeHub) — the pool instance's startup refresh will rotate
that token too and log the user out of their real desktop app.Oidc-Auth
header, and the CLI keeps one in ~/.lobehub. Extract it with the CLI's own
getValidToken() (it auto-refreshes), then override the single main-process accessor:
// apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts — [AGENT-TEST] REMOVE
async getAccessToken(): Promise<string | null> {
if (process.env.LOBE_TEST_ACCESS_TOKEN) return process.env.LOBE_TEST_ACCESS_TOKEN;
...
export LOBE_TEST_ACCESS_TOKEN=... before electron-dev.sh start <id> (the script forwards
the shell env). This authenticates BOTH the renderer (BackendProxyProtocolManager injects the
same token) and any main-process fetch, so the whole app comes up signed in. Revert with
git checkout -- + grep -rn AGENT-TEST afterwards, and never echo the token.mkdir -p /tmp/empty-golden
LOBE_GOLDEN_PROFILE=/tmp/empty-golden ./electron-dev.sh start <id>
开始 → 下一步 ×2 → 登录 LobeHub Cloud). The device-code flow opens the
browser and auto-approves against an existing app.lobehub.com session, giving the instance its
OWN token — so it never rotates the one the user's resident app holds.isUserStateInit:false
(with isLoaded:true, user:null), and the desktop first-frame gate waits on it forever. Every
route — /, /desktop-onboarding — renders empty. Reloading, soft-navving, and waiting all
fail to resolve it, so it reads exactly like a Case-1 blank page.pnpm install --ignore-scripts has NO electron binary
(node_modules/.pnpm/electron@*/node_modules/electron/dist missing). Fix with
cd apps/desktop && pnpm rebuild electron. Also remember apps/desktop and apps/cli are
NOT in the root pnpm workspace — install inside each.electron-dev.sh restart <id> keeps the userData dir, but a device-code session
did NOT survive it — budget for one more login after any restart (e.g. when a main-process
change forces one, see E9).logger.info is invisible unless DEBUG is setClaudeAgentSdkSession vs the CLI-spawn AgentStreamPipeline). Both produce identical
user-visible output, so the verdict needs a main-process log line.logger.info('Starting Claude Code SDK session:') line. In development createLogger().info routes to the debug package, which
prints nothing unless its namespace is enabled (only console.error shows up unconditionally).
Absence of the line is NOT evidence the branch didn't run.export DEBUG='controllers:*' before electron-dev.sh start <id>, then
grep -oE "controllers:HeterogeneousAgentCtr INFO: [^']*" /tmp/lobe-electron-pool/instance-<id>.log.
Don't trust the hetero tracing dir for this — it is gated and the copied golden profile ships
STALE trace sessions from months earlier that look like a fresh run.packages/heterogeneous-agents run in the main process
(JSONL framing + adapter + toStreamEvent). Editing one and reloading the
renderer verifies nothing — the old adapter is still running.# renderer: vite serves working-tree src (VITE_BASE + id)
curl -s --noproxy '*' "http://127.0.0.1:<vitePort>/src/<path>.ts" | grep -c '<marker>'
# main: rebuilt on start into the desktop dist bundle
grep -c '<marker>' apps/desktop/dist/main/index.js
record-electron-demo.sh IS real 30fps capture — but raw avfoundation timestamps are unusablerecord-gif.sh and record-app-screen.sh are
CDP-screenshot loops (capped by screenshot latency). record-electron-demo.sh is different:
ffmpeg -f avfoundation -framerate 30 (see scripts/record-electron-demo.sh:152-153) — a
true OS screen recording, fast enough for sub-second UI transitions.ffmpeg -f avfoundation -framerate 30 -i "1:" -t 14 out.mp4.
Measured on one 14s capture: 133332 frames muxed into a 0.13s file at 4.7 Gbit/s, 79 MB.
-ss <n> then fails with Output file is empty, nothing was encoded. Cause not established
(avfoundation's own PTS appear near-identical); the fix below is empirical.-use_wallclock_as_timestamps 1 on the input plus
-vf fps=20 on the output. Same 14s capture → 14.00s / 136 KB.
ffmpeg -y -use_wallclock_as_timestamps 1 -f avfoundation -framerate 30 -capture_cursor 0 \
-i "1:" -t 14 -vf "fps=20,scale=1200:-2" -c:v libx264 -crf 26 -pix_fmt yuv420p out.mp4
ffprobe may be absent even when ffmpeg is installed. Validate with
ffmpeg -v error -i out.mp4 -f null - (silence = decodes fine) and read Duration from
ffmpeg -i out.mp4 2>&1 | grep Duration.check-screen-recording.sh and hold caffeinate -dimsu for the WHOLE run — re-check right
before recording if the run has been long, since the display can sleep mid-session.animated = enableStream && transitionMode === 'fadeIn' && isGenerating, see
src/features/Conversation/Messages/useChatMarkdown.tsx:43), record it with a timestamped
debug-global (C2) in the same render: failing to film a flicker proves nothing about whether it
flickers, whereas 0ms:true → 7386ms:false pins the exact flip.about:blank with a 1280px viewport = the navigation never happenedagent-browser-klm.mjs --klm-* ... open <url> (to
record interaction atoms). The probe afterwards reports location.pathname === "blank",
document.body.innerText.length === 0, innerWidth === 1280, and a screenshot captures an
empty frame.agent-browser 0.26.0): the KLM wrapper does not blank
the page — a wrapped open navigates identically to a plain one — and open does not reset
the viewport; a viewport set beforehand survives it.open exited non-zero without navigating. The
session still exists, so every later probe succeeds — against the fresh page it was born
with: about:blank at the default 1280×720 viewport. The "reset viewport" is simply a
viewport that was never set on that page. One confirmed trigger is an unrecognized or
misplaced global flag (agent-browser --session S --headless open <url> →
Unknown command: --headless, exit 1). It is not established that this is the only
trigger: a --klm-command-timeout-ms SIGKILL landing mid-navigation, or a daemon restart,
would leave the same fingerprint. Treat the fingerprint as "the navigation failed", then read
why off the exit code and stderr rather than assuming a cause./dev/null, which is what made a loud Unknown command: … look like a silent blank page;
it now captures the output, echoes it on failure, and records a failed atom as
category: "blocked" with zeroed operators (so phantom work never enters the cost model).next dev appends a managed block to AGENTS.md — once, not every startinit-dev-env.sh dev / bun run dev prints Generated AGENTS.md for AI agents. Set agentRules: false in next.config to disable. and leaves the worktree dirty.next/dist/esm/server/lib/{start-server,app-info-log,generate-agent-files}.js): next dev
calls ensureAgentRulesForDev(dir), gated three ways — agentRules !== false in next.config
(the option is real: agentRules?: boolean, default true), detectAgent() returning non-null
(an AI coding agent is in the env), and the managed marker <!-- BEGIN:nextjs-agent-rules -->
being absent from AGENTS.md / CLAUDE.md. It then upserts the block into whichever file
exists. upsertFile compares content and reports unchanged without writing, so it is
idempotent: the block is appended once, later starts are no-ops. (Verified twice: calling
Next's own writeAgentFiles against a scratch project returns updated then unchanged,
byte-identical; and a real next dev printed the line once, after which
hasAgentRulesInstalled(repo) returns true.)git checkout -- AGENTS.md at
teardown. That strips the marker, so the very next next dev re-appends it. Reverting is what
makes it look like a per-start rewrite. The block's own text says so: "Keep this block,
including in commits. … If it appears as an uncommitted change, that is intentional — commit
it as-is. Do not remove it to clean up a diff; it will be regenerated."agentRules: false in next.config.ts. Either way it stops recurring. Still check
git status before reporting "worktree clean".grep honors .gitignore — "not found anywhere" can be a false negativegrep -rl "agentRules" . --exclude-dir=.git → one hit (a doc), concluding the option is
fictional.grep in this environment is a shell function wrapping
ugrep --ignore-files, which skips .gitignored paths. Searching . therefore silently
omits node_modules. The option existed in 26 files there.grep -rl "agentRules" node_modules), which
overrides the ignore, or call /usr/bin/grep directly. Before asserting "X exists nowhere",
re-run the search with an explicit path into the dependency tree.