.agents/acceptance/probe-mock-patterns.md
PROJECT layer — writable, LobeHub-specific. Append project learnings here during runs (each: Situation / Doesn't work / Works). The generic, product-independent recipes live in the installed skill's
references/probe-mock-patterns.md(read-only in this repo, updated by PR to@lobehub/cli) — read BOTH layers before any run that forces an error state or inspects runtime state. When an entry here turns out to be product-independent, genericize it (drop the LobeHub nouns) and PR it upstream. Keep the original discipline: citefile:linefor any mechanism claim; if you only saw a symptom, write "cause not established" rather than guess.
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.setState exposer to hydrate the list store with the exact
fixture shape, then exercise the real picker component and remove the exposer patch after
capture. Keep the report explicit that fixture hydration was test setup; validate the actual
mutation and created entity through the database or network boundary.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
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.chat().topicsMap[topicId] / chat().topicDataMap[topicId] — there is
no per-topic-id map. topicDataMap is keyed by view key (agent_<agentId>), and each
value is a paginated view object ({ items, total, hasMore, … }), not a topic.git worktree add side-effect recording):
var c = window.__LOBE_STORES.chat();
var view = c.topicDataMap['agent_' + agentId];
var topic = (view.items || []).find(function (x) {
return x.id === c.activeTopicId;
});
topic.metadata.workingDirectoryConfig; // ← e.g. git.activeWorktree written by recordGitCommandEffects
await c.openNewTopicOrSaveTopic() — with an
active topic it saves/exits to the agent's no-topic compose state (activeTopicId → null),
so the next send creates a new topic. Chain per-case fixtures this way instead of clicking
"Start New Topic". The contenteditable ref changes after the switch — re-run
snapshot -i -C before typing.heterogeneousProvider: { type: 'claude-code' }, executionTarget: 'local' in agencyConfig) + one message per case asking CC to run a
specific shell command → poll chat().operations for running === 0 → assert the
topic's metadata via the probe above. A real CC one-command turn completes in ~10–20s.task_-prefixed id or resolve() silently misses themtasks row directly in SQL for a router probe, with a
hand-written id like tsk_foo, then calling a task procedure — it 404s
("Task not found") even though the row exists and the caller is a member.task_. TaskModel.resolve()
(packages/database/src/models/task.ts:220-223) only treats the input as a row
id when it starts with task_; everything else is upper-cased and looked up as
a workspace identifier (e.g. T-1), so tsk_foo becomes the identifier
lookup TSK_FOO → null.task_<suffix>) for seeded ids, or pass
the row's identifier (T-<seq>) to the procedure instead.document.cookie, is the signalisOwner: false
even though the row's userId matched the seeded user — which reads exactly like a bug in
the ownership check.document.cookie as the auth probe. Better Auth's session cookie is
httpOnly, so document.cookie is legitimately [] on a fully authenticated page —
cookieCount: 0 proves nothing either way.setup-auth.sh web-seed's success line for the rest of the run.
It verified the session at /; the session can still be empty later (this run's page had
also drifted to about:blank at one point — see D14).401 = no session reached the server; 200 = you are really signed in.
const r = await fetch(
base +
'/trpc/lambda/user.getUserState?input=' +
encodeURIComponent(JSON.stringify({ json: {} })),
{ credentials: 'include' },
);
r.status; // 401 → re-seed; 200 → the session is live
agent-browser close --all + setup-auth.sh web-seed, then re-open the route.
After re-seeding, the same bundle returned isOwner: true with the owner-only link rendered —
the code was correct all along.{ nonce, url }) and the
consuming component guards against re-consumption with a useRef holding the last nonce.key that now varies per topic/session) resurrects the guard as
undefined, so a request that is still sitting in persisted state is re-consumed on every
remount — and, on a fresh boot, once more. The symptom looks nothing like the cause: a page the
agent had just loaded gets navigated to a URL from days ago.merge,
clearing with undefined is a no-op and the field must be set to null.null (not undefined) after the consume action; a test that
only checks "the request was acted on" passes in both the broken and fixed versions.agents.agency_config or agents.model directly in Postgres, then using
the app to drive an assertion.internal_refreshAgentConfig; IndexedDB/localStorage can retain the
previous config and make a fixture issue look like a product regression.__LOBE_STORES.agent().agentMap[id] before testing downstream behavior.agent-browser set offline on|off (under set, with
viewport/geo/headers), not a top-level offline command.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";})()'
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.)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.<webview> region BLACK intermittently — guest DOM eval is ground truth<webview> in a sidebar).
Early Page.captureScreenshot shots of the host page included the guest's pixels; minutes
later the same command on the same target returned the webview region uniformly black
(byte-identical output across retries), while the app chrome around it rendered fine.agent-browser
session following the webview target (E16) read document.title = the expected page and
full body text.title/innerText in the
webview target for the assertion, and capture the guest's own pixels via a session pinned
to the webview target (agent-browser screenshot there) when the embedded page's visual
matters. Use host-page screenshots only for the app chrome around the webview. Cause of the
black compositing not established (OOPIF surface not composited into the host capture).WebContentsView pages are their own CDP page targets — they hijack target selection, AND they are the best pool probeWebContentsViews (not renderer <webview> guests — that is E16/D15, a different shape)./json/list as its own type: page target, so both
agent-browser and scripts/cdp-screenshot.sh can silently attach to a web page the app
is hosting instead of the app itself. Measured: cdp-screenshot.sh reported
targetUrl: https://example.com/ and wrote that page's pixels while the intended evidence
was the app window; an agent-browser get url on the same port hung.url starts with
app://renderer, and evaluate against that:
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
const target = list.find((t) => t.type === 'page' && t.url.startsWith('app://renderer'));
new WebSocket(target.webSocketDebuggerUrl); // → Runtime.evaluate
page target, so /json/list filtered to non-app:// URLs is the pool's contents.
Use it to assert per-session isolation (N sessions → N coexisting pages, and a page that
should have survived an action is still listed) without adding any IPC or store probe:
curl -s --noproxy '*' http://127.0.0.1: < cdp > /json/list \
| python3 -c "import json,sys; print([t['url'] for t in json.load(sys.stdin) if t['type']=='page'])"
WebContentsView does not composite into the host
page's Page.captureScreenshot, so app-window evidence that must show the embedded page
has to come from capture-app-window.sh (macOS screencapture -l <windowid>), which does
not require bringing the window to the front.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.
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>.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).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.apps/desktop install BREAKS the root workspace's type resolution — re-run the root install after itpnpm install at the root, then
cd apps/desktop && pnpm install). Everything runs — Electron boots, the renderer serves live
code — but a full bun run type-check that passed BEFORE the desktop install now fails with
dozens of errors that have nothing to do with the change under test:
Module '"@lobechat/types"' has no exported member 'MetaData' | 'HotkeyId' | … plus
Type 'UserHotkeyConfig' is missing the following properties from type 'UserHotkeyConfig' —
the same name on both sides, i.e. two copies of @lobechat/types in one program.node_modules/@lobechat/types → ../../packages/types looks correct) and packages/types/node_modules
(its deps are all present). Both look fine while the program still sees two instances.apps/desktop is NOT in the root pnpm workspace (see E8). Its standalone install
re-resolves the packages/* links from its own lockfile and rewrites shared package deps under
packages/*/node_modules, leaving the root workspace pointing at a second instance.pnpm install at the worktree root ONE MORE TIME, after the desktop install
(~1.5 min, mostly cached). Type-check goes back to 0 errors and Electron keeps working.
So the safe order is: root install → desktop install → root install again. And never publish a
"type-check failed" verdict from a worktree until you've re-run the root install — the failure is
environmental, and the error text (a type "missing properties from" itself) is the tell.#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).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).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
<webview> guest<webview>
(in-app browser). After the guest mounts, eval on the SAME session suddenly returns the
guest page's DOM (__LOBE_STORES undefined, app selectors empty) — looks like the app broke.document.querySelectorAll('webview').length === 0 means "no webview" (you may be evaluating
INSIDE the guest).curl -s localhost:<cdp>/json/list to see targets (page = app, webview =
guest), then use separate session names per target (--session app-x re-picks the page
target; the old session keeps following the guest — handy for driving the embedded page).
Verify with get url (app:// vs the site URL) before trusting any eval result.logger.warn/debug is invisible without DEBUG env — probe with console.logcreateLogger(ns).warn/debug in development — it routes to the debug
package, which is silent unless DEBUG=<ns> was set when the process started.console.log('[AGENT-TEST] …') (always reaches the instance
log via stdout); confirm the rebuilt bundle actually contains the probe string
(grep "<probe>" apps/desktop/dist/main/index.js) before interpreting silence.local agents to the SERVER runtime — force client with disableGatewayModeexecutors: ['client']) via a real agent turn on the desktop. The agent's executionTarget is local and the tool-enable gate (isLocalSystemEnabled = runtime local) passes, so it looks like it will run client-side.execServerAgentRuntime (server/queue path) even for a local agent. The client-only tool isn't executable there — the model flails and returns a non-answer (e.g. "browser closed") with no real tool effect. Confirm the path by reading the running op's type in window.__LOBE_STORES.chat().operations (execServerAgentRuntime = server; executeToolCall = client).chatConfig.disableGatewayMode = true (via agentStore.updateAgentChatConfigById(id, { disableGatewayMode: true })) before sending. The run then goes through executeToolCall (client runtime); the composer's runtime chip flips to "Local device" and the client executor runs. The gate (isLocalSystemEnabled) and the transport (disableGatewayMode) are INDEPENDENT — enabling the tool does not force client execution.createSessionsessionStore.createSession({...}) — the desktop app doesn't use the classic session store (sessions is [], activeId is 'inbox'); agents are a server-backed model in agentStore.agentMap keyed by agt_.... The created session never becomes the active agent.model, provider, agencyConfig, relevant chatConfig), reconfigure it in place (updateAgentConfigById + updateAgentChatConfigById), run the test, then restore every field and clear any injected key-vault entry. Also: chat.sendMessage requires context: { agentId, topicId, isNew } or it throws Cannot destructure property 'agentId' of 'context'. Reads right after an updateAgent* can be stale — re-read after ~1.5s to confirm persistence..next/dev — every route 404s and / ↔ /signin ping-ponginit-dev-env.sh clean (or any kill) and a restart, the whole app is broken: / 302s to /signin, /signin 307s back to / (ERR_TOO_MANY_REDIRECTS in the browser, redirect count exceeded in the dev server's own prewarm), and even API routes like /api/auth/sign-in/email return the app-router not-found. It looks like an auth/OIDC misconfiguration..next/dev — the route manifest is gone, so every path falls through to GlobalNotFound, whose redirect collides with the middleware's.rm -rf .next then restart. Diagnose it in one step: if /signin does not return 200, the routes are not compiled — stop debugging auth./api/workflows/** (e.g. a dispatcher you want to fire on demand instead of waiting for its schedule).curl -X POST <app>/api/workflows/<...> → {"error":"Invalid signature"} / HTTP 401. The qstashAuth middleware verifies the Upstash signature whenever QSTASH_CURRENT_SIGNING_KEY is set — and init-dev-env.sh exports it, so the local env DOES verify. (Do not "fix" this by unsetting the key: you would then be testing an unauthenticated path that production doesn't have.)init-dev-env.sh qstash) and publish to the endpoint with the QStash client — QStash signs the delivery, so the handler sees exactly the production shape:
// must live INSIDE the repo (a script under /tmp cannot resolve @upstash/qstash)
import { Client } from '@upstash/qstash';
const client = new Client({ baseUrl: process.env.QSTASH_URL!, token: process.env.QSTASH_TOKEN! });
await client.publishJSON({
body: { dryRun: false },
url: `${process.env.APP_URL}/api/workflows/<path>`,
});
eval "$(init-dev-env.sh env)" && bunx tsx ./scripts/<probe>.mts, then read the outcome from DB side effects, not the HTTP body — QStash swallows the response. (A claim/lease row, a status transition, or new message rows are all observable; the handler's JSON return is not.)UPDATE ... SET metadata = jsonb_set(metadata, '{...,runAt}', '"<past ISO>"') and then fire the dispatcher. Cheaper and more deterministic than sleeping until the real due time.JWKS_KEY — every hetero agent run dies at signOperationJwt.env env fails immediately with Failed to sign operation JWT for hetero agent (apps/server/src/services/aiAgent/index.ts). Nothing in the UI explains it; the topic just fails.signOperationJwt → getSigningKey() → getJwksKey() needs the JWKS_KEY RSA JWK, and init-dev-env.sh does not export one.JWKS_KEY="$(node scripts/generate-oidc-jwk.mjs)" ./.agents/acceptance/scripts/init-dev-env.sh dev. Must be present at dev-server start (it is read from process.env), so a running server has to be restarted.JWKS_KEY set, the run proceeds and then fails at the hetero sandbox (Hetero sandbox spawn failed / unauthorized) unless the agent has a real Claude Code token. Those are two different walls — don't read the second as the first.will-attach-webview params carry NO custom attributes — identity via data-* never arrives<webview> belongs to (e.g. a per-conversation session id), and the renderer put it in a
custom data-* attribute on the element.params['data-…'] in will-attach-webview. Measured live: params
only contains the standard set (instanceId, partition, src, httpreferrer, useragent, nodeintegration, plugins, disablewebsecurity, allowpopups, preload, …). The handler silently
no-ops and — trap — a unit test that mocks params WITH the custom key passes green.partition
attribute set by the renderer (it IS forwarded); (2) identity bound after mount via an
explicit IPC — renderer listens for the webview's dom-ready, calls
attach({ sessionId, webContentsId: el.getWebContentsId() }), main process
webContents.fromId() + validates the guest's session belongs to the expected partition.apps/server/** — you are testing STALE compiled server codeapps/server/src/** (an agent-runtime
service, a tool executor, a router) against a bun run dev server that was started before the
edit. The app behaves normally, the feature simply does nothing.@/server/* maps to apps/server/src/* in
tsconfig.json (source, no dist), so it "should" recompile. It does not, at least for large
service files. Measured: a console.error added at the top of a code path that demonstrably ran
(child ops were created, the DB rows appeared) printed zero lines across four separate runs;
after a dev-server restart, the same line printed on the first run. The whole feature under test
had never executed once.apps/server/**, restart the dev server before drawing a
conclusion. If a run "should" have hit your code and didn't, prove the server is running your
code FIRST — drop a console.error on the path and restart — before debugging the code itself.source-ing an unquoted JSON env var silently corrupts it (JWKS_KEY → gateway auth_failed)JWKS_KEY={"keys":[{"kty":"RSA",...}]} on one line, then set -a; source that-file.{keys:[{kty:RSA,...}]} — invalid JSON. getJwksKey() (packages/trpc/src/utils/internalJwt.ts:13-20)
throws on JSON.parse, signUserJWT throws, and the server hands the client an empty gateway
token. The browser sends {"token":"","type":"auth"} and the gateway answers auth_failed.local-gateway-setup.sh and local-gateway-probe.mjs read
JWKS_KEY out of the file with a regex, not by sourcing it — so both are unaffected. The probe
cheerfully prints ✅ auth_success while the real browser path is broken. A green probe is NOT
evidence the app's own token works.export JWKS_KEY='{"keys":[...]}') and prove the
round-trip before starting the server:
(source env-file && node -e 'JSON.parse(process.env.JWKS_KEY); console.log("ok")')
auth_failed, hook ws.send in the page and read the token the client actually
sends — an empty string means the SERVER failed to sign, not that the gateway rejected a signature.sendMessage or type+Enter). The call resolves, no error
is thrown, activeTopicId stays null, and no agent_operations row appears. Nothing in the dev
server log — the request is never even issued.deepseek-chat) can be retired from
the model bank while the agent row still points at it.window.__LOBE_STORES.aiInfra().enabledChatModelList → [{id: provider, children: [{id: model}]}] —
and pick one from there. Also: a send that "resolves fine but creates no operation" is a UI-gate
symptom; screenshot the composer instead of re-reading your store call.seed-user dies on Cannot find module 'bcryptjs' — NODE_PATH into .pnpm fixes itinit-dev-env.sh seed-user
(which runs node <<'NODE' from the repo root) throws MODULE_NOT_FOUND for
bcryptjs, even though pnpm install succeeded.bcryptjs exists in node_modules/.pnpm/
but is not linked at the repo-root node_modules top level in that install
(pg was linked, bcryptjs wasn't), so a root-cwd stdin script can't
resolve it.NODE_PATH="$PWD/node_modules/.pnpm/bcryptjs@<ver>/node_modules" (check the
exact version dir first). CJS stdin scripts honor NODE_PATH; seeding then
completes normally.init-dev-env.sh dev ports are DYNAMIC (e.g.
next on 33803, vite on 32459) — never hardcode 3010; re-run
scripts/test-env.sh after the server is up, it reads the ports-file. And
the os error 35 agent-browser daemon wedge (D8) recovers with
agent-browser close --all + re-running setup-auth.sh web-seed.?t= values are ordinary HMR invalidation.agent_operations row will appearagent_operations for the run.messages.model, thread ids, and chat-store operations) for web;
use CLI/server execution and agent_operations for the server runtime. A change affecting both paths
needs evidence from both paths.E5. s3rver CORS allowed-origins must match the dev server port you actually use — a mismatch renders as "文档加载失败" in the evidence viewer while s3rver logs the GET as 200.
Situation: init-dev-env.sh s3 configures the bucket's CORS from the resolved ports (persisted ports file, e.g. 26938/9876). If you then run the dev server on override ports (SERVER_PORT=3010 SPA_PORT=9877), the browser's cross-origin fetch of a file evidence (presigned 127.0.0.1:29000 URL) is CORS-blocked; useTextFileLoader shows the failed state even though curl and the s3rver log both say 200.
Doesn't work: retrying in the same page after fixing CORS — SWR caches the rejected fetch for the same presigned URL; the drawer stays on the error/loading state.
Works: restart s3 with the same overrides (SERVER_PORT=3010 SPA_PORT=9877 init-dev-env.sh s3 — it logs replaced cors config for bucket), then do a full page reload before reopening the evidence drawer.
E6. The persisted ports file may belong to ANOTHER worktree's live dev server.
Situation: .records/env/agent-testing-ports.env said 26938 and a server answered there — but it was .claude/worktrees/<other>/'s instance serving that worktree's code (check ps for the listener's next dev path). Testing main-repo changes against it silently tests the wrong code, and init-dev-env.sh dev reusing the file dies with EADDRINUSE.
Works: trace the listener's cwd first (lsof -nP -iTCP:<port>, then ps -o command -p <pid>); if it is another worktree's, leave it alone and start your own instance with SERVER_PORT/SPA_PORT env overrides (they do not rewrite the ports file). Remember seed-user rewrites agent-testing-cli.env's LOBEHUB_SERVER — restore it in teardown if another session may still source it.
E7. The managed local Postgres kept its container but LOST all app data between two dev-server sessions in one day (cause not established).
Situation: round-1 fixtures (tasks, acceptances, verify runs/evidence) were verified present via psql; after pnpm install (which bumped workspace deps) and a dev-server restart, the same container answered with empty tasks/acceptances tables while verify_runs held only the new round's row. No setup-db/clean-db ran in between.
Works: treat local fixture data as disposable — recreate the subject task and re-ingest instead of debugging; assert presence with psql right before UI steps, not from memory of an earlier round.
agent_id, or the share page renders skeletons forever/share/t/<id> page (topics + messages + topic_shares
rows inserted directly). share.getSharedTopic returns fine, but the message list stays on
skeletons; message.getMessages with topicShareId returns [].agentId in the query context and MessageModel.query filters
on it — messages inserted with agent_id NULL are silently excluded (no error anywhere).agent_id on every seeded message row (matching the topic's agent_id).
Probe the endpoint directly before blaming the UI:
/trpc/lambda/message.getMessages?input={"json":{"topicId":..,"topicShareId":..,"agentId":..}}.topics.agent_id, or the client never fires the message fetchmessage.getMessages never even appears in network requests.useFetchMessages
gates on !!context.agentId && !!context.topicId
(src/store/chat/slices/message/actions/query.ts:268), and the share page passes
agentId: data.agentId ?? '' — a topic seeded without agent_id yields '' → SWR key
is null → no request, silent skeleton (a Case-1 lookalike with no error anywhere).agents row and set topics.agent_id (and messages.agent_id)
before opening the share page. Verify the fetch actually fired via
agent-browser network requests | grep getMessages, not by waiting on the UI.heteroIngest/heteroFinish directly needs an OIDC token, and bun's spawn-ENOENT message differs from node'slh hetero exec --topic <t> --operation-id <op> manually (the exact command a device daemon spawns), against a local dev
server, with the seeded CLI API key.LOBE_API_KEY. heteroAuthedProcedure requires ctx.oidcAuth
(packages/trpc/src/lambda/middleware/heteroOperationAuth.ts) — an API key never populates it, so
heteroFinish 401s. Also note the local no-.env env has no JWKS_KEY (E22), so the server
cannot even validate a JWT until restarted with one.node scripts/generate-oidc-jwk.mjs > /tmp/jwks.jsonJWKS_KEY="$(cat /tmp/jwks.json)" (must be present at start)hetero-operation JWT with the SAME key via signOperationJwt(<userId>)
(packages/trpc/src/utils/internalJwt.ts; run a small .mts inside the repo with bunx tsx),
then run the CLI with LOBEHUB_JWT=<token> LOBEHUB_SERVER=<app-url> — the CLI forwards it as
the Oidc-Auth header.
The fixture side needs topics.metadata.runningOperation = { operationId, assistantMessageId }
seeded, and the operationId must embed real ids (op_<ts>_agt_<id>_tpc_<id>_<suffix>).ENOENT: no such file or directory, posix_spawn '<cmd>' — NOT node's spawn <cmd> ENOENT. Any stderr-text pattern keyed to the node
format silently misses on bun; classification/assertions should key on the raw error's
err.code === 'ENOENT' (runtime-agnostic) and treat text matching as fallback only.HeterogeneousPersistenceHandler.finish early-returns — the message
error is written by CompletionLifecycle.completeOperation's onError branch instead
(body: messageError.body ?? { message }). Assert on messages.error, not on which writer ran.electron-dev.sh start <id> reports Ready, but CDP and the Vite port disappear
immediately after the shell command returns. The Electron log contains no crash or product error.start followed by
a short periodic wait loop in the same PTY), drive CDP from a second shell, then stop with
electron-dev.sh stop <id> and terminate the holder. Some execution harnesses reap descendants when
the command cell closes even though the launcher normally survives an interactive terminal./verify-im messenger bind SUCCESS state without a real platform tokenSuccessCard) for
Telegram/Slack/Discord. A real bind needs a live bot issuing a random_id link token —
unavailable in an isolated env.messenger_account_links row for (user, platform, tenant_id='') and (2) an enabled
system_bot_providers row for the platform — credentials must be encrypted with
KeyVaultsGateKeeper.initWithEnvKey() (same KEY_VAULTS_SECRET as the dev server), e.g.
telegram { botToken, botUsername }. Then open
/verify-im?random_id=<anything>&im_type=<platform>: the peek-token query fails, but the
existing-link lookup succeeds and the page falls through to the real success card
(shouldShowSingleAccountSuccess — existing link + no active token → success).botUsername drives the "Open in <platform>" deep-link CTA; re-encrypt the credentials
WITHOUT it to exercise the no-deep-link fallback. Note the platform config is cached
in-process for 30s (packages/app-config/src/messenger.ts CACHE_TTL_MS) — wait out the TTL
after editing the row before reloading.window.__LOBE_STORES.global().switchLocale('zh-CN') then reload.click @ref reports Done but the React onClick never fires, click via eval element.click()Situation: on a dense list row (acceptance check rows with hover-revealed ActionIcons and
expanded-area buttons), agent-browser click @ref returned ✓ Done but no TRPC mutation fired and
no state changed — the pointer click seemingly landed on an overlaying/other element. Repeated for
both hover icons and regular buttons inside the row.
Doesn't work: re-snapshotting and clicking the fresh @ref; the command still reports success
with no effect (so the failure is silent — always verify the click by its observable side effect,
e.g. the network request or a DB row, never by the driver's Done).
Works: locate the element in-page and call the DOM element.click() via eval — React's
synthetic onClick fires reliably:
agent-browser --session "(()=>{const b=[...document.querySelectorAll('button')]
.find(x=>x.textContent.trim()==='<label>'); b.click(); return 'clicked'})()" < s > eval
Scope the query to the intended row/container first (climb from a unique text node, stop before the
ancestor contains other rows' text) — a page-wide find(...) picks the FIRST match and can submit
an action against the wrong row. For drag interactions (annotation canvases), dispatch synthetic
MouseEvents (mousedown/mousemove/mouseup with bubbles:true and computed clientX/Y) on the
target element.
Situation: init-dev-env.sh dev launched from a shell where a system proxy (Clash etc.) exported
HTTP_PROXY/HTTPS_PROXY. The server booted fine, pages served, but POST /api/auth/sign-in/email
returned a bare 307 → / with NO set-cookie (body = Next __next_error__ page), the boot prewarm
logged redirect count exceeded, and setup-auth.sh web-seed failed with "sign-in succeeded but no
cookies were written" (307 matches its ^[23] success check).
Doesn't work: retrying the seed, restarting the agent-browser daemon — the failure is in the server process env, not the client.
Works: strip proxy vars when starting the dev server:
env -u HTTP_PROXY -u HTTPS_PROXY -u http_proxy -u https_proxy -u ALL_PROXY -u all_proxy \
-u NO_PROXY -u no_proxy ./.agents/acceptance/scripts/init-dev-env.sh dev
Symptom fingerprint: every auth POST answers 307 in ~25ms with application-code time present, and
the prewarm warning mentions redirect count exceeded.
lh ingest-report can silently create an ORPHAN verify run (no acceptance attach) when the branch's CLI contract is newer--subject / acceptance attach). The global lh accepted the report, returned a
verifyRunId, and printed no error — but the run's acceptance_id was NULL, so it never appeared
on the acceptance page, which reads as "my ingest didn't show up / the page is stale".verifyRunId from the global CLI as proof of attachment, or
passing --subject to it (error: unknown option '--subject' is the tell that it predates the
branch contract).cd apps/cli && bun src/index.ts verify ingest-report <dir> --subject topic:<id> … — and verify attachment in the DB
(select acceptance_id from verify_runs where id='<runId>') before driving the UI against it.lobe-activator → 激活工具, Task Tools → createTask, …) after EVERY tool
call. snapshot -i does not reliably expose the card's option rows / submit button as refs,
and one turn can chain 4–5 sequential interventions — a fixed approve-once script stalls.批准 (the row nests the label; the
filtered element list often misses it), or assuming one approval finishes the turn.提交 via eval, tag it data-probe, click through agent-browser (trusted
input), then poll chat().operations for zero running AND no remaining 提交 button before
declaring the turn done. Log which tool each round approved by grabbing the last visible
… → … header text. One creation turn (activate + createTask + setTaskVerify + runTask)
took 5 approvals..env says PORT=<n>, something answers on <n>, and a prior session recorded
"dev server running on <n>" — so the run plans a mere restart.ps -o command= -p <pid> of the listener shows the
repo path in the next dev/node command line, and an unauthenticated GET / redirect is a
cheap signature (this repo's better-auth redirects to /signin; a Clerk app redirects to
/login with x-clerk-* headers). If it is the wrong app, free the port and start the right
server — and re-verify with the same fingerprint after boot./verify-email link token is a
signed JWT that is never stored; the email-otp OTP did not appear in the verifications table
either (only unrelated OIDC rows), and the send endpoint returns {"success":true} regardless.
Flipping email_verified in the DB is a fixture, not a verification — route-gated hooks
correctly ignore it (which is itself a useful negative check).blocked and cover the path with unit tests, or run the flow with a
real receivable mailbox (staging/prod smoke). Use the DB flip only to unblock downstream
fixtures, and assert it does NOT produce the event as a bonus authenticity check.logger.info is invisible in dev without DEBUGcreateLogger(ns).info(...) lines in /tmp/electron-dev.log from a
plain dev start — in dev (app.isPackaged false) info only goes through the debug package,
which is silent unless its namespace is enabled.DEBUG="screenCapture:*" electron-dev.sh restart, then grep the log. In packaged builds the same
lines land in electron-log's file instead.key code 53 (Escape).window.electronAPI.invoke('...close')), or click its close affordance. Reserve
synthetic keys for globally-registered shortcuts that don't depend on focus.setTimeout probes mask it)globalShortcut callback; measurements
are wildly bimodal (sub-ms vs multi-second) and "fix" themselves when extra probes are added.setTimeout(0) diagnostic probe while re-measuring — a
pending timer wakes the loop and hides the stall entirely.await with markers plus one queueMicrotask and one
setTimeout(0) marker ONCE to classify the stall, then remove them and re-measure. If the
continuation only runs when an external event arrives (a CDP poll, mouse move), the cause is the
native-callback context not draining microtasks on an idle loop — fix by deferring the handler
body via setImmediate at the registration site, then verify variance collapses.apps/desktop installed with --ignore-scripts breaks EVERY native module, not just electronapps/desktop was installed with pnpm install --ignore-scripts
(e.g. to save time). Electron then crashes at boot before any window appears.pnpm rebuild electron alone. It fixes the missing electron/dist but the app
still dies in node-mac-permissions with Could not locate the bindings file ... permissions.node
and ELIFECYCLE Command failed with exit code 7 — every other native addon is unbuilt too.pnpm install (no --ignore-scripts) inside apps/desktop, then follow
E8b and re-run the ROOT install afterwards. Order: root → desktop (with scripts) → root again.electron-dev.sh: the saved snapshot beats LOBE_GOLDEN_PROFILE, and the seeded login targets LOCAL devLOBE_GOLDEN_PROFILE=/tmp/empty-golden. The script seeds from the
saved snapshot first (LOGIN_STATE_DIR, default ~/.lobehub/agent-testing/electron-login) and only
falls back to the golden profile when no snapshot exists — the log still says
"Seeding userData from saved login state" and you get the old login back.ud-<id>/lobehub-settings.json has dataSyncConfig.remoteServerUrl = http://localhost:3010. With
nothing on 3010 the app shows Authentication failed: signature verification failed plus
BackendProxy upstream fetch failed (net::ERR_CONNECTION_REFUSED), and the renderer sits at
isLoaded:true, isUserStateInit:false, isSignedIn:false — an E14 lookalike that is really a
wrong-server problem. Read ud-<id>/lobehub-settings.json before blaming the token.LOBE_GOLDEN_PROFILE=/tmp/empty-golden LOBE_LOGIN_STATE_DIR=/tmp/empty-loginstate \
SKIP_LOGIN_SAVE=1 .agents/acceptance/scripts/electron-dev.sh start <id>
/desktop-onboarding; drive 开始 → 下一步 ×2 → 登录 LobeHub Cloud and the
device-code flow auto-approves against the browser's existing app.lobehub.com session. A pristine
profile defaults to production, so remoteServerUrl stays unset. SKIP_LOGIN_SAVE=1 keeps stop
from overwriting the user's real snapshot.heteroSessionId / workingDirectory to reason about whether a
turn will --resume.chat().topicDataMap['agent_<agentId>'].items.find(...) — the view is paginated,
so any topic past the first page is simply absent (found:false) even while it is the ACTIVE topic.
UI messages in messagesMap also carry metadata.heteroSessionId only on rows the current session
produced, so an old conversation yields {}.fetch('/trpc/lambda/topic.getTopicDetail?input=' + encodeURIComponent(JSON.stringify({json:{id:'<topicId>'}})), {credentials:'include'})
and read result.data.json.metadata. That returns the authoritative heteroSessionId,
workingDirectory, and heteroSessionIdByWorkingDirectory.resolveHeteroResume silently drops --resume when the bound workingDirectory
differs from the device's current cwd (cwd_changed) or is absent (missing_bound_cwd). The turn
then SUCCEEDS with a brand-new CC session and no error — context loss with no visible symptom. To
detect it, compare the CC session id before/after a turn (or diff ~/.claude/projects/*/*.jsonl):
a new file per turn, each containing only its own user message, means resume never happened.workspaces / workspace_members / agents).agent.getAgentConfigById fails FORBIDDEN —
the cloud RBAC middleware reads rbac_user_roles → roles → role_permissions → permissions, which raw member rows never create. And even after RBAC, a raw
SQL agents row renders "助理不可用" (missing real config).seedWorkspaceRoles(db, wsId) + assignWorkspaceRoleToUser(...) from
packages/database/src/utils/seedWorkspaceRoles.ts (run with bun from inside
packages/database); ② create agents through the real API from the authed
page (POST /trpc/lambda/agent.createAgent with the X-Workspace-Id header,
visibility: 'public' | 'private'), then repoint seeded topics' agent_id.pnpm install (this repo has no
lockfile), node_modules/.pnpm can hold two @lobehub/ui@X peer-hash
instances. The workspace agent conversation route then dies in the error
boundary with Please wrap your app with <ConfigProvider> (or <MotionProvider>) thrown from TypewriterEffect — the two instances carry
two React contexts. The sidebar-only routes may still work, which disguises
the cause; clearing node_modules/.vite does NOT fix it.pnpm dedupe @lobehub/ui (peer sets differ, instances survive).resolve: { dedupe: ['@lobehub/ui', 'antd-style', 'motion', 'react', 'react-dom'] } to the cloud root vite.config.ts +
rm -rf node_modules/.vite, and REVERT the config after capturing evidence
(snapshot the file first — it may carry uncommitted edits).apps/desktop/stubs/types lags packages/typeselectron-dev.sh start reaches CDP but the SPA never becomes interactive;
/tmp/electron-dev.log shows SyntaxError: The requested module '/apps/desktop/stubs/types/src/index.ts' does not provide an export named 'MAX_ANALYSIS_...'.cp ... /tmp/...bak), append the missing export const lines copied
from packages/types (grep the missing name there for the real value), reload, and RESTORE the stub
at teardown. PR #17436 removes the stub entirely; delete this entry once it lands.wsagent-browser --cdp 9222 call (eval/snapshot/fill) times out while
curl http://localhost:9222/json answers instantly — the daemon connection is wedged, not the app.ws) that picks a target from /json by URL substring
and speaks Runtime.evaluate (awaitPromise:true, returnByValue:true) / Page.captureScreenshot
directly. Key targets: the SPA renderer is app://renderer/...; each in-app-browser page
(WebContentsView) is its OWN page target (match by its site URL). See
.records/guest-eval.mjs / .records/guest-shot.mjs from the 2026-07-22 browser-panel run.Page.captureScreenshot does NOT contain WebContentsView
content (black hole where the page is), and the guest target's screenshot contains ONLY the page.
For a composite (panel chrome + embedded page + in-page overlays) use OS capture with the window
bounds from System Events: screencapture -x -R"x,y,w,h" — works even when the window sits on a
secondary display at negative coordinates (where capture-app-window.sh fails with "could not
create image from window").signature verification failed; the app must log into localhost:3010.requestAuthorization({ storageMode: 'cloud' }) — that targets production
app.lobehub.com. Also the plain dev server rejects /oidc/auth with "OIDC is not enabled".JWKS_KEY (that is what flips ENABLE_OIDC): generate once with
node scripts/generate-oidc-jwk.mjs, export, restart dev.DEBUG='controllers:AuthCtr*' — in dev, logger.info only reaches the
terminal via the debug namespace, and the log line Constructed authorization URL: ... is the
only place to harvest the PKCE authorize URL (shell.openExternal races it into the user's
default browser, which just bounces to signin).remoteServerService.setRemoteServerConfig({ active: true, remoteServerUrl: 'http://localhost:<port>', storageMode: 'selfHost' }) — THEN
trigger requestAuthorization({ storageMode: 'selfHost', remoteServerUrl: ... }) via CDP eval.
requestAuthorization success only sets active: true; it never writes remoteServerUrl, so
without the explicit config write the BackendProxy keeps routing every renderer call to the OLD
server (symptom: main log says "Authorization successful" + token valid, renderer stays signed
out, and the 401/502 stack paths point at the wrong repo's .next/dev). Grep the authorize URL
from /tmp/electron-dev.log, open it in the seeded web session, click 确认登录;the consent is
remembered, so later rounds complete without a click. Note the 60s polling window — if the web
session must first do a full password login, the handoff times out; warm the session before
triggering. After config + auth, reload the renderer; app-probe.sh auth flips signed-in.localhost:3010; if init-dev-env.sh allocated a
different port, pin ALLOC_SERVER_PORT=3010 in .records/env/agent-testing-ports.env and restart.el.click() from CDP — dispatch the full pointer sequenceRuntime.evaluate; el.click()
silently does nothing (no handler fires, no error).pointerdown → mousedown → pointerup → mouseup → click (all
{bubbles:true, cancelable:true, view:window}, PointerEvent for pointer*). This is what the
browser-panel run used for the camera button, the "+" dropdown trigger, and its menu items.electron-dev.sh start runs on the USER'S OWN dev profile — and parallel sessions fight over ports and dev serverselectron-dev.sh start uses the default userData
(~/Library/Application Support/lobehub-desktop-dev) — the user's own dev-app profile, not an
isolated copy. Any selfHost re-auth you drive overwrites their dataSyncConfig and tokens.
Prefer the pool form (start <id>), which copies login state into an isolated dir; if legacy
mode was used, tell the user their dev-app login/server config was changed.bun run dev tree dying with SIGTERM ("Polite quit request") seconds-to-minutes
after start, repeatedly, with no error of its own. nohup/disown does not help (the killer
targets the process, not your task tree)..records/env/agent-testing-ports.env
(ALLOC_SERVER_PORT, ALLOC_SPA_PORT) to values far from the common bases (e.g. 3111 / 25999),
restart, and re-point the Electron app at the new server (see C17 step 3). Also check
lsof -iTCP:<port> plus the listener's ps cwd before blaming your own code — a listener from
ANOTHER repo checkout answering on "your" port produces confusing wrong-stack error traces.electron.app undefined — the agent harness leaks ELECTRON_RUN_AS_NODE=1electron-dev.sh start fails repeatedly; the instance log shows
TypeError: Cannot read properties of undefined (reading 'setName') at electron.app.setName(...)
with a plain Node.js v24.x banner, and the dev watcher then tears everything down. Rebuilding
electron / reinstalling deps does not help; the same script worked in earlier sessions.ELECTRON_RUN_AS_NODE=1. Every child inherits it, so the spawned Electron
binary boots as PLAIN NODE — require('electron') returns a path string, electron.app is
undefined. Whether a session carries the variable depends on how that session was started,
which is why the symptom appears "randomly" across sessions.env -u ELECTRON_RUN_AS_NODE .agents/acceptance/scripts/electron-dev.sh start <id>.
Check env | grep ELECTRON FIRST whenever an Electron dev boot dies before any window appears.setsid(1);
daemonize with python os.fork()+os.setsid() double-fork AND keep the leader alive, or accept
the session-bound lifetime and re-start per session.init-dev-env.sh dev) and the desktop instance vite
concurrently from ONE worktree makes both optimizers share <root>/node_modules/.vite —
two cold optimizers clobber each other and dynamic imports 504 (Outdated Optimize Dep)
indefinitely. Boot them sequentially (let one finish bundling before starting the other).agent-browser sessions are also running on the same machine. New sessions
may hang if another task is concurrently restarting its browser daemon.agent-browser close --all or killing every
agent-browser/Chrome process. That destroys unrelated verification work and
can race with another task recreating the daemon.init-dev-env.sh dev / bare bun run dev as a background task from an
agent harness whose Bash tool sandboxes commands by default. The server boots, serves a few
requests, then dies with exit 143; bun logs terminated by signal SIGTERM (Polite quit request).
Reproduced 3× in one run (recorded-PID path, bare unrecorded launch — both die), which
masquerades as "another session keeps killing my server" when a parallel run is also active.bun run dev instead of the script)
— the killer is not stop-dev; the sandbox supervisor reaps the background task's process tree
shortly after the spawning tool call returns. Also note the kill can land mid-write and corrupt
.next (E20 follows: /signin 307 ping-pong, auth POST without set-cookie).