Compare commits

..

6 Commits

Author SHA1 Message Date
YuTengjing ff61f4b3fa 💄 style: add Qwen3.7 Max locale (#15150) 2026-05-24 21:49:34 +08:00
Innei 192111840c 💄 style(workflow): normalize block spacing (#15169) 2026-05-24 20:17:30 +08:00
Arvin Xu 837a3daa58 feat(chat): consume gateway uiMessages snapshot as SoT at step boundaries (#15153)
* ♻️ refactor(chat-store): useFetchMessages accepts options object

LOBE-9501

Replace the positional `skipFetch?: boolean` second argument with an
`options?: { skipFetch?, revalidateOnFocus? }` object on both
`useChatStore.useFetchMessages` and `useConversationStore.useFetchMessages`.
Plumb `revalidateOnFocus` through to the underlying SWR config so callers
can suppress focus revalidate per-call (default behaviour unchanged).

Mechanically migrate all 7 call sites to the new shape. No behaviour
change in this commit — the streaming-aware `revalidateOnFocus: false`
follow-up lives in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(chat): consume gateway uiMessages snapshot as SoT at step boundaries

LOBE-9501

Server attaches the canonical UIChatMessage[] snapshot to step_start and
agent_runtime_end events (#15152). The client now uses that pushed payload
as the source of truth instead of refetching from DB:

- step_start handler calls replaceMessages(uiMessages, { context }) when
  the snapshot is present, so the assistant tab-switch / next-step path
  no longer issues a refetch that returns a stale assistant placeholder.
- agent_runtime_end handler does the same for the terminal step — the
  last step has no later step_start to carry a fresh snapshot, so this
  branch is the only one that reconciles the final commit.
- step_complete on phase=tool_execution stops calling refreshMessages.
  That refetch was the direct cause of the assistantGroup→assistant
  clobber regression captured by the agent-gateway probe scripts.
- ChatList disables SWR revalidateOnFocus while the current topic is
  streaming (via operationSelectors.isAgentRuntimeRunningByContext) and
  automatically restores it after the run ends. Tab-focus during a run
  no longer triggers the stale DB read.

Doesn't touch streamingExecutor.ts (homogeneous runtime — parallel path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(chat-store): wire gateway handler to consume server-pushed uiMessages SoT

LOBE-9501

#15152 (server) attaches the canonical UIChatMessage[] snapshot to both
the Redis SSE channel and the gateway /push-event channel. The earlier
client patch wired the consumer into `runAgent.ts`, but that file only
runs on the Group Chat SSE path. The actual gateway entry point
(`createGatewayEventHandler` in `gatewayEventHandler.ts`, used by single
agent, sub-agent, and hetero-CLI flows) ignored the field entirely and
kept refetching from DB.

Fix the gateway handler:

- step_start: consume `event.data.uiMessages` and replaceMessages with
  the pushed SoT. Skipped when absent — hetero adapters don't emit
  step_start at all (HeterogeneousEventType excludes it), so the new
  branch is invisible to hetero.

- agent_runtime_end: same SoT consumption; the existing
  `fetchAndReplaceMessages` becomes the fallback for events without the
  field. Claude Code adapter emits agent_runtime_end with empty data,
  so hetero terminal behavior is preserved by the fallback.

- stream_start: gate the DB fetch on `!newAssistantMessageId`. Native
  gateway streams carry `assistantMessage.id` (the preceding step_start
  also delivered the SoT), so the await is unnecessary — AND it was
  blocking the enqueue chain. Live chunks queued behind that await
  could not dispatch, which manifested as "streaming content never
  lands in messagesMap" during tab-switch and slow-network repros.
  Hetero CLI streams never set `assistantMessage.id`, so the fetch
  still runs for them on every stream_start.

Verified with the agent-gateway probe (separate commit): chunks now
land in real time (cLen grows 3 → 529 monotonically), and tab-switch
mid-stream no longer rolls the streamed assistantGroup back to the
LOADING placeholder (ROLLBACKS=none in the analyzer output).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* 🧪 chore(local-testing): rewrite agent-gateway probes in TS + add CLI

LOBE-9501

Convert the local-testing agent-gateway probes from .js/.mjs to TypeScript
and add a unified `run.ts` CLI that bundles via Bun.build (no extra
deps) and persists dumps to a gitignored `.agent-gateway/` directory for
use as streaming-replay test fixtures.

- types.ts: shared dump shape (ProbeStreamEvent / ProbeTimelineSample /
  ProbeDump) and `declare global` for the `window.__PROBE_*` surface
- probe-events.ts: WebSocket + fetch interception (gateway WS captures
  any socket with `operationId=`; fetch captures `/api/agent/stream` for
  direct SSE). Per-key timeline samples every 200ms so we can see
  which messagesMap key streaming chunks actually land in
- probe-dump.ts: stops the timeline timer and stashes JSON dump on
  `window.__PROBE_LAST_DUMP_JSON` (runner returns that global)
- analyze-events.ts: stream events (non-chunk) + chunks summary +
  action-call stacks + correlation + per-key assistant growth +
  rollback detection. Per-key growth was added specifically to
  diagnose "chunks arrive but assistant cLen never moves"
- run.ts: `install` | `dump [name]` | `analyze [path]` CLI. Bundles via
  Bun.build, wraps as IIFE with explicit return, pipes to
  `agent-browser eval --stdin`. Dumps land at
  `.agent-gateway/<name>-<YYYYMMDD-HHmmss>.json`

`.agent-gateway/` is gitignored so dumps accumulate across debugging
sessions without polluting git.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* 🐛 fix(local-testing): repair run.ts after autofix mangled path imports

LOBE-9501

The eslint --fix run during the previous commit applied the unicorn
`import-style` rule and renamed every `join(` / `dirname(` / `resolve(`
to `path.join(` / `path.dirname(` / `path.resolve(`, but the replacement
was a naive text substitution that:

1. rewrote `array.join('\n')` to `array.path.join('\n')` — broke bundle
   error reporting (would TypeError on the build-failure path)
2. produced `const path = path.join(DUMP_DIR, filename)` inside cmdDump
   — shadowed the `path` module with itself, ReferenceError on every
   dump invocation

Rename the local `path` to `dumpPath` and drop the spurious `.path`
prefix on the array `.join`. Verified round-trip: install + dump now
write a valid capture to `.agent-gateway/`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* 🧪 chore(local-testing): capture per-call message snapshot in probe

LOBE-9501

The probe's `replaceMessages` wrapper used to record only `count` and
`params` — enough to see "two messages were written" but not WHICH two.
For post-stream collapse debugging we need to see whether each call
restored streamed content (cLen=N) or wiped to LOADING_FLAT (cLen=3).

Two changes:

- Capture `snapshot` field on every replaceMessages call: last 2
  messages' id / role / cLen / rLen / updatedAt. The analyzer prints
  this inline next to each call so reviewers can see content drift /
  collapse without re-reading the dump.

- Make wrapping idempotent across re-installs. The old guard
  `chat.__probeWrapped = true` froze the first-installed wrapper across
  re-installs, so updates to the probe body had no effect without a
  page reload. Stash the originals on
  `window.__PROBE_ORIG_REFRESH_MESSAGES` /
  `window.__PROBE_ORIG_REPLACE_MESSAGES` and re-wrap from those on
  every install.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* 🧪 chore(local-testing): add mutation log + dispatchMessage wrap to probe

LOBE-9501

The replaceMessages-only wrap couldn't catch chunk-level writes (those go
through internal_dispatchMessage) or attribute post-stream collapses to a
specific writer. Add:

- `__PROBE_MUTATIONS` — unified ordered log of every dbMessagesMap[key]
  reference change, with `last`/`prevLast` summaries and a `delta` field
  that tags interesting transitions (`cLen↓N→M`, `rLen↓`, `id:A→B`,
  `n↓prev→cur`). Both writers — replaceMessages AND internal_dispatchMessage
  — push to the same buffer so a single timeline shows all stores writes.

- Idempotent action wrapping. Originals are stashed on
  `window.__PROBE_ORIG_*` and re-wrapped from there on every install, so
  probe edits take effect without a page reload (previous
  `chat.__probeWrapped` flag froze the first wrapper).

- Snapshot field on replaceMessages — last 2 messages'
  id/role/cLen/rLen/updatedAt — so reviewers can see WHICH content each
  call is writing instead of just the count.

- Dump file now carries the `mutations` array alongside streamEvents,
  actionCalls, timeline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* 🐛 fix(chat-store): gate SWR onData by isStreaming for streaming topic

LOBE-9501

Backstop for the post-stream cLen collapse that survives even with the
gateway SoT consume in place. Reproduction (confirmed):

1. Send a stream that lands lots of WS chunks into ChatStore
2. Immediately reload the page

If the page reload races against server-side chunk fan-out into Postgres,
SWR's fresh fetch returns the assistant row in its LOADING_FLAT placeholder
state (cLen=3) and writes that to ChatStore via the conversation-store
mirror — even though the WS push at agent_runtime_end carried the
correct full content moments earlier.

`mergeFetchedMessagesWithLocalState`'s updatedAt tie-breaker handles
this for in-session repros (local message wins when its updatedAt is
newer), but it degenerates when:

- The SoT consume just wrote server's snapshot updatedAt onto the local
  message, equalising the timestamps so the next stale DB fetch wins
- The user reloads (no local state to merge against — fresh fetch wins
  outright)

Add a gate at the bottom of `ConversationStore.useFetchMessages.onData`:
while `isAgentRuntimeRunningByContext(context)` is true, drop the SWR
write entirely. SWR's own cache still updates, so once streaming ends a
normal revalidate writes through correctly.

This is layered defense — it does NOT fix the underlying server-side
fan-out lag (filed as separate Linear issue). It does prevent the
client-side flash users currently see during the lag window.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* 🧪 test(chat-store): align gateway handler tests with SoT contract

The previous assertions still expected `stream_start` to issue a DB refetch
on every native gateway stream — the very behaviour LOBE-9501 removes
(`acb9523a04`). Update the three failing cases to the new contract:

- `stream_start > should associate new message with operation`:
  assert `messageService.getMessages` is NOT called when
  `assistantMessage.id` is present (the SoT snapshot from the preceding
  `step_start` already pre-populated `dbMessagesMap`).
- `sequential processing`: rewrite around the surviving ordering guarantee
  — `associate` (stream_start) must precede `dispatch` (stream_chunk) so
  the chunk targets the new id. Add a sibling case for hetero CLI streams
  (no `assistantMessage.id` → DB fetch is still mandatory).
- `multi-step integration > full LLM → tools → LLM cycle`: keep the
  post-`tool_end` `replaceMessages` assertion (tool_end still refreshes
  from DB), invert the post-`stream_start` assertion for step 2.

42 tests passing (was 41 + 1 new hetero fallback test).

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:05:58 +08:00
AmAzing- 5f6f053039 🐛 fix(agent): hide community publish for heterogeneous agents (#15166) 2026-05-24 18:39:05 +08:00
AmAzing- 775be47513 🐛 fix(agent): align settings defaults and locale state (#15163) 2026-05-24 16:29:22 +08:00
Arvin Xu 2f265a9307 🐛 fix(conversation): only swap model name for remote hetero agents in Usage (#15156)
* 🐛 fix(conversation): only swap model name for remote hetero agents in Usage

Local CLI hetero agents (claude-code, codex) report their actual model
id on `turn_metadata` and persist it on the assistant message, but the
Usage extra was unconditionally replacing it with the provider brand
label ("Claude Code" / "Codex") whenever `HETEROGENEOUS_TYPE_LABELS`
had an entry. Gate the swap to remote platform agents (openclaw,
hermes) — those don't expose a real model id — so CC/Codex turns show
the underlying model again.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

*  test(desktop): update GatewayConnectionCtr tests for lh hetero exec route

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:08:21 +08:00
50 changed files with 2359 additions and 542 deletions
@@ -0,0 +1,243 @@
// Analyzer for probe-events dumps. Reads a JSON file produced by `run.ts dump`
// and prints a layered breakdown:
//
// 1. STREAM EVENTS — every non-chunk WS/SSE event in receipt order
// 2. CHUNKS SUMMARY — collapsed per-step chunk counts (otherwise floods)
// 3. ACTION CALLS — replaceMessages / refreshMessages / MARK:* with stack
// 4. CORRELATION — calls ↔ nearest stream event within ±300ms
// 5. PER-KEY ASSISTANT GROWTH — for each messagesMap key, when the leading
// assistant message's cLen / rLen actually moves (this is what reveals
// "chunks arrived but the message never grew" regressions)
// 6. ROLLBACKS — msgN / childN / role drops in the active-topic timeline
//
// Usage:
// bun run .agents/skills/local-testing/scripts/agent-gateway/analyze-events.ts <dump.json>
import { readFileSync } from 'node:fs';
import type {
ProbeActionCall,
ProbeDump,
ProbeMessageSummary,
ProbeStreamEvent,
ProbeTimelineSample,
} from './types';
const file = process.argv[2];
if (!file) {
console.error('usage: bun run analyze-events.ts <dump.json>');
process.exit(1);
}
const raw = readFileSync(file, 'utf8');
// agent-browser eval --stdin wraps return values in quotes when the value is
// a string — so the JSON file may be double-encoded depending on how it was
// captured. Handle both.
const parsedOnce = JSON.parse(raw) as ProbeDump | string;
const dump: ProbeDump = typeof parsedOnce === 'string' ? JSON.parse(parsedOnce) : parsedOnce;
const { streamEvents = [], actionCalls = [], timeline = [] } = dump;
const pad = (v: unknown, n: number) => String(v).padStart(n);
// ── META ───────────────────────────────────────────────────────────
console.log('=== META ===');
console.log(` events: ${streamEvents.length}`);
console.log(` calls: ${actionCalls.length}`);
console.log(` timeline: ${timeline.length}`);
// ── 1. STREAM EVENTS (non-chunk) ───────────────────────────────────
const nonChunkEvents = streamEvents.filter((e) => e.type !== 'stream_chunk');
const chunkEvents = streamEvents.filter((e) => e.type === 'stream_chunk');
console.log(
`\n=== STREAM EVENTS (${nonChunkEvents.length} non-chunk + ${chunkEvents.length} chunks elided) ===`,
);
for (const e of nonChunkEvents) {
const dataStr = e.dataKeys?.length ? ` [${e.dataKeys.join(',')}]` : '';
const data = e.data as Record<string, unknown> | undefined;
const uiHint = data?.uiMessagesPreview
? ` uiPreview=${JSON.stringify(data.uiMessagesPreview)}`
: data?.uiMessagesTotal
? ` uiTotal=${data.uiMessagesTotal}`
: '';
const phaseHint = data?.phase ? ` phase=${data.phase}` : '';
const extra = e.serverType ? ` serverType=${e.serverType}` : '';
console.log(
` t=${pad(e.t, 7)} [${(e.transport ?? '?').padEnd(3)}] step=${pad(e.stepIndex ?? '-', 2)} ` +
`type=${(e.type ?? '').padEnd(22)} op=${e.opIdTail ?? '-'}${phaseHint}${uiHint}${extra}${dataStr}`,
);
}
// ── 2. CHUNK SUMMARY ───────────────────────────────────────────────
console.log('\n=== CHUNKS SUMMARY (per step / chunkType) ===');
const chunkBuckets = new Map<string, { count: number; firstT: number; lastT: number }>();
for (const c of chunkEvents) {
const data = c.data as Record<string, unknown> | undefined;
const ct = (data?.chunkType as string | undefined) ?? '?';
const key = `step=${c.stepIndex ?? '-'} chunkType=${ct.padEnd(8)} op=${c.opIdTail}`;
const slot = chunkBuckets.get(key);
if (slot) {
slot.count += 1;
slot.lastT = c.t;
} else {
chunkBuckets.set(key, { count: 1, firstT: c.t, lastT: c.t });
}
}
for (const [k, v] of chunkBuckets) {
console.log(` ${k} count=${pad(v.count, 4)} t=${pad(v.firstT, 7)}..${pad(v.lastT, 7)}`);
}
// ── 3. ACTION CALLS ───────────────────────────────────────────────
console.log('\n=== ACTION CALLS (replace/refresh/MARK) ===');
for (const c of actionCalls) {
if (c.name?.startsWith('MARK:')) {
console.log(` t=${pad(c.t, 7)} ${c.name}`);
continue;
}
const snapshot = (c.args as any)?.snapshot as
| Array<{ id: string; role: string; cLen: number; rLen: number }>
| undefined;
const snapStr = snapshot?.length
? ' snapshot=' + snapshot.map((m) => `${m.id}:${m.role}/c${m.cLen}/r${m.rLen}`).join(' | ')
: '';
const summary =
c.name === 'replaceMessages'
? `count=${c.args?.count} action=${(c.args?.params as any)?.action ?? '-'}${snapStr}`
: c.name === 'refreshMessages'
? `ctx=${JSON.stringify(c.args?.context)}`
: c.error
? `error=${c.error}`
: '';
console.log(` t=${pad(c.t, 7)} ${c.name.padEnd(20)} ${summary}`);
if (c.stack) {
const frames = c.stack
.split(' ← ')
.filter((f) => !!f && !f.includes('Object.<anonymous>'))
.slice(0, 3);
for (const f of frames) console.log(`${f}`);
}
}
// ── 4. CORRELATION ────────────────────────────────────────────────
function nearestEventForCall(
call: ProbeActionCall,
windowMs = 300,
): { event: ProbeStreamEvent; delta: number } | null {
let best: ProbeStreamEvent | null = null;
let bestDelta = Infinity;
for (const e of streamEvents) {
const d = Math.abs(e.t - call.t);
if (d < bestDelta && d <= windowMs) {
bestDelta = d;
best = e;
}
}
return best ? { event: best, delta: bestDelta } : null;
}
console.log('\n=== CORRELATION (replace/refresh ↔ nearest event within ±300ms) ===');
for (const c of actionCalls) {
if (c.name !== 'refreshMessages' && c.name !== 'replaceMessages') continue;
const hit = nearestEventForCall(c);
if (hit) {
const phase = (hit.event.data as Record<string, unknown> | undefined)?.phase;
console.log(
` t=${pad(c.t, 7)} ${c.name.padEnd(16)} ← Δ${pad(hit.delta, 4)}ms ${hit.event.type}` +
(phase ? ` phase=${phase}` : ''),
);
} else {
console.log(` t=${pad(c.t, 7)} ${c.name.padEnd(16)} ← (no event nearby — external trigger)`);
}
}
// ── 5. PER-KEY ASSISTANT GROWTH ───────────────────────────────────
// For each messagesMap key, find the trailing assistant message and report
// the points in time where its cLen / rLen actually changed. If the timeline
// shows chunks arriving but the assistant cLen never moves, that's the
// signature of "dispatch queue blocked / messageId mismatch".
console.log('\n=== PER-KEY ASSISTANT GROWTH ===');
const keysEverSeen = new Set<string>();
for (const s of timeline) for (const k of Object.keys(s.byKey ?? {})) keysEverSeen.add(k);
for (const key of keysEverSeen) {
console.log(`\n key=${key}`);
let lastSig: string | null = null;
for (const s of timeline) {
const slot = s.byKey?.[key];
if (!slot) continue;
const last = slot.msgs.at(-1) as ProbeMessageSummary | undefined;
if (!last) continue;
const sig = `${last.id}|c${last.cLen}|r${last.rLen}|n${slot.n}`;
if (sig === lastSig) continue;
lastSig = sig;
console.log(
` t=${pad(s.t, 7)} msgN=${pad(slot.n, 3)} ` +
`lastAssistant=${last.id} cLen=${pad(last.cLen, 5)} rLen=${pad(last.rLen, 5)}` +
` runOps=${s.runOps}`,
);
}
}
// ── 6. ROLLBACKS (active-topic msgN / childN / role drops) ─────────
console.log('\n=== ROLLBACKS (active-topic msgN / childN / role drops) ===');
let prev: ProbeTimelineSample | null = null;
const rollbacks: Array<{ t: number; topic: string | null; drops: string[] }> = [];
const flatten = (s: ProbeTimelineSample) => {
if (!s.activeTopic) return [];
return Object.entries(s.byKey ?? {})
.filter(([k]) => k.includes(s.activeTopic!))
.flatMap(([, v]) => v.msgs);
};
for (const s of timeline) {
if (s.err) {
prev = null;
continue;
}
if (!prev || prev.activeTopic !== s.activeTopic) {
prev = s;
continue;
}
const prevMsgs = flatten(prev);
const curMsgs = flatten(s);
const drops: string[] = [];
if (curMsgs.length < prevMsgs.length) drops.push(`msgN ${prevMsgs.length}${curMsgs.length}`);
let prevChild = 0;
let curChild = 0;
for (const m of prevMsgs) prevChild += m.chN ?? 0;
for (const m of curMsgs) curChild += m.chN ?? 0;
if (curChild < prevChild) drops.push(`childN ${prevChild}${curChild}`);
const prevById = new Map(prevMsgs.map((m) => [m.id, m]));
for (const m of curMsgs) {
const pr = prevById.get(m.id);
if (!pr) continue;
if (m.cLen < pr.cLen) drops.push(`cLen[${m.id}] ${pr.cLen}${m.cLen}`);
if (m.rLen < pr.rLen) drops.push(`rLen[${m.id}] ${pr.rLen}${m.rLen}`);
}
if (drops.length) rollbacks.push({ t: s.t, topic: s.activeTopic, drops });
prev = s;
}
if (rollbacks.length === 0) {
console.log(' (none)');
} else {
for (const r of rollbacks) {
const nearEvent = streamEvents
.filter((e) => Math.abs(e.t - r.t) <= 300)
.map((e) => `${e.type}${(e.data as any)?.phase ? ':' + (e.data as any).phase : ''}`);
const nearCall = actionCalls
.filter((c) => Math.abs(c.t - r.t) <= 300 && !c.name?.startsWith('MARK:'))
.map((c) => c.name);
console.log(
` t=${pad(r.t, 7)} topic=${r.topic} ${r.drops.join(' | ')}` +
(nearEvent.length ? ` near-event:[${nearEvent.join(',')}]` : '') +
(nearCall.length ? ` near-call:[${nearCall.join(',')}]` : ''),
);
}
}
@@ -0,0 +1,37 @@
// Stops the events-probe timeline timer and stashes the full capture as a
// JSON string on `window.__PROBE_LAST_DUMP_JSON`. `run.ts` wraps the bundle
// in an IIFE that returns that global, which `agent-browser eval` prints to
// stdout — the runner then persists it under `.agent-gateway/`.
import type { ProbeDump } from './types';
declare global {
interface Window {
__PROBE_LAST_DUMP_JSON?: string;
}
}
const w = window;
if (w.__PROBE_TIMELINE_TIMER) {
clearInterval(w.__PROBE_TIMELINE_TIMER);
w.__PROBE_TIMELINE_TIMER = null;
}
const mutations = w.__PROBE_MUTATIONS ?? [];
const dump: ProbeDump & { mutations: typeof mutations } = {
meta: {
t0: w.__PROBE_T0 ?? 0,
collectedAt: Date.now(),
sampleCount: (w.__PROBE_MSG_TIMELINE ?? []).length,
eventCount: (w.__PROBE_STREAM_EVENTS ?? []).length,
callCount: (w.__PROBE_ACTION_CALLS ?? []).length,
},
streamEvents: w.__PROBE_STREAM_EVENTS ?? [],
actionCalls: w.__PROBE_ACTION_CALLS ?? [],
timeline: w.__PROBE_MSG_TIMELINE ?? [],
mutations,
};
w.__PROBE_LAST_DUMP_JSON = JSON.stringify(dump);
@@ -0,0 +1,637 @@
// LobeHub gateway raw-event-stream probe.
//
// Gateway-mode chats subscribe via WebSocket — NOT via the `/api/agent/stream`
// SSE endpoint (that one belongs to the direct/client durable-agent runtime).
// `AgentStreamClient` (`packages/agent-gateway-client/src/client.ts`) opens
// `new WebSocket('wss://.../ws?operationId=...')`, then parses JSON frames in
// its `onmessage` handler and re-emits `agent_event.event` objects to the
// chat store.
//
// To capture the RAW gateway events before the store touches them, we wrap
// `window.WebSocket` so that for any socket whose URL contains `operationId=`
// we intercept the `onmessage` handler / `addEventListener('message')` and
// log every `agent_event` frame.
//
// We *also* keep the `window.fetch` hook for `/api/agent/stream` so this
// probe still works for direct-mode runs — but gateway-mode events come
// through the WebSocket path.
//
// Buffers (read via `dump`):
// __PROBE_STREAM_EVENTS — raw events parsed off the wire
// __PROBE_ACTION_CALLS — replaceMessages / refreshMessages calls (best-effort)
// __PROBE_MSG_TIMELINE — 200ms snapshots of every messagesMap key
import type {
ProbeActionCall,
ProbeMessageSummary,
ProbeStreamEvent,
ProbeTimelineSample,
} from './types';
// Bundled by esbuild as an IIFE. Top-level code runs once on injection.
const w = window;
// ── Buffers ─────────────────────────────────────────────────────────
declare global {
interface Window {
__PROBE_MUTATIONS?: Array<{
t: number;
key: string;
n: number;
last?: { id: string; role: string; cLen: number; rLen: number; updatedAt?: unknown };
prevLast?: { id: string; role: string; cLen: number; rLen: number };
delta?: string;
}>;
__PROBE_STORE_UNSUB?: () => void;
}
}
const events: ProbeStreamEvent[] = (w.__PROBE_STREAM_EVENTS ??= []);
const calls: ProbeActionCall[] = (w.__PROBE_ACTION_CALLS ??= []);
const timeline: ProbeTimelineSample[] = (w.__PROBE_MSG_TIMELINE ??= []);
const mutations = (w.__PROBE_MUTATIONS ??= []);
events.length = 0;
calls.length = 0;
timeline.length = 0;
mutations.length = 0;
const t0 = Date.now();
w.__PROBE_T0 = t0;
const now = (): number => Date.now() - t0;
// ── Helpers ─────────────────────────────────────────────────────────
function summarizeData(data: unknown): Record<string, unknown> | unknown {
if (!data || typeof data !== 'object') return data;
const src = data as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const k of Object.keys(src)) {
const v = src[k];
if (v == null) {
out[k] = v;
} else if (Array.isArray(v)) {
out[k] = `Array(${v.length})`;
if (k === 'uiMessages') {
out.uiMessagesPreview = v.slice(0, 5).map((m: any) => ({
id: (m.id ?? '').slice(-8),
role: m.role,
cLen: (m.content ?? '').length,
children: (m.children ?? []).length,
tools: (m.tools ?? []).length,
reasoning: (m.reasoning?.content ?? '').length,
}));
out.uiMessagesTotal = v.length;
}
} else if (typeof v === 'object') {
const obj = v as Record<string, unknown>;
out[k] =
'Object{' +
Object.keys(obj)
.slice(0, 6)
.map((kk) => kk + (typeof obj[kk] === 'string' ? `=${(obj[kk] as string).length}ch` : ''))
.join(',') +
'}';
} else if (typeof v === 'string') {
out[k] = v.length > 100 ? v.slice(0, 100) + `…(${v.length})` : v;
} else {
out[k] = v;
}
}
return out;
}
function summarizeMessages(msgs: any[]): ProbeMessageSummary[] {
return (msgs ?? []).slice(0, 80).map((m) => ({
id: (m.id ?? '').slice(-8),
role: m.role,
cLen: (m.content ?? '').length,
rLen: (m.reasoning?.content ?? '').length,
tools: (m.tools ?? []).length,
chN: (m.children ?? []).length,
}));
}
function shortStack(): string {
const raw = new Error('probe-stack').stack ?? '';
return raw
.split('\n')
.slice(3)
.filter((l) => !l.includes('probe-events') && !l.includes('node_modules'))
.map((l) => l.trim().replace(/^at\s+/, ''))
.slice(0, 6)
.join(' ← ');
}
function recordAgentEvent(args: {
transport: 'ws' | 'sse';
opId: string | null;
agentEvent: any;
eventId?: string | null;
rawLen?: number;
}): void {
const { transport, opId, agentEvent, eventId, rawLen } = args;
if (!agentEvent || typeof agentEvent !== 'object') return;
events.push({
t: now(),
transport,
opIdTail: (opId ?? '').slice(-10),
eventId: eventId ?? null,
type: agentEvent.type,
stepIndex: agentEvent.stepIndex,
dataKeys: agentEvent.data ? Object.keys(agentEvent.data) : [],
data: summarizeData(agentEvent.data) as Record<string, unknown>,
rawLen,
});
}
// ── 1. Patch window.WebSocket for gateway WS events ────────────────
if (!w.__PROBE_ORIG_WEBSOCKET) w.__PROBE_ORIG_WEBSOCKET = w.WebSocket;
const OrigWS = w.__PROBE_ORIG_WEBSOCKET;
function extractOpIdFromWsUrl(url: string | URL): string | null {
const m = String(url ?? '').match(/operationId=([^&]+)/);
return m ? decodeURIComponent(m[1]) : null;
}
function isGatewayWs(url: string | URL): boolean {
return String(url ?? '').includes('operationId=');
}
function handleWsFrame(rawData: unknown, opId: string | null): void {
const rawLen = typeof rawData === 'string' ? rawData.length : -1;
let parsed: any;
try {
parsed = typeof rawData === 'string' ? JSON.parse(rawData) : null;
} catch {
events.push({
t: now(),
transport: 'ws',
opIdTail: (opId ?? '').slice(-10),
type: '_PARSE_ERROR_',
raw: typeof rawData === 'string' && rawData.length < 400 ? rawData : '(non-string or large)',
});
return;
}
if (!parsed) return;
if (parsed.type === 'agent_event') {
recordAgentEvent({
transport: 'ws',
opId,
agentEvent: parsed.event,
eventId: parsed.id,
rawLen,
});
} else {
events.push({
t: now(),
transport: 'ws',
opIdTail: (opId ?? '').slice(-10),
type: '_SERVER_MSG_',
serverType: parsed.type,
rawLen,
});
}
}
// Wrap the constructor. Instance `constructor` will still reflect OrigWS
// (we share prototypes), so use the `_WS_OPEN_` sentinel events to confirm
// the patch is firing.
function PatchedWebSocket(this: WebSocket, url: string | URL, protocols?: string | string[]) {
const ws: WebSocket = protocols == null ? new OrigWS(url) : new OrigWS(url, protocols);
const opId = extractOpIdFromWsUrl(url);
if (!isGatewayWs(url)) return ws;
events.push({
t: now(),
transport: 'ws',
opIdTail: (opId ?? '').slice(-10),
type: '_WS_OPEN_',
url: String(url),
});
// One observer listener that always fires, regardless of how the consumer
// (AgentStreamClient uses `ws.onmessage = …`) subscribes.
ws.addEventListener('message', (e) => {
try {
handleWsFrame((e as MessageEvent).data, opId);
} catch {
/* swallow */
}
});
ws.addEventListener('close', () => {
events.push({
t: now(),
transport: 'ws',
opIdTail: (opId ?? '').slice(-10),
type: '_WS_CLOSE_',
});
});
return ws;
}
// Preserve prototype + static fields so `instanceof WebSocket` and
// `WebSocket.OPEN` constants still work.
(PatchedWebSocket as unknown as { prototype: WebSocket }).prototype = OrigWS.prototype;
for (const k of Object.keys(OrigWS) as Array<keyof typeof OrigWS>) {
try {
(PatchedWebSocket as any)[k] = (OrigWS as any)[k];
} catch {
/* readonly */
}
}
(['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'] as const).forEach((k) => {
(PatchedWebSocket as any)[k] = (OrigWS as any)[k];
});
w.WebSocket = PatchedWebSocket as unknown as typeof WebSocket;
// ── 2. Patch window.fetch for `/api/agent/stream` (direct-mode SSE) ─
if (!w.__PROBE_ORIG_FETCH) w.__PROBE_ORIG_FETCH = w.fetch.bind(w);
const origFetch = w.__PROBE_ORIG_FETCH;
function isAgentStreamUrl(input: RequestInfo | URL): boolean {
let url = '';
if (typeof input === 'string') url = input;
else if (input instanceof URL) url = input.toString();
else if (input && typeof (input as Request).url === 'string') url = (input as Request).url;
return url.includes('/api/agent/stream');
}
function extractOpIdFromHttpUrl(input: RequestInfo | URL): string | null {
const url = typeof input === 'string' ? input : (input as Request | URL).toString();
const m = url.match(/operationId=([^&]+)/);
return m ? decodeURIComponent(m[1]) : null;
}
function pushFromSSEFrame(rawFrame: string, opId: string | null): void {
const lines = rawFrame.split('\n');
let dataJson = '';
let evtName = 'message';
for (const line of lines) {
if (line.startsWith('event:')) evtName = line.slice(6).trim();
else if (line.startsWith('data:')) dataJson += line.slice(5).trim();
}
if (!dataJson) return;
let parsed: any;
try {
parsed = JSON.parse(dataJson);
} catch {
events.push({
t: now(),
transport: 'sse',
opIdTail: (opId ?? '').slice(-10),
type: '_PARSE_ERROR_',
sseEvent: evtName,
raw: dataJson.length > 400 ? dataJson.slice(0, 400) + '…' : dataJson,
});
return;
}
recordAgentEvent({
transport: 'sse',
opId,
agentEvent: parsed,
eventId: null,
rawLen: dataJson.length,
});
}
async function teeAndDrain(response: Response, opId: string | null): Promise<Response> {
if (!response.body) return response;
const [a, b] = response.body.tee();
void (async () => {
const reader = b.getReader();
const decoder = new TextDecoder();
let buf = '';
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buf.indexOf('\n\n')) !== -1) {
const frame = buf.slice(0, idx);
buf = buf.slice(idx + 2);
if (frame.trim()) pushFromSSEFrame(frame, opId);
}
}
if (buf.trim()) pushFromSSEFrame(buf, opId);
} catch (e: any) {
events.push({
t: now(),
transport: 'sse',
opIdTail: (opId ?? '').slice(-10),
type: '_TEE_ERROR_',
message: String(e?.message ?? e),
});
}
})();
return new Response(a, {
headers: response.headers,
status: response.status,
statusText: response.statusText,
});
}
w.fetch = async function patchedFetch(input: RequestInfo | URL, init?: RequestInit) {
const response = await origFetch(input as any, init);
if (!isAgentStreamUrl(input)) return response;
const opId = extractOpIdFromHttpUrl(input);
const url =
typeof input === 'string'
? input.split('?')[0]
: (input as Request | URL).toString().split('?')[0];
events.push({
t: now(),
transport: 'sse',
opIdTail: (opId ?? '').slice(-10),
type: '_CONNECTED_',
url,
status: response.status,
});
return teeAndDrain(response, opId);
} as typeof fetch;
// ── 3. Wrap store actions (best-effort for "who called replace") ────
// Side-global stash for the original chat-store actions. Re-installs ALWAYS
// rewrap from the originals so updates to the probe body take effect
// without a page reload — using only a `__probeWrapped` flag on the chat
// state object would freeze the first-installed wrapper across re-installs.
declare global {
interface Window {
__PROBE_ORIG_REFRESH_MESSAGES?: any;
__PROBE_ORIG_REPLACE_MESSAGES?: any;
}
}
try {
const chat = w.__LOBE_STORES?.chat?.();
if (chat) {
// First-time install: cache the originals. Re-install: restore from
// the cached originals before wrapping again.
if (!w.__PROBE_ORIG_REFRESH_MESSAGES) w.__PROBE_ORIG_REFRESH_MESSAGES = chat.refreshMessages;
if (!w.__PROBE_ORIG_REPLACE_MESSAGES) w.__PROBE_ORIG_REPLACE_MESSAGES = chat.replaceMessages;
const origRefresh = w.__PROBE_ORIG_REFRESH_MESSAGES;
const origReplace = w.__PROBE_ORIG_REPLACE_MESSAGES;
chat.refreshMessages = origRefresh;
chat.replaceMessages = origReplace;
chat.refreshMessages = async function probeRefresh(this: unknown, ...args: any[]) {
calls.push({
t: now(),
name: 'refreshMessages',
args: { context: args[0] ?? null },
stack: shortStack(),
});
return origRefresh.apply(this, args);
};
chat.replaceMessages = function probeReplace(this: unknown, ...args: any[]) {
const msgs = (args[0] as any[]) ?? [];
const snapshot = msgs.slice(-2).map((m) => ({
id: (m.id ?? '').slice(-8),
role: m.role,
cLen: (m.content ?? '').length,
rLen: (m.reasoning?.content ?? '').length,
updatedAt: m.updatedAt,
}));
calls.push({
t: now(),
name: 'replaceMessages',
args: { count: msgs.length, params: args[1] ?? null, snapshot } as any,
stack: shortStack(),
});
// Pair the call with a mutation row so the analyzer can build a
// single ordered timeline across replaceMessages + dispatchMessage.
const stackTop = shortStack().split(' ← ')[0]?.slice(0, 80);
const last = msgs.at(-1);
const lastSum = last
? {
id: (last.id ?? '').slice(-8),
role: last.role,
cLen: (last.content ?? '').length,
rLen: (last.reasoning?.content ?? '').length,
updatedAt: last.updatedAt,
}
: undefined;
const params: any = args[1] ?? {};
const ctxKey = params.context
? `main_${params.context.agentId ?? '?'}_${
params.context.topicId ? 'tpc_' + params.context.topicId : 'new'
}`.replace('main_tpc_', 'main_') // crude key inference
: '(no-ctx)';
mutations.push({
t: now(),
key: ctxKey,
n: msgs.length,
last: lastSum,
delta: `replaceMessages(action=${params.action ?? '-'}) src=${stackTop ?? '-'}`,
});
return origReplace.apply(this, args);
};
}
} catch (e: any) {
calls.push({ t: now(), name: '_WRAP_ERROR_', error: String(e?.message ?? e) });
}
// ── 3.5. Mutation log — wrap the TWO ChatStore writers (replaceMessages,
// internal_dispatchMessage) to record EVERY dbMessagesMap[key] reference
// change with a one-line "before/after last assistant message" delta. This
// reveals dispatchMessage-driven collapses that the replaceMessages wrap
// alone cannot see.
declare global {
interface Window {
__PROBE_ORIG_DISPATCH_MESSAGE?: any;
}
}
try {
const chat = w.__LOBE_STORES?.chat?.();
if (chat?.internal_dispatchMessage) {
if (!w.__PROBE_ORIG_DISPATCH_MESSAGE)
w.__PROBE_ORIG_DISPATCH_MESSAGE = chat.internal_dispatchMessage;
const origDispatch = w.__PROBE_ORIG_DISPATCH_MESSAGE;
chat.internal_dispatchMessage = origDispatch;
chat.internal_dispatchMessage = function probeDispatch(this: unknown, payload: any, ctx?: any) {
// Snapshot BEFORE — read the would-be target key + last message.
const before = (() => {
try {
const state = w.__LOBE_STORES?.chat?.();
if (!state) return null;
// Replicate state.internal_getConversationContext logic enough to
// resolve a key — but most callers pass operationId on ctx, and
// operationId-keyed lookup needs store internals. Easiest: snapshot
// ALL keys' last-assistant cLen and compare BEFORE vs AFTER below.
const map = state.dbMessagesMap ?? {};
const out: Record<string, any> = {};
for (const k of Object.keys(map)) {
const last = (map[k] ?? []).at(-1);
out[k] = last
? {
id: (last.id ?? '').slice(-8),
cLen: (last.content ?? '').length,
rLen: (last.reasoning?.content ?? '').length,
n: map[k].length,
}
: { n: 0 };
}
return out;
} catch {
return null;
}
})();
const result = origDispatch.apply(this, [payload, ctx]);
// Snapshot AFTER — find which key(s) actually changed.
try {
const state = w.__LOBE_STORES?.chat?.();
if (state && before) {
const map = state.dbMessagesMap ?? {};
for (const k of Object.keys(map)) {
const last = (map[k] ?? []).at(-1);
const beforeSnap = before[k];
const afterSnap = last
? {
id: (last.id ?? '').slice(-8),
cLen: (last.content ?? '').length,
rLen: (last.reasoning?.content ?? '').length,
n: map[k].length,
}
: { n: 0 };
const changed =
!beforeSnap ||
beforeSnap.n !== afterSnap.n ||
beforeSnap.id !== (afterSnap as any).id ||
beforeSnap.cLen !== (afterSnap as any).cLen ||
beforeSnap.rLen !== (afterSnap as any).rLen;
if (!changed) continue;
let delta = '';
if (beforeSnap?.id !== undefined && beforeSnap.id !== (afterSnap as any).id)
delta += `id:${beforeSnap.id}${(afterSnap as any).id};`;
if (
beforeSnap?.cLen !== undefined &&
(afterSnap as any).cLen !== undefined &&
(afterSnap as any).cLen < beforeSnap.cLen
)
delta += `cLen↓${beforeSnap.cLen}${(afterSnap as any).cLen};`;
if (
beforeSnap?.rLen !== undefined &&
(afterSnap as any).rLen !== undefined &&
(afterSnap as any).rLen < beforeSnap.rLen
)
delta += `rLen↓${beforeSnap.rLen}${(afterSnap as any).rLen};`;
if (beforeSnap?.n !== undefined && afterSnap.n < beforeSnap.n)
delta += `n↓${beforeSnap.n}${afterSnap.n};`;
mutations.push({
t: now(),
key: k,
n: afterSnap.n,
last: (afterSnap as any).id ? (afterSnap as any) : undefined,
prevLast: beforeSnap?.id ? beforeSnap : undefined,
delta: delta || `dispatch:${payload?.type}`,
});
}
}
} catch (e: any) {
mutations.push({
t: now(),
key: '_DISPATCH_PROBE_ERROR_',
n: -1,
delta: String(e?.message ?? e),
});
}
return result;
};
}
} catch (e: any) {
calls.push({ t: now(), name: '_DISPATCH_WRAP_ERROR_', error: String(e?.message ?? e) });
}
// ── 4. Periodic per-key timeline snapshots ─────────────────────────
function captureTimeline(): void {
try {
const c = w.__LOBE_STORES?.chat?.();
if (!c) return;
const msgsMap = (c.messagesMap ?? {}) as Record<string, any[]>;
const dbMap = (c.dbMessagesMap ?? {}) as Record<string, any[]>;
const byKey: ProbeTimelineSample['byKey'] = {};
for (const k of Object.keys(msgsMap)) {
const display = msgsMap[k] ?? [];
const db = dbMap[k] ?? [];
if (display.length === 0 && db.length === 0) continue;
byKey[k] = {
n: display.length,
dbN: db.length,
msgs: summarizeMessages(display),
};
}
const ops = Object.values((c.operations ?? {}) as Record<string, any>);
timeline.push({
t: now(),
activeTopic: ((c.activeTopicId as string | null) ?? '').slice(-10) || null,
keys: Object.keys(byKey),
byKey,
runOps: ops.filter((o: any) => o.status === 'running').length,
});
} catch (e: any) {
timeline.push({
t: now(),
activeTopic: null,
keys: [],
byKey: {},
runOps: 0,
err: e?.message ?? String(e),
});
}
}
captureTimeline();
if (w.__PROBE_TIMELINE_TIMER) clearInterval(w.__PROBE_TIMELINE_TIMER);
w.__PROBE_TIMELINE_TIMER = setInterval(captureTimeline, 200);
// ── 5. Tab-switch helpers ──────────────────────────────────────────
function listTopBarTabs(): HTMLElement[] {
return Array.from(
document.querySelectorAll<HTMLElement>(
'[data-insp-path*="TabItem.tsx"][data-contextmenu-trigger]',
),
).filter((t) => t.getBoundingClientRect().top < 30);
}
w.__listTabs = () =>
listTopBarTabs().map((t, i) => ({
i,
key: t.getAttribute('data-contextmenu-trigger'),
active: t.getAttribute('data-active') === 'true',
title: (t.innerText ?? '').slice(0, 60),
}));
w.__clickTabByKey = (key: string) => {
const tab = listTopBarTabs().find((t) => t.getAttribute('data-contextmenu-trigger') === key);
if (!tab) return 'not found: ' + key;
if (tab.getAttribute('data-active') === 'true') return 'already active: ' + key;
tab.click();
return 'clicked key=' + key;
};
w.__PROBE_EVENT = (name: string) => {
calls.push({ t: now(), name: 'MARK:' + name });
};
// `run.ts` wraps the bundle in an IIFE and appends a `return <confirmation>`
// after the bundle body — agent-browser then prints the confirmation back to
// the operator. Nothing to do here at the end of the module body.
@@ -0,0 +1,211 @@
// CLI for the agent-gateway probe.
//
// Bundles the TS probes with esbuild, pipes them into `agent-browser eval`,
// and persists dumps under `.agent-gateway/` (gitignored) for later use as
// streaming-replay test fixtures.
//
// Commands:
// bun run .agents/skills/local-testing/scripts/agent-gateway/run.ts install
// Bundle probe-events.ts and inject into the CDP-attached browser.
// Re-installing clears all buffers and re-patches WebSocket / fetch.
//
// bun run .agents/skills/local-testing/scripts/agent-gateway/run.ts dump [name]
// Stop the timeline timer, fetch the capture as JSON, write it to
// `.agent-gateway/<name>-<YYYYMMDD-HHmmss>.json`. `name` defaults to
// `dump`. Prints the absolute path written.
//
// bun run .agents/skills/local-testing/scripts/agent-gateway/run.ts analyze [path]
// Run analyze-events.ts on the dump. `path` defaults to the most
// recently modified file in `.agent-gateway/`.
//
// Optional flags:
// --cdp <port> CDP port (default 9222)
// --browser <bin> agent-browser binary (default 'agent-browser')
import { spawn } from 'node:child_process';
import { mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
// .agents/skills/local-testing/scripts/agent-gateway/ → 5 levels up
const PROJECT_ROOT = path.resolve(SCRIPT_DIR, '../../../../..');
const DUMP_DIR = path.join(PROJECT_ROOT, '.agent-gateway');
interface Flags {
browser: string;
cdp: string;
positional: string[];
}
function parseFlags(argv: string[]): Flags {
const out: Flags = { cdp: '9222', browser: 'agent-browser', positional: [] };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--cdp') out.cdp = argv[++i] ?? out.cdp;
else if (a === '--browser') out.browser = argv[++i] ?? out.browser;
else out.positional.push(a);
}
return out;
}
async function bundle(entry: string): Promise<string> {
// Bun.build is built into the Bun runtime — no external dep needed.
const r = await Bun.build({
entrypoints: [path.join(SCRIPT_DIR, entry)],
target: 'browser',
format: 'esm',
minify: false,
});
if (!r.success) {
const msgs = r.logs.map((l) => `${l.level}: ${l.message}`).join('\n');
throw new Error(`bundle failed for ${entry}:\n${msgs}`);
}
return await r.outputs[0].text();
}
function wrapIife(body: string, returnExpr: string): string {
// Wrap as an IIFE that swallows the bundled top-level (top-level `const`
// declarations get scoped to the IIFE, so re-injection doesn't conflict)
// and returns the configured expression — which `agent-browser eval`
// captures and prints to stdout.
return `(() => {\n${body}\n;return ${returnExpr};\n})()`;
}
function runAgentBrowserEval(flags: Flags, script: string): Promise<string> {
return new Promise((resolveP, rejectP) => {
const child = spawn(flags.browser, ['--cdp', flags.cdp, 'eval', '--stdin'], {
stdio: ['pipe', 'pipe', 'inherit'],
});
let stdout = '';
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
});
child.on('error', rejectP);
child.on('close', (code) => {
if (code === 0) resolveP(stdout);
else rejectP(new Error(`agent-browser exited ${code}`));
});
child.stdin.write(script);
child.stdin.end();
});
}
// agent-browser prints eval results as JSON (string values are quoted).
function unquoteAgentBrowserResult(raw: string): string {
const trimmed = raw.trim();
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
try {
return JSON.parse(trimmed) as string;
} catch {
/* fall through */
}
}
return trimmed;
}
function isoStamp(): string {
const d = new Date();
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${yyyy}${mm}${dd}-${hh}${mi}${ss}`;
}
function ensureDumpDir(): void {
mkdirSync(DUMP_DIR, { recursive: true });
}
function latestDump(): string | null {
ensureDumpDir();
const entries = readdirSync(DUMP_DIR)
.filter((f) => f.endsWith('.json'))
.map((f) => ({ f, mtime: statSync(path.join(DUMP_DIR, f)).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime);
return entries[0] ? path.join(DUMP_DIR, entries[0].f) : null;
}
// ── Commands ────────────────────────────────────────────────────────
async function cmdInstall(flags: Flags): Promise<void> {
const body = await bundle('probe-events.ts');
const installMsg = JSON.stringify(
'events probe installed: WebSocket+fetch interception. ' +
'WS captures operationId= sockets (gateway), fetch captures /api/agent/stream (direct).',
);
const script = wrapIife(body, installMsg);
const out = await runAgentBrowserEval(flags, script);
console.log(unquoteAgentBrowserResult(out));
}
async function cmdDump(flags: Flags): Promise<void> {
const name = flags.positional[1] ?? 'dump';
const body = await bundle('probe-dump.ts');
const script = wrapIife(body, 'window.__PROBE_LAST_DUMP_JSON');
const raw = await runAgentBrowserEval(flags, script);
const json = unquoteAgentBrowserResult(raw);
ensureDumpDir();
const filename = `${name}-${isoStamp()}.json`;
const dumpPath = path.join(DUMP_DIR, filename);
writeFileSync(dumpPath, json, 'utf8');
// Validate by parsing the meta header so we error early on bad capture
try {
const parsed = JSON.parse(json) as {
meta?: { eventCount?: number; callCount?: number; sampleCount?: number };
};
const meta = parsed.meta ?? {};
console.log(
`wrote ${dumpPath} (${json.length} bytes events=${meta.eventCount ?? '?'} ` +
`calls=${meta.callCount ?? '?'} samples=${meta.sampleCount ?? '?'})`,
);
} catch {
console.log(`wrote ${dumpPath} (${json.length} bytes — JSON.parse failed; see file)`);
}
}
async function cmdAnalyze(flags: Flags): Promise<void> {
const target = flags.positional[1] ?? latestDump();
if (!target) {
console.error('no dump file found. run `dump` first or pass a path.');
process.exit(1);
}
const child = spawn('bun', ['run', path.join(SCRIPT_DIR, 'analyze-events.ts'), target], {
stdio: 'inherit',
});
await new Promise<void>((resolveP, rejectP) => {
child.on('error', rejectP);
child.on('close', (code) => (code === 0 ? resolveP() : rejectP(new Error(`exit ${code}`))));
});
}
// ── Entry point ─────────────────────────────────────────────────────
const flags = parseFlags(process.argv.slice(2));
const cmd = flags.positional[0];
const usage = `usage:
bun run run.ts install [--cdp 9222]
bun run run.ts dump [name] [--cdp 9222]
bun run run.ts analyze [path]
`;
if (!cmd) {
console.error(usage);
process.exit(1);
}
try {
if (cmd === 'install') await cmdInstall(flags);
else if (cmd === 'dump') await cmdDump(flags);
else if (cmd === 'analyze') await cmdAnalyze(flags);
else {
console.error(`unknown command: ${cmd}\n\n${usage}`);
process.exit(1);
}
} catch (e: any) {
console.error(e?.stack ?? e);
process.exit(1);
}
@@ -0,0 +1,113 @@
// Shared types between the in-browser probe and the Node-side analyzer.
// Kept tiny on purpose — anything the analyzer can re-derive is left off.
export interface ProbeStreamEvent {
/** Summarized payload — long strings truncated, arrays printed as Array(N) */
data?: Record<string, unknown>;
/** Keys present on the event's `data` payload — useful at a glance */
dataKeys?: string[];
/** ServerMessage.id — gateway WS frames carry an event-id we may resume from */
eventId?: string | null;
message?: string;
/** Last 10 chars of the operationId (full id is excessively long) */
opIdTail: string;
raw?: string;
/** Raw frame byte length, when applicable */
rawLen?: number;
/** For non-agent_event server frames (auth_success, heartbeat_ack, …) */
serverType?: string;
sseEvent?: string;
status?: number;
stepIndex?: number;
/** Milliseconds since the probe's t0 (install time). */
t: number;
/** 'ws' for gateway WebSocket frames, 'sse' for direct /api/agent/stream */
transport: 'ws' | 'sse';
/** Either the AgentStreamEvent.type, or a probe sentinel like `_WS_OPEN_` */
type: string;
url?: string;
}
export interface ProbeActionCall {
args?: {
count?: number;
context?: unknown;
params?: unknown;
};
error?: string;
/** `replaceMessages` / `refreshMessages` / `MARK:<label>` / `_WRAP_ERROR_` */
name: string;
stack?: string;
t: number;
}
export interface ProbeMessageSummary {
/** children.length */
chN: number;
/** content.length */
cLen: number;
/** Last 8 chars of the message id */
id: string;
/** reasoning.content.length */
rLen: number;
role: string;
/** tools.length */
tools: number;
}
export interface ProbeTimelineSample {
/** Last 10 chars of activeTopicId, or null */
activeTopic: string | null;
/** Per-key breakdown: display count, db count, message summaries */
byKey: Record<
string,
{
n: number;
dbN: number;
msgs: ProbeMessageSummary[];
}
>;
err?: string;
/** All messagesMap keys that have content at this moment */
keys: string[];
/** Number of operations in 'running' status */
runOps: number;
t: number;
}
export interface ProbeDumpMeta {
callCount: number;
/** Date.now() at dump call */
collectedAt: number;
eventCount: number;
sampleCount: number;
/** Date.now() at probe install */
t0: number;
}
export interface ProbeDump {
actionCalls: ProbeActionCall[];
meta: ProbeDumpMeta;
streamEvents: ProbeStreamEvent[];
timeline: ProbeTimelineSample[];
}
/**
* Globals the probe attaches to `window`. Keeps `as any` casts at the boundary
* instead of sprinkling them through the probe body.
*/
declare global {
interface Window {
__clickTabByKey?: (key: string) => string;
__listTabs?: () => Array<{ i: number; key: string | null; active: boolean; title: string }>;
__LOBE_STORES?: Record<string, () => any>;
__PROBE_ACTION_CALLS?: ProbeActionCall[];
__PROBE_EVENT?: (label: string) => void;
__PROBE_MSG_TIMELINE?: ProbeTimelineSample[];
__PROBE_ORIG_FETCH?: typeof fetch;
__PROBE_ORIG_WEBSOCKET?: typeof WebSocket;
__PROBE_STREAM_EVENTS?: ProbeStreamEvent[];
__PROBE_T0?: number;
__PROBE_TIMELINE_TIMER?: ReturnType<typeof setInterval> | null;
}
}
+3
View File
@@ -28,6 +28,9 @@ prd
# Recordings
.records/
# Agent-gateway probe captures (local debugging dumps)
.agent-gateway/
# Temporary files
.temp/
temp/
@@ -200,11 +200,13 @@ const mockShellCommandCtr = {
const mockHeterogeneousAgentCtr = {
sendPrompt: vi.fn().mockResolvedValue(undefined),
spawnLhHeteroExec: vi.fn(),
startSession: vi.fn().mockResolvedValue({ sessionId: 'mock-session-id' }),
} as unknown as HeterogeneousAgentCtr;
const mockRemoteServerConfigCtr = {
getAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
getRemoteServerUrl: vi.fn().mockResolvedValue('https://server.example.com'),
isRemoteServerConfigured: vi.fn().mockResolvedValue(true),
refreshAccessToken: vi.fn().mockResolvedValue({ success: true }),
} as unknown as RemoteServerConfigCtr;
@@ -631,26 +633,23 @@ describe('GatewayConnectionCtr', () => {
}
beforeEach(() => {
vi.mocked(mockHeterogeneousAgentCtr.startSession).mockClear();
vi.mocked(mockHeterogeneousAgentCtr.sendPrompt).mockClear();
vi.mocked(mockHeterogeneousAgentCtr.spawnLhHeteroExec).mockClear();
});
it.each([
['openclaw', 'openclaw'],
['hermes', 'hermes'],
['codex', 'codex'],
['claude-code', 'claude'],
] as const)('uses command "%s" for agentType "%s"', async (agentType, expectedCommand) => {
const client = await connectAndOpen();
client.simulateAgentRunRequest(agentType);
await vi.advanceTimersByTimeAsync(0);
it.each(['openclaw', 'hermes', 'codex', 'claude-code'] as const)(
'forwards agentType "%s" to spawnLhHeteroExec',
async (agentType) => {
const client = await connectAndOpen();
client.simulateAgentRunRequest(agentType);
await vi.advanceTimersByTimeAsync(0);
expect(mockHeterogeneousAgentCtr.startSession).toHaveBeenCalledWith(
expect.objectContaining({ agentType, command: expectedCommand }),
);
});
expect(mockHeterogeneousAgentCtr.spawnLhHeteroExec).toHaveBeenCalledWith(
expect.objectContaining({ agentType }),
);
},
);
it('sends accepted ack and fires sendPrompt', async () => {
it('sends accepted ack and spawns lh hetero exec', async () => {
const client = await connectAndOpen();
client.simulateAgentRunRequest('openclaw', 'op-xyz');
await vi.advanceTimersByTimeAsync(0);
@@ -659,15 +658,37 @@ describe('GatewayConnectionCtr', () => {
operationId: 'op-xyz',
status: 'accepted',
});
expect(mockHeterogeneousAgentCtr.sendPrompt).toHaveBeenCalledWith(
expect.objectContaining({ operationId: 'op-xyz', sessionId: 'mock-session-id' }),
expect(mockHeterogeneousAgentCtr.spawnLhHeteroExec).toHaveBeenCalledWith(
expect.objectContaining({
agentType: 'openclaw',
jwt: 'mock-jwt',
operationId: 'op-xyz',
prompt: 'hello',
serverUrl: 'https://server.example.com',
topicId: 'topic-1',
}),
);
});
it('sends rejected ack when startSession throws', async () => {
vi.mocked(mockHeterogeneousAgentCtr.startSession).mockRejectedValueOnce(
new Error('binary not found'),
);
it('sends rejected ack when remote server URL is not configured', async () => {
vi.mocked(mockRemoteServerConfigCtr.getRemoteServerUrl).mockResolvedValueOnce('');
const client = await connectAndOpen();
client.simulateAgentRunRequest('openclaw', 'op-fail');
await vi.advanceTimersByTimeAsync(0);
expect(client.sendAgentRunAck).toHaveBeenCalledWith({
operationId: 'op-fail',
reason: 'Remote server URL not configured',
status: 'rejected',
});
expect(mockHeterogeneousAgentCtr.spawnLhHeteroExec).not.toHaveBeenCalled();
});
it('sends rejected ack when spawnLhHeteroExec throws', async () => {
vi.mocked(mockHeterogeneousAgentCtr.spawnLhHeteroExec).mockImplementationOnce(() => {
throw new Error('binary not found');
});
const client = await connectAndOpen();
client.simulateAgentRunRequest('openclaw', 'op-fail');
-4
View File
@@ -411,16 +411,12 @@
"rag.userQuery.actions.regenerate": "Regenerate Query",
"regenerate": "Regenerate",
"roleAndArchive": "Agent Profile & History",
"runtimeEnv.device.empty": "No devices available. Connect to gateway from the desktop app first.",
"runtimeEnv.mode.cloud": "Cloud Sandbox",
"runtimeEnv.mode.cloudDesc": "Run in a secure cloud sandbox",
"runtimeEnv.mode.local": "Local",
"runtimeEnv.mode.localDesc": "Access local files and commands",
"runtimeEnv.mode.none": "Off",
"runtimeEnv.mode.noneDesc": "Disable runtime environment",
"runtimeEnv.mode.sandbox": "Sandbox",
"runtimeEnv.mode.sandboxDesc": "Run in an isolated cloud sandbox",
"runtimeEnv.section.device": "Device",
"runtimeEnv.selectMode": "Select Runtime Environment",
"runtimeEnv.title": "Runtime Environment",
"search.grounding.imageSearchQueries": "Image Search Keywords",
-4
View File
@@ -411,16 +411,12 @@
"rag.userQuery.actions.regenerate": "重新生成 Query",
"regenerate": "重新生成",
"roleAndArchive": "助理档案与记录",
"runtimeEnv.device.empty": "暂无可用设备,请先在桌面端连接到网关",
"runtimeEnv.mode.cloud": "云端沙箱",
"runtimeEnv.mode.cloudDesc": "在安全的云端沙箱中运行",
"runtimeEnv.mode.local": "本地",
"runtimeEnv.mode.localDesc": "访问本地文件和命令",
"runtimeEnv.mode.none": "关闭",
"runtimeEnv.mode.noneDesc": "禁用运行时环境",
"runtimeEnv.mode.sandbox": "沙箱",
"runtimeEnv.mode.sandboxDesc": "在隔离的云端沙箱中运行",
"runtimeEnv.section.device": "设备",
"runtimeEnv.selectMode": "选择运行环境",
"runtimeEnv.title": "运行环境",
"search.grounding.imageSearchQueries": "图片搜索关键词",
+3
View File
@@ -34,6 +34,9 @@ export const DEFAULT_AGENT_CHAT_CONFIG: LobeAgentChatConfig = {
reasoningBudgetToken: 1024,
searchFCModel: DEFAULT_AGENT_SEARCH_FC_MODEL,
searchMode: 'auto',
selfIteration: {
enabled: false,
},
};
export const DEFAULT_AGENT_CONFIG: LobeAgentConfig = {
+3 -9
View File
@@ -9,12 +9,11 @@ export type AgentMode = 'auto' | 'plan' | 'ask' | 'implement';
/**
* Runtime environment mode
* - local: Run on a specific device (desktop only, requires deviceId)
* - sandbox: Run in isolated cloud sandbox
* - cloud: @deprecated Use 'sandbox' instead, kept for backward compatibility
* - local: Access local files and commands (desktop only)
* - cloud: Run in cloud sandbox
* - none: No runtime environment
*/
export type RuntimeEnvMode = 'cloud' | 'local' | 'none' | 'sandbox';
export type RuntimeEnvMode = 'cloud' | 'local' | 'none';
export type RuntimePlatform = 'desktop' | 'web';
@@ -22,11 +21,6 @@ export type RuntimePlatform = 'desktop' | 'web';
* Runtime environment configuration
*/
export interface RuntimeEnvConfig {
/**
* Device ID when runtimeMode is 'local' (desktop only).
* Identifies which bound device to run on.
*/
deviceId?: string;
/**
* Runtime environment mode per platform
*/
+1 -2
View File
@@ -170,10 +170,9 @@ export interface LobeAgentChatConfig extends AgentMemoryChatConfig, AgentSelfIte
/**
* Zod schema for RuntimeEnvConfig
*/
const runtimeEnvModeEnum = z.enum(['local', 'cloud', 'none', 'sandbox']);
const runtimeEnvModeEnum = z.enum(['local', 'cloud', 'none']);
export const RuntimeEnvConfigSchema = z.object({
deviceId: z.string().optional(),
runtimeMode: z.record(z.string(), runtimeEnvModeEnum).optional(),
workingDirectory: z.string().optional(),
});
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_AGENT_CONFIG } from '@/const/settings';
import { type Store } from './action';
import { selectors } from './selectors';
describe('AgentSetting selectors', () => {
describe('currentChatConfig', () => {
it('should include disabled self iteration by default', () => {
const state = {
config: DEFAULT_AGENT_CONFIG,
} as Store;
expect(selectors.currentChatConfig(state).selfIteration).toEqual({ enabled: false });
});
});
});
@@ -1,109 +0,0 @@
import { type DeviceAttachment } from '@lobechat/builtin-tool-remote-device';
import { Flexbox, Icon } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { LaptopIcon, MonitorIcon, ServerIcon } from 'lucide-react';
import { memo } from 'react';
const styles = createStaticStyles(({ css }) => ({
deviceName: css`
font-size: 13px;
font-weight: 500;
color: ${cssVar.colorText};
`,
deviceOption: css`
cursor: pointer;
width: 100%;
padding-block: 8px;
padding-inline: 8px;
border-radius: ${cssVar.borderRadius};
transition: background-color 0.2s;
&:hover {
background: ${cssVar.colorFillTertiary};
}
`,
deviceOptionActive: css`
background: ${cssVar.colorFillTertiary};
`,
deviceOptionDesc: css`
font-size: 12px;
color: ${cssVar.colorTextDescription};
`,
deviceOptionIcon: css`
flex-shrink: 0;
border: 1px solid ${cssVar.colorFillTertiary};
border-radius: ${cssVar.borderRadius};
background: ${cssVar.colorBgElevated};
`,
sectionTitle: css`
padding-block: 6px 2px;
padding-inline: 8px;
font-size: 11px;
font-weight: 500;
color: ${cssVar.colorTextQuaternary};
text-transform: uppercase;
letter-spacing: 0.5px;
`,
}));
const PLATFORM_ICONS: Record<string, typeof LaptopIcon> = {
darwin: LaptopIcon,
linux: MonitorIcon,
win32: MonitorIcon,
};
interface DeviceSelectorProps {
activeDeviceId?: string;
devices: DeviceAttachment[];
onSelect: (deviceId: string) => void;
}
export const DeviceSelector = memo<DeviceSelectorProps>(
({ activeDeviceId, devices, onSelect }) => {
return (
<>
{devices.map((device) => {
const IconComp = PLATFORM_ICONS[device.platform] || ServerIcon;
const isActive = activeDeviceId === device.deviceId;
return (
<Flexbox
horizontal
align={'flex-start'}
className={cx(styles.deviceOption, isActive && styles.deviceOptionActive)}
gap={12}
key={device.deviceId}
onClick={() => onSelect(device.deviceId)}
>
<Flexbox
align={'center'}
className={styles.deviceOptionIcon}
height={32}
justify={'center'}
width={32}
>
<Icon icon={IconComp} size={16} />
</Flexbox>
<Flexbox flex={1}>
<div className={styles.deviceName}>{device.hostname}</div>
<div className={styles.deviceOptionDesc}>{device.platform}</div>
</Flexbox>
</Flexbox>
);
})}
</>
);
},
);
DeviceSelector.displayName = 'DeviceSelector';
/** Section header for device/sandbox/none groups */
export const SectionHeader = memo<{ label: string }>(({ label }) => (
<div className={styles.sectionTitle}>{label}</div>
));
SectionHeader.displayName = 'SectionHeader';
+65 -142
View File
@@ -4,7 +4,6 @@ import { Github } from '@lobehub/icons';
import { Flexbox, Icon, Popover, Skeleton, Tooltip } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import {
BoxIcon,
ChevronDownIcon,
CloudIcon,
FolderIcon,
@@ -13,10 +12,9 @@ import {
MonitorOffIcon,
SquircleDashed,
} from 'lucide-react';
import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { memo, type ReactNode, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { deviceService } from '@/services/device';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
@@ -28,7 +26,6 @@ import { useUpdateAgentConfig } from '../hooks/useUpdateAgentConfig';
import { useChatInputStore } from '../store';
import ApprovalMode from './ApprovalMode';
import CloudRepoSwitcher from './CloudRepoSwitcher';
import { DeviceSelector, SectionHeader } from './DeviceSelector';
import GitStatus from './GitStatus';
import ModeSelector from './ModeSelector';
import { useRepoType } from './useRepoType';
@@ -38,7 +35,6 @@ const MODE_ICONS: Record<RuntimeEnvMode, typeof LaptopIcon> = {
cloud: CloudIcon,
local: LaptopIcon,
none: MonitorOffIcon,
sandbox: BoxIcon,
};
const styles = createStaticStyles(({ css }) => ({
@@ -67,11 +63,6 @@ const styles = createStaticStyles(({ css }) => ({
background: ${cssVar.colorFillSecondary};
}
`,
divider: css`
height: 1px;
margin-block: 4px;
background: ${cssVar.colorBorderSecondary};
`,
modeDesc: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
@@ -116,21 +107,16 @@ const RuntimeConfig = memo(() => {
const { updateAgentChatConfig } = useUpdateAgentConfig();
const [dirPopoverOpen, setDirPopoverOpen] = useState(false);
const [modePopoverOpen, setModePopoverOpen] = useState(false);
const [devices, setDevices] = useState<Awaited<ReturnType<typeof deviceService.listDevices>>>([]);
const [devicesLoading, setDevicesLoading] = useState(false);
const showContextWindow = useChatInputStore((s) =>
s.rightActions.flat().includes('contextWindow'),
);
const [isLoading, runtimeMode, isHeterogeneous, enableAgentMode, deviceId] = useAgentStore(
(s) => [
agentByIdSelectors.isAgentConfigLoadingById(agentId)(s),
chatConfigByIdSelectors.getRuntimeModeById(agentId)(s),
agentId ? agentByIdSelectors.isAgentHeterogeneousById(agentId)(s) : false,
agentByIdSelectors.getAgentEnableModeById(agentId)(s),
chatConfigByIdSelectors.getDeviceIdById(agentId)(s),
],
);
const [isLoading, runtimeMode, isHeterogeneous, enableAgentMode] = useAgentStore((s) => [
agentByIdSelectors.isAgentConfigLoadingById(agentId)(s),
chatConfigByIdSelectors.getRuntimeModeById(agentId)(s),
agentId ? agentByIdSelectors.isAgentHeterogeneousById(agentId)(s) : false,
agentByIdSelectors.getAgentEnableModeById(agentId)(s),
]);
const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
const agentWorkingDirectory = useAgentStore((s) =>
@@ -140,17 +126,6 @@ const RuntimeConfig = memo(() => {
const repoType = useRepoType(effectiveWorkingDirectory);
// Fetch device list when popover opens (desktop only)
useEffect(() => {
if (modePopoverOpen && isDesktop) {
setDevicesLoading(true);
deviceService.listDevices().then((list) => {
setDevices(list);
setDevicesLoading(false);
});
}
}, [modePopoverOpen]);
const dirIconNode = useMemo((): ReactNode => {
if (!effectiveWorkingDirectory) return <Icon icon={SquircleDashed} size={14} />;
if (repoType === 'github') return <Github size={14} />;
@@ -159,43 +134,18 @@ const RuntimeConfig = memo(() => {
}, [effectiveWorkingDirectory, repoType]);
const switchMode = useCallback(
async (mode: RuntimeEnvMode, opts?: { deviceId?: string }) => {
if (mode === runtimeMode && opts?.deviceId === deviceId) return;
async (mode: RuntimeEnvMode) => {
if (mode === runtimeMode) return;
const platform = isDesktop ? 'desktop' : 'web';
await updateAgentChatConfig({
runtimeEnv: {
deviceId: opts?.deviceId,
runtimeMode: { [platform]: mode },
},
runtimeEnv: { runtimeMode: { [platform]: mode } },
});
},
[runtimeMode, deviceId, updateAgentChatConfig],
[runtimeMode, updateAgentChatConfig],
);
// Compute the display label for the mode button
const activeDevice = useMemo(
() => (deviceId ? devices.find((d) => d.deviceId === deviceId) : undefined),
[deviceId, devices],
);
const ModeIcon = MODE_ICONS[runtimeMode] || LaptopIcon;
const modeLabel = useMemo(() => {
// When running on a specific device, show device hostname
if (runtimeMode === 'local' && activeDevice) {
return activeDevice.hostname;
}
return t(`runtimeEnv.mode.${runtimeMode}`);
}, [runtimeMode, activeDevice, t]);
const displayName = effectiveWorkingDirectory
? effectiveWorkingDirectory.split('/').findLast(Boolean) || effectiveWorkingDirectory
: tPlugin('localSystem.workingDirectory.notSet');
const hasDevices = devices.length > 0;
// Skeleton placeholder to prevent layout jump during loading
if (!agentId || isLoading) {
return (
@@ -206,93 +156,66 @@ const RuntimeConfig = memo(() => {
);
}
// ─── Popover Content ───
const ModeIcon = MODE_ICONS[runtimeMode];
const modeLabel = t(`runtimeEnv.mode.${runtimeMode}`);
const displayName = effectiveWorkingDirectory
? effectiveWorkingDirectory.split('/').findLast(Boolean) || effectiveWorkingDirectory
: tPlugin('localSystem.workingDirectory.notSet');
const modes: { desc: string; icon: typeof LaptopIcon; label: string; mode: RuntimeEnvMode }[] = [
// Local mode is desktop-only
...(isDesktop
? [
{
desc: t('runtimeEnv.mode.localDesc'),
icon: LaptopIcon,
label: t('runtimeEnv.mode.local'),
mode: 'local' as RuntimeEnvMode,
},
]
: []),
{
desc: t('runtimeEnv.mode.cloudDesc'),
icon: CloudIcon,
label: t('runtimeEnv.mode.cloud'),
mode: 'cloud',
},
{
desc: t('runtimeEnv.mode.noneDesc'),
icon: MonitorOffIcon,
label: t('runtimeEnv.mode.none'),
mode: 'none',
},
];
const modeContent = (
<Flexbox gap={4} style={{ minWidth: 280 }}>
{/* ── Device section (desktop only) ── */}
{isDesktop && (
<>
<SectionHeader label={t('runtimeEnv.section.device')} />
{devicesLoading ? (
<Flexbox paddingBlock={12} paddingInline={8}>
<Skeleton.Button
active
size="small"
style={{ height: 16, marginBottom: 4, width: '60%' }}
/>
<Skeleton.Button active size="small" style={{ height: 12, width: '40%' }} />
</Flexbox>
) : hasDevices ? (
<DeviceSelector
activeDeviceId={deviceId}
devices={devices}
onSelect={(id) => switchMode('local', { deviceId: id })}
/>
) : (
<Flexbox
className={styles.modeOptionDesc}
paddingBlock={8}
paddingInline={8}
>
{t('runtimeEnv.device.empty')}
</Flexbox>
)}
<div className={styles.divider} />
</>
)}
{/* ── Sandbox ── */}
<Flexbox
horizontal
align={'flex-start'}
gap={12}
className={cx(
styles.modeOption,
(runtimeMode === 'sandbox' || runtimeMode === 'cloud') && styles.modeOptionActive,
)}
onClick={() => switchMode('sandbox')}
>
{modes.map(({ mode, icon, label, desc }) => (
<Flexbox
align={'center'}
className={styles.modeOptionIcon}
flex={'none'}
height={32}
justify={'center'}
width={32}
horizontal
align={'flex-start'}
className={cx(styles.modeOption, runtimeMode === mode && styles.modeOptionActive)}
gap={12}
key={mode}
onClick={() => switchMode(mode)}
>
<Icon icon={BoxIcon} />
<Flexbox
align={'center'}
className={styles.modeOptionIcon}
flex={'none'}
height={32}
justify={'center'}
width={32}
>
<Icon icon={icon} />
</Flexbox>
<Flexbox flex={1}>
<div className={styles.modeOptionTitle}>{label}</div>
<div className={styles.modeOptionDesc}>{desc}</div>
</Flexbox>
</Flexbox>
<Flexbox flex={1}>
<div className={styles.modeOptionTitle}>{t('runtimeEnv.mode.sandbox')}</div>
<div className={styles.modeOptionDesc}>{t('runtimeEnv.mode.sandboxDesc')}</div>
</Flexbox>
</Flexbox>
{/* ── Disabled ── */}
<Flexbox
horizontal
align={'flex-start'}
className={cx(styles.modeOption, runtimeMode === 'none' && styles.modeOptionActive)}
gap={12}
onClick={() => switchMode('none')}
>
<Flexbox
align={'center'}
className={styles.modeOptionIcon}
flex={'none'}
height={32}
justify={'center'}
width={32}
>
<Icon icon={MonitorOffIcon} />
</Flexbox>
<Flexbox flex={1}>
<div className={styles.modeOptionTitle}>{t('runtimeEnv.mode.none')}</div>
<div className={styles.modeOptionDesc}>{t('runtimeEnv.mode.noneDesc')}</div>
</Flexbox>
</Flexbox>
))}
</Flexbox>
);
+8 -1
View File
@@ -7,6 +7,7 @@ import { useFetchAgentDocuments } from '@/hooks/useFetchAgentDocuments';
import { useFetchTopicMemories } from '@/hooks/useFetchMemoryForTopic';
import { useFetchNotebookDocuments } from '@/hooks/useFetchNotebookDocuments';
import { useChatStore } from '@/store/chat';
import { operationSelectors } from '@/store/chat/selectors';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useUserStore } from '@/store/user';
import { settingsSelectors } from '@/store/user/selectors';
@@ -84,8 +85,14 @@ const ChatList = memo<ChatListProps>(
s.useFetchMessages,
]);
const activeAgentId = useChatStore((s) => s.activeAgentId);
// Suppress SWR focus revalidate while the current topic is streaming —
// the server-pushed UIChatMessage[] snapshot at step boundaries is the
// source of truth during that window. A focus refetch could hit DB
// mid-fan-out and clobber the in-memory streamed state with a stale
// assistant placeholder.
const isStreaming = useChatStore(operationSelectors.isAgentRuntimeRunningByContext(context));
const { enableAgentSelfIteration } = useServerConfigStore(featureFlagsSelectors);
useFetchMessages(context, skipFetch);
useFetchMessages(context, { revalidateOnFocus: !isStreaming, skipFetch });
const displayMessages = useConversationStore(dataSelectors.displayMessages);
const displayMessageIds = useConversationStore(dataSelectors.displayMessageIds);
const latestMessageId = displayMessageIds.at(-1);
@@ -9,7 +9,9 @@ import ContentBlocksScroll from './ContentBlocksScroll';
import type { RenderableAssistantContentBlock } from './types';
vi.mock('@lobehub/ui', () => ({
Flexbox: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Flexbox: ({ children, gap }: { children?: ReactNode; gap?: number }) => (
<div data-gap={gap}>{children}</div>
),
ScrollArea: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}));
@@ -62,4 +64,20 @@ describe('ContentBlocksScroll', () => {
'true',
);
});
it('uses a consistent gap between workflow blocks', () => {
const { container } = render(
<ContentBlocksScroll
assistantId="assistant-1"
blocks={[
{ content: 'first workflow block', id: 'block-1' },
{ content: 'second workflow block', id: 'block-2' },
]}
scroll={false}
variant="workflow"
/>,
);
expect(container.querySelector('[data-gap="8"]')).toBeInTheDocument();
});
});
@@ -68,7 +68,7 @@ const ContentBlocksScroll = memo<ContentBlocksScrollProps>((props) => {
}, [assistantIdFromProps, blocksFromProps, messagesList]);
const list = (
<Flexbox>
<Flexbox gap={variant === 'workflow' ? 8 : undefined}>
{blocks.map((block) => (
<ContentBlock
key={block.renderKey ?? block.id}
@@ -76,7 +76,7 @@ const ClientTaskItem = memo<ClientTaskItemProps>(({ item }) => {
);
// Fetch thread messages (skip when executing - messages come from real-time updates)
useFetchMessages(threadContext, isProcessing);
useFetchMessages(threadContext, { skipFetch: isProcessing });
// Get thread messages from store using selector
const threadMessages = useChatStore((s) =>
@@ -59,7 +59,7 @@ export const useClientTaskStats = ({
);
// Fetch thread messages (skip when disabled or no threadId)
useFetchMessages(threadContext, !enabled || !threadId);
useFetchMessages(threadContext, { skipFetch: !enabled || !threadId });
// Get thread messages from store using selector
const threadMessages = useChatStore((s) =>
@@ -54,7 +54,7 @@ const ClientTaskDetail = memo<ClientTaskDetailProps>(
);
// Fetch thread messages (skip when executing - messages come from real-time updates)
useFetchMessages(threadContext, isExecuting);
useFetchMessages(threadContext, { skipFetch: isExecuting });
// Get thread messages from store using selector
const threadMessages = useChatStore((s) =>
@@ -61,7 +61,7 @@ const ClientTaskItem = memo<ClientTaskItemProps>(({ item }) => {
);
// Fetch thread messages (skip when executing - messages come from real-time updates)
useFetchMessages(threadContext, isProcessing);
useFetchMessages(threadContext, { skipFetch: isProcessing });
// Get thread messages from store using selector
const threadMessages = useChatStore((s) =>
@@ -59,7 +59,7 @@ export const useClientTaskStats = ({
);
// Fetch thread messages (skip when disabled or no threadId)
useFetchMessages(threadContext, !enabled || !threadId);
useFetchMessages(threadContext, { skipFetch: !enabled || !threadId });
// Get thread messages from store using selector
const threadMessages = useChatStore((s) =>
@@ -1,4 +1,7 @@
import { HETEROGENEOUS_TYPE_LABELS } from '@lobechat/heterogeneous-agents';
import {
HETEROGENEOUS_TYPE_LABELS,
isRemoteHeterogeneousType,
} from '@lobechat/heterogeneous-agents';
import { type ModelPerformance, type ModelUsage } from '@lobechat/types';
import { ModelIcon } from '@lobehub/icons';
import { Center, Flexbox } from '@lobehub/ui';
@@ -33,7 +36,14 @@ const Usage = memo<UsageProps>(({ model, usage, performance, provider }) => {
if (!isDev && onboardingAgentId && conversationAgentId === onboardingAgentId) return null;
const heteroName = provider ? HETEROGENEOUS_TYPE_LABELS[provider] : undefined;
// Only remote platform agents (openclaw, hermes) replace the model name with
// the brand label — they don't expose a real model id. Local CLI agents
// (claude-code, codex) report their actual model on `turn_metadata` and
// should keep showing it.
const heteroName =
provider && isRemoteHeterogeneousType(provider)
? HETEROGENEOUS_TYPE_LABELS[provider]
: undefined;
return (
<Flexbox
@@ -6,6 +6,8 @@ import { type StateCreator } from 'zustand/vanilla';
import { useClientDataSWRWithSync } from '@/libs/swr';
import { messageService } from '@/services/message';
import { getChatStoreState } from '@/store/chat';
import { operationSelectors } from '@/store/chat/selectors';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
import { type Store as ConversationStore } from '../../action';
@@ -67,14 +69,17 @@ export interface DataAction {
switchMessageBranch: (messageId: string, branchIndex: number) => Promise<void>;
/**
* Fetch messages for this conversation using SWR
* Fetch messages for this conversation using SWR.
*
* @param context - Conversation context with sessionId and topicId
* @param skipFetch - When true, SWR key is null and no fetch occurs
* @param options.skipFetch - When true, SWR key is null and no fetch occurs
* @param options.revalidateOnFocus - Override SWR's default focus revalidate.
* Pass `false` while a streaming flow owns the in-memory message state so
* a focus refetch doesn't clobber it with a stale DB snapshot.
*/
useFetchMessages: (
context: ConversationContext,
skipFetch?: boolean,
options?: { revalidateOnFocus?: boolean; skipFetch?: boolean },
) => SWRResponse<UIChatMessage[]>;
}
@@ -184,7 +189,8 @@ export const dataSlice: StateCreator<
await state.updateMessageMetadata(message.parentId, { activeBranchIndex: branchIndex });
},
useFetchMessages: (context, skipFetch) => {
useFetchMessages: (context, options) => {
const { skipFetch, revalidateOnFocus } = options ?? {};
// When skipFetch is true, SWR key is null - no fetch occurs
// This is used when external messages are provided (e.g., creating new thread)
// Also skip fetch when topicId is null (new conversation state) - there's no server data,
@@ -206,10 +212,27 @@ export const dataSlice: StateCreator<
() => messageService.getMessages(context),
{
...(revalidateOnFocus !== undefined && { revalidateOnFocus }),
onData: (data) => {
if (!data) return;
if (!context.topicId) return;
// Defense-in-depth gate (LOBE-9501): drop any SWR onData while the
// topic is streaming. DB fan-out for chunk writes is async and lags
// the WS push by anywhere from 100ms to several seconds; an SWR
// refetch that lands inside that window returns the assistant row
// as the LOADING_FLAT placeholder (cLen=3) and would collapse the
// in-memory streamed content. SWR's own cache still receives the
// value, so once streaming ends a normal revalidate writes through.
//
// This is the catch-all backstop sitting BELOW the SoT consumption
// in gatewayEventHandler — `mergeFetchedMessagesWithLocalState`'s
// updatedAt tie-breaker handles most cases on its own, but the
// updatedAt comparison degenerates when server's pushed snapshot
// carries a DB updatedAt equal to a later stale fetch's row.
if (operationSelectors.isAgentRuntimeRunningByContext(context)(getChatStoreState()))
return;
const prevDbMessages = get().dbMessages;
const mergedMessages = mergeFetchedMessagesWithLocalState(data, prevDbMessages);
const storeContextKey = messageMapKey(get().context);
@@ -64,7 +64,7 @@ const ShareDataProvider = memo<PropsWithChildren<ShareDataProviderProps>>(
}, [activeAgentId, activeGroupId, activeThreadId, activeTopicId, context]);
const shouldSkipFetch = !resolvedContext.agentId || !resolvedContext.topicId;
const { isLoading } = useFetchMessages(resolvedContext, shouldSkipFetch);
const { isLoading } = useFetchMessages(resolvedContext, { skipFetch: shouldSkipFetch });
const messageKey = useMemo(() => {
if (!resolvedContext.agentId) return undefined;
+15 -11
View File
@@ -18,6 +18,8 @@ import { LOBE_THEME_NEUTRAL_COLOR, LOBE_THEME_PRIMARY_COLOR } from '@/const/them
import { isDesktop } from '@/const/version';
import { useIsDark } from '@/hooks/useIsDark';
import { getUILocaleAndResources } from '@/libs/getUILocaleAndResources';
import type { UILocaleResources } from '@/libs/getUILocaleAndResources.utils';
import { resolveUILocale } from '@/libs/getUILocaleAndResources.utils';
import Image from '@/libs/next/Image';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
@@ -115,20 +117,22 @@ const AppTheme = memo<AppThemeProps>(
[messageTop],
);
const [uiResources, setUIResources] = useState<any>(null);
const uiLocale = useMemo(() => {
if (language.startsWith('zh')) return 'zh-CN';
if (language.startsWith('en')) return 'en-US';
return 'en-US';
}, [language]);
const [uiResources, setUIResources] = useState<UILocaleResources>();
const [uiLocale, setUILocale] = useState(() => resolveUILocale(language).uiLocale);
useEffect(() => {
let mounted = true;
getUILocaleAndResources(language).then(({ resources }) => {
if (mounted) {
setUIResources(resources);
}
});
setUILocale(resolveUILocale(language).uiLocale);
getUILocaleAndResources(language)
.then(({ locale, resources }) => {
if (mounted) {
setUILocale(locale);
setUIResources(resources);
}
})
.catch((error) => {
console.error('Failed to load UI locale resources:', error);
});
return () => {
mounted = false;
};
+16 -18
View File
@@ -1,24 +1,23 @@
import { en, zhCn } from '@lobehub/ui/es/i18n/resources/index';
import { normalizeLocale } from '@/locales/resources';
type UILocaleResources = Record<string, Record<string, string>>;
import type { UILocaleResourceInput, UILocaleResources } from './getUILocaleAndResources.utils';
import {
mergeUILocaleResources,
normalizeUILocaleResources,
resolveUILocale,
} from './getUILocaleAndResources.utils';
// eager: true — UI locale fully inlined at build time
const uiLocaleModules = import.meta.glob<{ default: UILocaleResources }>('/locales/*/ui.json', {
const uiLocaleModules = import.meta.glob<{ default: UILocaleResourceInput }>('/locales/*/ui.json', {
eager: true,
});
const getUILocale = (locale: string): string => {
if (locale.startsWith('zh')) return 'zh-CN';
if (locale.startsWith('en')) return 'en-US';
return locale;
};
const loadBusinessResources = (locale: string): UILocaleResources | null => {
const key = `/locales/${locale}/ui.json`;
const mod = uiLocaleModules[key];
return mod ? (mod.default as UILocaleResources) : null;
const resources = mod?.default as UILocaleResourceInput | null | undefined;
return resources ? normalizeUILocaleResources(resources) : null;
};
const loadLobeUIBuiltinResources = (locale: string): UILocaleResources | null => {
@@ -29,15 +28,14 @@ const loadLobeUIBuiltinResources = (locale: string): UILocaleResources | null =>
export const getUILocaleAndResources = async (
locale: string | 'auto',
): Promise<{ locale: string; resources: UILocaleResources }> => {
const effectiveLocale = locale === 'auto' ? 'en-US' : locale;
const normalizedLocale = normalizeLocale(effectiveLocale);
const uiLocale = getUILocale(normalizedLocale);
const { normalizedLocale, uiLocale } = resolveUILocale(locale);
const resources =
loadBusinessResources(normalizedLocale) ??
loadLobeUIBuiltinResources(normalizedLocale) ??
loadBusinessResources('en-US') ??
loadLobeUIBuiltinResources('en-US');
mergeUILocaleResources(
loadLobeUIBuiltinResources(normalizedLocale),
loadBusinessResources(normalizedLocale),
) ??
mergeUILocaleResources(loadLobeUIBuiltinResources('en-US'), loadBusinessResources('en-US'));
if (!resources)
throw new Error(
+41 -4
View File
@@ -2,6 +2,11 @@ import { describe, expect, it, vi } from 'vitest';
import { getUILocaleAndResources } from './getUILocaleAndResources';
const translateFromUILocaleResources = (
resources: Record<string, Record<string, string>>,
key: string,
) => Object.assign({}, ...Object.values(resources))[key];
describe('getUILocaleAndResources', () => {
it('should return zh-CN locale and zhCn resources for zh-CN', async () => {
const result = await getUILocaleAndResources('zh-CN');
@@ -9,6 +14,30 @@ describe('getUILocaleAndResources', () => {
expect(result.resources).toBeDefined();
});
it('should normalize business ui.json into a @lobehub/ui consumable resource map', async () => {
const result = await getUILocaleAndResources('zh-CN');
expect(translateFromUILocaleResources(result.resources, 'form.submit')).toBe('提交');
});
it('should merge built-in resources with partial business ui.json resources', async () => {
const result = await getUILocaleAndResources('zh-CN');
expect(translateFromUILocaleResources(result.resources, 'image.copy')).toBe('复制');
expect(translateFromUILocaleResources(result.resources, 'hotkey.clear')).toBe('清除绑定');
expect(translateFromUILocaleResources(result.resources, 'form.submit')).toBe('提交');
});
it('should merge en built-in fallback resources for non-en/zh partial business ui.json resources', async () => {
const result = await getUILocaleAndResources('de-DE');
expect(result.locale).toBe('de-DE');
expect(translateFromUILocaleResources(result.resources, 'image.copy')).toBe('Copy');
expect(translateFromUILocaleResources(result.resources, 'hotkey.clear')).toBe('Clear binding');
expect(translateFromUILocaleResources(result.resources, 'common.empty')).toBe('(empty)');
expect(translateFromUILocaleResources(result.resources, 'form.submit')).toBe('Absenden');
});
it('should return zh-CN locale and zhCn resources for zh-TW', async () => {
const result = await getUILocaleAndResources('zh-TW');
expect(result.locale).toBe('zh-CN');
@@ -27,10 +56,18 @@ describe('getUILocaleAndResources', () => {
expect(result.resources).toBeDefined();
});
it('should return en-US locale and en resources for auto', async () => {
const result = await getUILocaleAndResources('auto');
expect(result.locale).toBe('en-US');
expect(result.resources).toBeDefined();
it('should resolve auto from the current document language', async () => {
const previousLang = document.documentElement.lang;
document.documentElement.lang = 'zh-CN';
try {
const result = await getUILocaleAndResources('auto');
expect(result.locale).toBe('zh-CN');
expect(translateFromUILocaleResources(result.resources, 'form.submit')).toBe('提交');
} finally {
document.documentElement.lang = previousLang;
}
});
it('should return ar locale and custom resources for ar', async () => {
+18 -21
View File
@@ -1,17 +1,16 @@
import { normalizeLocale } from '@/locales/resources';
type UILocaleResources = Record<string, Record<string, string>>;
const getUILocale = (locale: string): string => {
if (locale.startsWith('zh')) return 'zh-CN';
if (locale.startsWith('en')) return 'en-US';
return locale;
};
import type { UILocaleResourceInput, UILocaleResources } from './getUILocaleAndResources.utils';
import {
mergeUILocaleResources,
normalizeUILocaleResources,
resolveUILocale,
} from './getUILocaleAndResources.utils';
const loadBusinessResources = async (locale: string): Promise<UILocaleResources | null> => {
try {
const resourcesModule = await import(`@/../locales/${locale}/ui.json`);
return resourcesModule.default as UILocaleResources;
const resources = resourcesModule.default as UILocaleResourceInput | null;
return resources ? normalizeUILocaleResources(resources) : null;
} catch {
return null;
}
@@ -31,19 +30,17 @@ const loadLobeUIBuiltinResources = async (locale: string): Promise<UILocaleResou
export const getUILocaleAndResources = async (
locale: string | 'auto',
): Promise<{ locale: string; resources: UILocaleResources }> => {
const effectiveLocale = locale === 'auto' ? 'en-US' : locale;
const normalizedLocale = normalizeLocale(effectiveLocale);
const uiLocale = getUILocale(normalizedLocale);
const { normalizedLocale, uiLocale } = resolveUILocale(locale);
// Priority:
// 1) business-defined ui.json
// 2) @lobehub/ui built-in resources (en/zh)
// 3) fallback to default en
const resources =
(await loadBusinessResources(normalizedLocale)) ??
(await loadLobeUIBuiltinResources(normalizedLocale)) ??
(await loadBusinessResources('en-US')) ??
(await loadLobeUIBuiltinResources('en-US'));
mergeUILocaleResources(
await loadLobeUIBuiltinResources(normalizedLocale),
await loadBusinessResources(normalizedLocale),
) ??
mergeUILocaleResources(
await loadLobeUIBuiltinResources('en-US'),
await loadBusinessResources('en-US'),
);
if (!resources)
throw new Error(
+60
View File
@@ -0,0 +1,60 @@
import { DEFAULT_LANG } from '@/const/locale';
import { normalizeLocale } from '@/locales/resources';
export type UILocaleResourceBundle = Record<string, string>;
export type UILocaleResources = Record<string, UILocaleResourceBundle>;
export type UILocaleResourceInput = UILocaleResourceBundle | UILocaleResources;
const getDocumentLocale = () => {
if (typeof document === 'undefined') return;
return document.documentElement.lang || undefined;
};
const getNavigatorLocale = () => {
if (typeof navigator === 'undefined') return;
return navigator.language || undefined;
};
const getUILocale = (locale: string): string => {
if (locale.startsWith('zh')) return 'zh-CN';
if (locale.startsWith('en')) return 'en-US';
return locale;
};
const isFlatUILocaleResources = (
resources: UILocaleResourceInput,
): resources is UILocaleResourceBundle =>
Object.values(resources).every((value) => typeof value === 'string');
const flattenUILocaleResources = (resources: UILocaleResourceInput): UILocaleResourceBundle =>
isFlatUILocaleResources(resources) ? resources : Object.assign({}, ...Object.values(resources));
export const normalizeUILocaleResources = (
resources: UILocaleResourceInput,
): UILocaleResources => ({
app: flattenUILocaleResources(resources),
});
export const mergeUILocaleResources = (
...resourcesList: (UILocaleResourceInput | null)[]
): UILocaleResources | null => {
const mergedResources = Object.assign(
{},
...resourcesList.filter(Boolean).map((resources) => flattenUILocaleResources(resources!)),
);
return Object.keys(mergedResources).length > 0 ? { app: mergedResources } : null;
};
export const resolveUILocale = (locale: string | 'auto') => {
const effectiveLocale =
locale === 'auto' ? (getDocumentLocale() ?? getNavigatorLocale() ?? DEFAULT_LANG) : locale;
const normalizedLocale = normalizeLocale(effectiveLocale);
return {
normalizedLocale,
uiLocale: getUILocale(normalizedLocale),
};
};
+19 -18
View File
@@ -1,14 +1,11 @@
import { normalizeLocale } from '@/locales/resources';
import type { UILocaleResourceInput, UILocaleResources } from './getUILocaleAndResources.utils';
import {
mergeUILocaleResources,
normalizeUILocaleResources,
resolveUILocale,
} from './getUILocaleAndResources.utils';
type UILocaleResources = Record<string, Record<string, string>>;
const uiLocaleLoaders = import.meta.glob<{ default: UILocaleResources }>('/locales/*/ui.json');
const getUILocale = (locale: string): string => {
if (locale.startsWith('zh')) return 'zh-CN';
if (locale.startsWith('en')) return 'en-US';
return locale;
};
const uiLocaleLoaders = import.meta.glob<{ default: UILocaleResourceInput }>('/locales/*/ui.json');
const loadBusinessResources = async (locale: string): Promise<UILocaleResources | null> => {
const key = `/locales/${locale}/ui.json`;
@@ -16,7 +13,9 @@ const loadBusinessResources = async (locale: string): Promise<UILocaleResources
if (!loader) return null;
try {
const mod = await loader();
return mod.default as UILocaleResources;
const resources = mod.default as UILocaleResourceInput | null;
return resources ? normalizeUILocaleResources(resources) : null;
} catch {
return null;
}
@@ -36,15 +35,17 @@ const loadLobeUIBuiltinResources = async (locale: string): Promise<UILocaleResou
export const getUILocaleAndResources = async (
locale: string | 'auto',
): Promise<{ locale: string; resources: UILocaleResources }> => {
const effectiveLocale = locale === 'auto' ? 'en-US' : locale;
const normalizedLocale = normalizeLocale(effectiveLocale);
const uiLocale = getUILocale(normalizedLocale);
const { normalizedLocale, uiLocale } = resolveUILocale(locale);
const resources =
(await loadBusinessResources(normalizedLocale)) ??
(await loadLobeUIBuiltinResources(normalizedLocale)) ??
(await loadBusinessResources('en-US')) ??
(await loadLobeUIBuiltinResources('en-US'));
mergeUILocaleResources(
await loadLobeUIBuiltinResources(normalizedLocale),
await loadBusinessResources(normalizedLocale),
) ??
mergeUILocaleResources(
await loadLobeUIBuiltinResources('en-US'),
await loadBusinessResources('en-US'),
);
if (!resources)
throw new Error(
+2
View File
@@ -15,6 +15,8 @@ const lobeHubOnlineModelLocales = {
'grok-4.20-beta-0309-non-reasoning.description': 'A non-reasoning variant for simple use cases',
'MiniMax-M2.1-Lightning.description':
'Powerful multilingual programming capabilities with faster and more efficient inference.',
'qwen3.7-max.description':
"Qwen3.7-Max is Alibaba Cloud's flagship agent-era model for complex coding, reasoning, office automation, and long-horizon autonomous workflows.",
'seedream-5-0-260128.description':
'ByteDance-Seedream-5.0-lite by BytePlus features web-retrieval-augmented generation for real-time information, enhanced complex prompt interpretation, and improved reference consistency for professional visual creation.',
'fal-ai/bytedance/seedream/v4.5.description':
@@ -0,0 +1,120 @@
import { render, screen } from '@testing-library/react';
import type { PropsWithChildren, ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ChatSettingsTabs } from '@/store/global/initialState';
import Content from './Content';
const mocks = vi.hoisted(() => ({
agentState: {
activeAgentId: 'inbox-agent',
config: {},
isInbox: true,
meta: {},
optimisticUpdateAgentConfig: vi.fn(),
optimisticUpdateAgentMeta: vi.fn(),
},
serverState: {
featureFlags: {
enableAgentSelfIteration: true,
},
},
}));
vi.mock('@lobehub/ui', () => ({
Avatar: () => <div data-testid="avatar" />,
Block: ({ children }: PropsWithChildren) => <div>{children}</div>,
Flexbox: ({ children }: PropsWithChildren) => <div>{children}</div>,
Icon: () => <span />,
Text: ({ children }: PropsWithChildren) => <span>{children}</span>,
}));
vi.mock('@/components/Menu', () => ({
default: ({
items = [],
onClick,
selectedKeys = [],
}: {
items?: { key?: string; label?: ReactNode }[];
onClick?: ({ key }: { key: string }) => void;
selectedKeys?: string[];
}) => (
<div data-selected={selectedKeys.join(',')} data-testid="agent-settings-menu">
{items.map((item) => (
<button
key={item.key}
type="button"
onClick={() => item.key && onClick?.({ key: item.key })}
>
{item.label}
</button>
))}
</div>
),
}));
vi.mock('@/features/AgentSetting', () => ({
AgentSettings: ({ tab }: { tab: ChatSettingsTabs }) => (
<div data-tab={tab} data-testid="agent-settings-content" />
),
}));
vi.mock('@/store/agent', () => {
const useAgentStore = (selector: (state: typeof mocks.agentState) => unknown) =>
selector(mocks.agentState);
useAgentStore.getState = () => mocks.agentState;
return { useAgentStore };
});
vi.mock('@/store/agent/selectors', () => ({
agentSelectors: {
currentAgentConfig: (state: typeof mocks.agentState) => state.config,
currentAgentMeta: (state: typeof mocks.agentState) => state.meta,
},
builtinAgentSelectors: {
isInboxAgent: (state: typeof mocks.agentState) => state.isInbox,
},
}));
vi.mock('@/store/serverConfig', () => ({
featureFlagsSelectors: (state: typeof mocks.serverState) => state.featureFlags,
useServerConfigStore: (selector: (state: typeof mocks.serverState) => unknown) =>
selector(mocks.serverState),
}));
vi.mock('antd-style', () => ({
useTheme: () => ({
colorBgLayout: '#fff',
colorBorderSecondary: '#eee',
}),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe('AgentSettings Content', () => {
beforeEach(() => {
mocks.agentState.isInbox = true;
mocks.serverState.featureFlags.enableAgentSelfIteration = true;
});
it('should select self iteration when inbox hides opening settings', () => {
render(<Content />);
expect(screen.queryByRole('button', { name: 'agentTab.opening' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'agentTab.selfIteration' })).toBeInTheDocument();
expect(screen.getByTestId('agent-settings-menu')).toHaveAttribute(
'data-selected',
ChatSettingsTabs.SelfIteration,
);
expect(screen.getByTestId('agent-settings-content')).toHaveAttribute(
'data-tab',
ChatSettingsTabs.SelfIteration,
);
});
});
@@ -5,7 +5,7 @@ import { type ItemType } from 'antd/es/menu/interface';
import { useTheme } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { ActivityIcon, MessageSquareHeartIcon } from 'lucide-react';
import { memo, useMemo, useState } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { shallow } from 'zustand/shallow';
@@ -29,6 +29,21 @@ const Content = memo(() => {
const { enableAgentSelfIteration } = useServerConfigStore(featureFlagsSelectors);
const [tab, setTab] = useState(ChatSettingsTabs.Opening);
const availableTabs = useMemo(
() =>
[
!isInbox ? ChatSettingsTabs.Opening : null,
enableAgentSelfIteration ? ChatSettingsTabs.SelfIteration : null,
].filter(Boolean) as ChatSettingsTabs[],
[isInbox, enableAgentSelfIteration],
);
const activeTab = availableTabs.includes(tab) ? tab : availableTabs[0];
useEffect(() => {
if (activeTab && activeTab !== tab) setTab(activeTab);
}, [activeTab, tab]);
const updateAgentConfig = async (config: any) => {
if (!agentId) return;
await useAgentStore.getState().optimisticUpdateAgentConfig(agentId, config);
@@ -41,23 +56,30 @@ const Content = memo(() => {
const menuItems: ItemType[] = useMemo(
() =>
[
!isInbox
? {
icon: <Icon icon={MessageSquareHeartIcon} />,
key: ChatSettingsTabs.Opening,
label: t('agentTab.opening'),
availableTabs
.map((tab) => {
switch (tab) {
case ChatSettingsTabs.Opening: {
return {
icon: <Icon icon={MessageSquareHeartIcon} />,
key: ChatSettingsTabs.Opening,
label: t('agentTab.opening'),
};
}
: null,
enableAgentSelfIteration
? {
icon: <Icon icon={ActivityIcon} />,
key: ChatSettingsTabs.SelfIteration,
label: t('agentTab.selfIteration'),
case ChatSettingsTabs.SelfIteration: {
return {
icon: <Icon icon={ActivityIcon} />,
key: ChatSettingsTabs.SelfIteration,
label: t('agentTab.selfIteration'),
};
}
: null,
].filter(Boolean) as ItemType[],
[t, isInbox, enableAgentSelfIteration],
default: {
return null;
}
}
})
.filter(Boolean) as ItemType[],
[availableTabs, t],
);
const displayTitle = isInbox ? 'Lobe AI' : meta.title || t('defaultSession', { ns: 'common' });
@@ -105,7 +127,7 @@ const Content = memo(() => {
<Menu
selectable
items={menuItems}
selectedKeys={[tab]}
selectedKeys={activeTab ? [activeTab] : []}
style={{ width: '100%' }}
onClick={({ key }) => setTab(key as ChatSettingsTabs)}
/>
@@ -116,15 +138,17 @@ const Content = memo(() => {
paddingInline={64}
style={{ overflow: 'scroll', width: '100%' }}
>
<Settings
config={config}
id={agentId}
loading={false}
meta={meta}
tab={tab}
onConfigChange={updateAgentConfig}
onMetaChange={updateAgentMeta}
/>
{activeTab && (
<Settings
config={config}
id={agentId}
loading={false}
meta={meta}
tab={activeTab}
onConfigChange={updateAgentConfig}
onMetaChange={updateAgentMeta}
/>
)}
</Flexbox>
</Flexbox>
);
@@ -1,45 +0,0 @@
import isEqual from 'fast-deep-equal';
import { memo, useCallback, useState } from 'react';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import PublishButton from './PublishButton';
import PublishResultModal from './PublishResultModal';
/**
* Agent Publish Button Component
*
* Simplified version - backend now handles ownership check automatically.
* The action type (submit vs upload) is determined by backend based on:
* 1. Whether the identifier exists
* 2. Whether the current user is the owner
*/
const AgentPublishButton = memo(() => {
const meta = useAgentStore(agentSelectors.currentAgentMeta, isEqual);
const [showResultModal, setShowResultModal] = useState(false);
const [publishedIdentifier, setPublishedIdentifier] = useState<string>();
const handlePublishSuccess = useCallback((identifier: string) => {
setPublishedIdentifier(identifier);
setShowResultModal(true);
}, []);
// Determine action based on whether we have an existing marketIdentifier
// Backend will verify ownership and decide to create new or update
const action = meta?.marketIdentifier ? 'upload' : 'submit';
return (
<>
<PublishButton action={action} onPublishSuccess={handlePublishSuccess} />
<PublishResultModal
identifier={publishedIdentifier}
open={showResultModal}
onCancel={() => setShowResultModal(false)}
/>
</>
);
});
export default AgentPublishButton;
@@ -0,0 +1,214 @@
import { render, screen } from '@testing-library/react';
import type { PropsWithChildren, ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import Header from './index';
const mocks = vi.hoisted(() => ({
agentState: {
activeAgentId: 'agent-1',
canCurrentAgentPublishToCommunity: true,
isCurrentAgentHeterogeneous: false,
meta: {
title: 'Test Agent',
},
systemRole: 'You are helpful.',
},
globalState: {
isStatusInit: true,
showAgentBuilderPanel: false,
toggleAgentBuilderPanel: vi.fn(),
},
homeState: {
removeAgent: vi.fn(),
},
marketAuth: {
isAuthenticated: true,
isLoading: false,
signIn: vi.fn(),
},
marketPublish: {
checkOwnership: vi.fn(),
isPublishing: false,
publish: vi.fn(),
},
navigate: vi.fn(),
versionReviewStatus: {
isUnderReview: false,
},
}));
vi.mock('@lobehub/ui', () => ({
ActionIcon: () => <button aria-label="more" type="button" />,
DropdownMenu: ({
children,
items = [],
}: PropsWithChildren<{
items?: Array<{ key?: string; label?: ReactNode; type?: string }>;
}>) => (
<div>
{children}
<div data-testid="agent-profile-menu">
{items
.filter((item) => item.type !== 'divider')
.map((item) => (
<button key={item.key} type="button">
{item.label}
</button>
))}
</div>
</div>
),
Flexbox: ({ children }: PropsWithChildren) => <div>{children}</div>,
Icon: () => <span />,
}));
vi.mock('@lobehub/ui/icons', () => ({
ShapesUploadIcon: () => null,
}));
vi.mock('antd', () => ({
App: {
useApp: () => ({
modal: {
confirm: vi.fn(),
},
}),
},
Modal: {
confirm: vi.fn(),
},
}));
vi.mock('lucide-react', () => ({
BotMessageSquareIcon: () => null,
MoreHorizontal: () => null,
Settings2Icon: () => null,
Trash: () => null,
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('react-router-dom', () => ({
useNavigate: () => mocks.navigate,
}));
vi.mock('@/components/AntdStaticMethods', () => ({
message: {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
},
}));
vi.mock('@/const/layoutTokens', () => ({
DESKTOP_HEADER_ICON_SMALL_SIZE: 24,
}));
vi.mock('@/features/NavHeader', () => ({
default: ({ left, right }: { left?: ReactNode; right?: ReactNode }) => (
<header>
{left}
{right}
</header>
),
}));
vi.mock('@/features/RightPanel/ToggleRightPanelButton', () => ({
default: () => <button type="button">agentBuilder</button>,
}));
vi.mock('@/layout/AuthProvider/MarketAuth', () => ({
useMarketAuth: () => mocks.marketAuth,
}));
vi.mock('@/layout/AuthProvider/MarketAuth/errors', () => ({
resolveMarketAuthError: () => ({ code: 'unknown' }),
}));
vi.mock('@/store/agent', () => ({
useAgentStore: (selector: (state: typeof mocks.agentState) => unknown) =>
selector(mocks.agentState),
}));
vi.mock('@/store/agent/selectors', () => ({
agentSelectors: {
canCurrentAgentPublishToCommunity: (state: typeof mocks.agentState) =>
state.canCurrentAgentPublishToCommunity,
currentAgentMeta: (state: typeof mocks.agentState) => state.meta,
currentAgentSystemRole: (state: typeof mocks.agentState) => state.systemRole,
isCurrentAgentHeterogeneous: (state: typeof mocks.agentState) =>
state.isCurrentAgentHeterogeneous,
},
}));
vi.mock('@/store/global', () => ({
useGlobalStore: (selector: (state: typeof mocks.globalState) => unknown) =>
selector(mocks.globalState),
}));
vi.mock('@/store/global/selectors', () => ({
systemStatusSelectors: {
isStatusInit: (state: typeof mocks.globalState) => state.isStatusInit,
showAgentBuilderPanel: (state: typeof mocks.globalState) => state.showAgentBuilderPanel,
},
}));
vi.mock('@/store/home', () => ({
useHomeStore: (selector: (state: typeof mocks.homeState) => unknown) => selector(mocks.homeState),
}));
vi.mock('./AgentForkTag', () => ({
default: () => null,
}));
vi.mock('./AgentPublishButton/ForkConfirmModal', () => ({
default: () => null,
}));
vi.mock('./AgentPublishButton/PublishResultModal', () => ({
default: () => null,
}));
vi.mock('./AgentPublishButton/useMarketPublish', () => ({
useMarketPublish: () => mocks.marketPublish,
}));
vi.mock('./AgentStatusTag', () => ({
default: () => null,
}));
vi.mock('./AutoSaveHint', () => ({
default: () => null,
}));
vi.mock('./AgentVersionReviewTag', () => ({
default: () => null,
useVersionReviewStatus: () => mocks.versionReviewStatus,
}));
describe('Agent profile Header', () => {
beforeEach(() => {
mocks.agentState.canCurrentAgentPublishToCommunity = true;
mocks.agentState.isCurrentAgentHeterogeneous = false;
});
it('should show the community publish action for normal agents', () => {
render(<Header />);
expect(screen.getByRole('button', { name: 'publishToCommunity' })).toBeInTheDocument();
});
it('should hide the community publish action for heterogeneous and platform agents', () => {
mocks.agentState.canCurrentAgentPublishToCommunity = false;
mocks.agentState.isCurrentAgentHeterogeneous = true;
render(<Header />);
expect(screen.queryByRole('button', { name: 'publishToCommunity' })).not.toBeInTheDocument();
});
});
@@ -36,6 +36,7 @@ const Header = memo(() => {
const systemRole = useAgentStore(agentSelectors.currentAgentSystemRole);
const activeAgentId = useAgentStore((s) => s.activeAgentId);
const isHeterogeneous = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
const canPublishToCommunity = useAgentStore(agentSelectors.canCurrentAgentPublishToCommunity);
const [showAgentBuilderPanel, toggleAgentBuilderPanel, isStatusInit] = useGlobalStore((s) => [
systemStatusSelectors.showAgentBuilderPanel(s),
s.toggleAgentBuilderPanel,
@@ -148,13 +149,17 @@ const Header = memo(() => {
onClick: () => useAgentStore.setState({ showAgentSetting: true }),
},
{ type: 'divider' as const },
{
icon: <Icon icon={ShapesUploadIcon} />,
key: 'publish',
label: t('publishToCommunity', { ns: 'setting' }),
onClick: handlePublishClick,
},
{ type: 'divider' as const },
...(canPublishToCommunity
? [
{
icon: <Icon icon={ShapesUploadIcon} />,
key: 'publish',
label: t('publishToCommunity', { ns: 'setting' }),
onClick: handlePublishClick,
},
{ type: 'divider' as const },
]
: []),
{
danger: true,
icon: <Icon icon={Trash} />,
@@ -163,7 +168,7 @@ const Header = memo(() => {
onClick: handleDelete,
},
],
[handlePublishClick, handleDelete, t],
[canPublishToCommunity, handlePublishClick, handleDelete, t],
);
return (
@@ -182,7 +187,7 @@ const Header = memo(() => {
<DropdownMenu items={menuItems}>
<ActionIcon
icon={MoreHorizontal}
loading={isPublishing || isAuthLoading}
loading={canPublishToCommunity && (isPublishing || isAuthLoading)}
size={DESKTOP_HEADER_ICON_SMALL_SIZE}
/>
</DropdownMenu>
@@ -201,7 +201,7 @@ export const createServerAgentToolsEngine = (
// Always-on builtin tools
...Object.fromEntries(alwaysOnToolIds.map((id) => [id, true])),
// System-level rules (may override user selection for specific tools)
[CloudSandboxManifest.identifier]: runtimeMode === 'cloud' || runtimeMode === 'sandbox',
[CloudSandboxManifest.identifier]: runtimeMode === 'cloud',
[KnowledgeBaseManifest.identifier]: hasEnabledKnowledgeBases,
// Local-system: gated by `canUseDevice` (resolveDeviceAccessPolicy)
// first — keeps external bot senders out before runtime checks even
-28
View File
@@ -1,28 +0,0 @@
import { type DeviceAttachment } from '@lobechat/builtin-tool-remote-device';
import { lambdaClient } from '@/libs/trpc/client';
export const deviceService = {
/**
* List all online devices bound to the current user.
* Returns devices from the device-gateway via tRPC.
*/
listDevices: async (): Promise<DeviceAttachment[]> => {
try {
return await lambdaClient.device.listDevices.query();
} catch {
return [];
}
},
/**
* Check if the user has any online devices.
*/
getStatus: async (): Promise<{ deviceCount: number; online: boolean }> => {
try {
return await lambdaClient.device.status.query();
} catch {
return { deviceCount: 0, online: false };
}
},
};
@@ -58,26 +58,17 @@ const getRuntimeEnvConfigById = (agentId: string) => (s: AgentStoreState) =>
const isLocalSystemEnabledById = (agentId: string) => (s: AgentStoreState) =>
getRuntimeModeById(agentId)(s) === 'local';
/** Get the selected device ID for the agent (desktop only) */
const getDeviceIdById =
(agentId: string) =>
(s: AgentStoreState): string | undefined =>
getChatConfigById(agentId)(s).runtimeEnv?.deviceId;
/**
* Get runtime environment mode by agent ID.
* Reads from `runtimeMode[platform]`, defaults to 'local' on desktop, 'none' on web.
* Legacy 'cloud' values are normalized to 'sandbox' for backward compatibility.
*/
const getRuntimeModeById =
(agentId: string) =>
(s: AgentStoreState): RuntimeEnvMode => {
const runtimeEnv = getChatConfigById(agentId)(s).runtimeEnv;
const platform = isDesktop ? 'desktop' : 'web';
const mode = runtimeEnv?.runtimeMode?.[platform] ?? (isDesktop ? 'local' : 'none');
// Legacy backward compatibility: map 'cloud' to 'sandbox'
return mode === 'cloud' ? 'sandbox' : mode;
return runtimeEnv?.runtimeMode?.[platform] ?? (isDesktop ? 'local' : 'none');
};
const getSkillActivateModeById =
@@ -87,7 +78,6 @@ const getSkillActivateModeById =
export const chatConfigByIdSelectors = {
getChatConfigById,
getDeviceIdById,
getEnableHistoryCountById,
getHistoryCountById,
getRuntimeEnvConfigById,
@@ -34,10 +34,8 @@ const isMemoryToolEnabled = (s: AgentStoreState) =>
const isLocalSystemEnabled = (s: AgentStoreState) =>
chatConfigByIdSelectors.isLocalSystemEnabledById(s.activeAgentId || '')(s);
const isCloudSandboxEnabled = (s: AgentStoreState) => {
const mode = chatConfigByIdSelectors.getRuntimeModeById(s.activeAgentId || '')(s);
return mode === 'cloud' || mode === 'sandbox';
};
const isCloudSandboxEnabled = (s: AgentStoreState) =>
chatConfigByIdSelectors.getRuntimeModeById(s.activeAgentId || '')(s) === 'cloud';
const skillActivateMode = (s: AgentStoreState) =>
chatConfigByIdSelectors.getSkillActivateModeById(s.activeAgentId || '')(s);
@@ -360,6 +360,50 @@ describe('agentSelectors', () => {
});
});
describe('canCurrentAgentPublishToCommunity', () => {
it('should allow publishing normal agents', () => {
const state = createState({
activeAgentId: 'agent-1',
agentMap: { 'agent-1': { id: 'agent-1' } },
});
expect(agentSelectors.canCurrentAgentPublishToCommunity(state)).toBe(true);
});
it('should prevent publishing local heterogeneous agents', () => {
const state = createState({
activeAgentId: 'agent-1',
agentMap: {
'agent-1': {
agencyConfig: {
heterogeneousProvider: { command: 'codex', type: 'codex' },
},
id: 'agent-1',
},
},
});
expect(agentSelectors.canCurrentAgentPublishToCommunity(state)).toBe(false);
});
it('should prevent publishing platform agents', () => {
const state = createState({
activeAgentId: 'agent-1',
agentMap: {
'agent-1': {
agencyConfig: {
boundDeviceId: 'device-1',
heterogeneousProvider: { type: 'openclaw' },
},
id: 'agent-1',
},
},
});
expect(agentSelectors.canCurrentAgentPublishToCommunity(state)).toBe(false);
});
});
describe('currentKnowledgeIds', () => {
it('should return enabled file and knowledge base IDs', () => {
const state = createState({
+4
View File
@@ -286,6 +286,9 @@ const isCurrentAgentExternal = (s: AgentStoreState): boolean => !currentAgentDat
const isCurrentAgentHeterogeneous = (s: AgentStoreState): boolean =>
!!currentAgentConfig(s)?.agencyConfig?.heterogeneousProvider;
const canCurrentAgentPublishToCommunity = (s: AgentStoreState): boolean =>
!!currentAgentData(s) && !isCurrentAgentHeterogeneous(s);
const currentAgentHeterogeneousProviderType = (s: AgentStoreState) =>
currentAgentConfig(s)?.agencyConfig?.heterogeneousProvider?.type;
@@ -293,6 +296,7 @@ const getAgentDocumentsById = (agentId: string) => (s: AgentStoreState) =>
s.agentDocumentsMap[agentId];
export const agentSelectors = {
canCurrentAgentPublishToCommunity,
currentAgentHeterogeneousProviderType,
currentAgentAvatar,
currentAgentBackgroundColor,
@@ -378,6 +378,139 @@ describe('runAgent actions', () => {
}),
);
});
it('replaces messages from server-pushed uiMessages snapshot (SoT)', async () => {
const replaceMessages = vi.fn();
act(() => {
useChatStore.setState({
replaceMessages,
operations: {
[TEST_IDS.OPERATION_ID]: {
abortController: new AbortController(),
context: { agentId: 'agent-1', topicId: 'topic-1' },
id: TEST_IDS.OPERATION_ID,
metadata: { lastEventId: '0', startTime: Date.now(), stepCount: 0 },
status: 'running',
type: 'groupAgentGenerate',
},
},
});
});
const { result } = renderHook(() => useChatStore());
const context = createStreamingContext({ assistantId: TEST_IDS.ASSISTANT_MESSAGE_ID });
const uiMessages = [{ id: 'msg_a', role: 'user' }] as any;
const event: StreamEvent = {
type: 'step_start',
timestamp: Date.now(),
operationId: TEST_IDS.OPERATION_ID,
data: { phase: 'tool_execution', uiMessages },
};
await act(async () => {
await result.current.internal_handleAgentStreamEvent(
TEST_IDS.OPERATION_ID,
event,
context,
);
});
expect(replaceMessages).toHaveBeenCalledWith(uiMessages, {
context: { agentId: 'agent-1', topicId: 'topic-1' },
});
});
it('does not call replaceMessages when uiMessages absent on step_start', async () => {
const replaceMessages = vi.fn();
act(() => {
useChatStore.setState({ replaceMessages });
});
const { result } = renderHook(() => useChatStore());
const context = createStreamingContext({ assistantId: TEST_IDS.ASSISTANT_MESSAGE_ID });
const event: StreamEvent = {
type: 'step_start',
timestamp: Date.now(),
operationId: TEST_IDS.OPERATION_ID,
data: { phase: 'tool_execution' }, // no uiMessages
};
await act(async () => {
await result.current.internal_handleAgentStreamEvent(
TEST_IDS.OPERATION_ID,
event,
context,
);
});
expect(replaceMessages).not.toHaveBeenCalled();
});
});
describe('agent_runtime_end event', () => {
it('replaces messages from terminal uiMessages snapshot (final-step SoT)', async () => {
const replaceMessages = vi.fn();
act(() => {
useChatStore.setState({
replaceMessages,
operations: {
[TEST_IDS.OPERATION_ID]: {
abortController: new AbortController(),
context: { agentId: 'agent-1', topicId: 'topic-1' },
id: TEST_IDS.OPERATION_ID,
metadata: { lastEventId: '0', startTime: Date.now(), stepCount: 0 },
status: 'running',
type: 'groupAgentGenerate',
},
},
});
});
const { result } = renderHook(() => useChatStore());
const uiMessages = [{ id: 'msg_final', role: 'assistantGroup' }] as any;
const event: StreamEvent = {
type: 'agent_runtime_end',
timestamp: Date.now(),
operationId: TEST_IDS.OPERATION_ID,
data: { finalState: { status: 'done' }, reason: 'done', uiMessages },
};
await act(async () => {
await result.current.internal_handleAgentStreamEvent(
TEST_IDS.OPERATION_ID,
event,
createStreamingContext({ assistantId: TEST_IDS.ASSISTANT_MESSAGE_ID }),
);
});
expect(replaceMessages).toHaveBeenCalledWith(uiMessages, {
context: { agentId: 'agent-1', topicId: 'topic-1' },
});
});
});
describe('step_complete event', () => {
// The previous DB-refetch on tool_execution was the source of the
// assistantGroup-clobber regression (LOBE-9501) — tool results are
// now reconciled via the next step_start's uiMessages snapshot.
it('does NOT refreshMessages on tool_execution phase', async () => {
const { result } = renderHook(() => useChatStore());
const event: StreamEvent = {
type: 'step_complete',
timestamp: Date.now(),
operationId: TEST_IDS.OPERATION_ID,
data: { executionTime: 10, phase: 'tool_execution', result: { ok: true } },
};
await act(async () => {
await result.current.internal_handleAgentStreamEvent(
TEST_IDS.OPERATION_ID,
event,
createStreamingContext({ assistantId: TEST_IDS.ASSISTANT_MESSAGE_ID }),
);
});
expect(result.current.refreshMessages).not.toHaveBeenCalled();
});
});
});
});
@@ -118,9 +118,19 @@ export class AgentActionImpl {
case 'agent_runtime_end': {
// Agent runtime finished - this is the definitive signal that generation is complete
const { reason, reasonDetail, finalState } = event.data || {};
const { reason, reasonDetail, finalState, uiMessages } = event.data || {};
log(`Agent runtime ended for ${assistantId}: reason=${reason}, detail=${reasonDetail}`);
// Server pushes the canonical UIChatMessage[] snapshot for the
// topic as the Source of Truth on terminal-state. The last step
// has no later step_start to carry a fresh snapshot, so without
// this branch the streamed assistantGroup would only be reconciled
// with DB once a refetch fires — losing the SoT guarantee.
if (Array.isArray(uiMessages)) {
log(`Replacing messages from agent_runtime_end uiMessages (${uiMessages.length} msgs)`);
this.#get().replaceMessages(uiMessages, { context: operation.context });
}
// Update operation metadata with final state
if (finalState) {
this.#get().updateOperationMetadata(operationId, {
@@ -276,7 +286,19 @@ export class AgentActionImpl {
}
case 'step_start': {
const { phase, toolCall, pendingToolsCalling, requiresApproval } = event.data || {};
const { phase, toolCall, pendingToolsCalling, requiresApproval, uiMessages } =
event.data || {};
// Server attaches the canonical UIChatMessage[] snapshot to
// step_start so the client uses the pushed payload as Source of
// Truth instead of refetching from DB (the DB fan-out from the
// previous step's stream chunks is async — a refetch here would
// return a stale assistant placeholder that clobbers the
// streamed assistantGroup).
if (Array.isArray(uiMessages)) {
log(`Replacing messages from step_start uiMessages (${uiMessages.length} msgs)`);
this.#get().replaceMessages(uiMessages, { context: operation.context });
}
if (phase === 'human_approval' && requiresApproval) {
// Requires human approval
@@ -301,8 +323,10 @@ export class AgentActionImpl {
if (phase === 'tool_execution' && result) {
log(`Tool execution completed for ${assistantId} in ${executionTime}ms:`, result);
// Refresh messages to display tool results
await this.#get().refreshMessages();
// Tool results are reconciled via the canonical uiMessages
// snapshot the server pushes on the next step_start; no need
// to refetch from DB here (the refetch was the source of the
// assistantGroup-clobber regression that LOBE-9501 fixes).
} else if (phase === 'execution_complete' && finalState) {
// Agent execution complete
log(`Agent execution completed for ${assistantId}:`, finalState);
@@ -82,7 +82,7 @@ describe('createGatewayEventHandler', () => {
});
describe('stream_start', () => {
it('should associate new message with operation', async () => {
it('should associate new message with operation and skip the DB refetch (LOBE-9501)', async () => {
const store = createMockStore();
const handler = createHandler(store);
@@ -90,7 +90,12 @@ describe('createGatewayEventHandler', () => {
await flush();
expect(store.associateMessageWithOperation).toHaveBeenCalledWith('msg-step2', 'op-1');
expect(store.replaceMessages).toHaveBeenCalled();
// Native gateway streams carry the new assistant id directly + a SoT
// uiMessages snapshot on the preceding step_start, so stream_start must
// NOT trigger a DB refetch (the refetch is what clobbered the streamed
// assistantGroup with a stale placeholder).
expect(messageService.getMessages).not.toHaveBeenCalled();
expect(store.replaceMessages).not.toHaveBeenCalled();
expect(emitClientAgentSignalSourceEvent).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
@@ -843,17 +848,14 @@ describe('createGatewayEventHandler', () => {
});
describe('sequential processing', () => {
it('should process stream_chunk only after stream_start refresh completes', async () => {
it('should dispatch stream_chunk to the new assistant id after stream_start switches it', async () => {
// Native gateway streams no longer await a DB fetch on stream_start
// (LOBE-9501) — but stream_chunk must still queue behind stream_start
// so the chunk targets the NEW assistant id (from stream_start.data),
// not the previous one.
const store = createMockStore();
const callOrder: string[] = [];
const { messageService } = await import('@/services/message');
(messageService.getMessages as any).mockImplementation(async () => {
callOrder.push('refresh_start');
await new Promise((r) => setTimeout(r, 10));
callOrder.push('refresh_end');
return [];
});
store.internal_dispatchMessage.mockImplementation(() => {
callOrder.push('dispatch');
});
@@ -867,10 +869,34 @@ describe('createGatewayEventHandler', () => {
handler(makeEvent('stream_chunk', { chunkType: 'text', content: 'Hello' }));
await flush();
const refreshEndIdx = callOrder.indexOf('refresh_end');
// associate (from stream_start) precedes dispatch (from stream_chunk)
const associateIdx = callOrder.indexOf('associate');
const dispatchIdx = callOrder.indexOf('dispatch');
expect(refreshEndIdx).toBeGreaterThan(-1);
expect(dispatchIdx).toBeGreaterThan(refreshEndIdx);
expect(associateIdx).toBeGreaterThan(-1);
expect(dispatchIdx).toBeGreaterThan(associateIdx);
// Chunk targets the new id, proving the queue ordering held
expect(store.internal_dispatchMessage).toHaveBeenLastCalledWith(
expect.objectContaining({ id: 'msg-new', value: { content: 'Hello' } }),
{ operationId: 'op-1' },
);
// And no DB refetch was issued for the native stream
expect(messageService.getMessages).not.toHaveBeenCalled();
});
it('should still fetch from DB on stream_start when assistantMessage id is absent (hetero CLI)', async () => {
// Hetero CLI adapters (Claude Code / Codex) never set
// `assistantMessage.id` on stream_start, so the DB read is still
// mandatory — it pulls the executor-created placeholder into
// `dbMessagesMap` so subsequent chunks have a target.
const store = createMockStore();
const handler = createHandler(store);
handler(makeEvent('stream_start', {}));
await flush();
expect(messageService.getMessages).toHaveBeenCalled();
expect(store.replaceMessages).toHaveBeenCalled();
});
});
@@ -901,18 +927,22 @@ describe('createGatewayEventHandler', () => {
// Loading stays active between steps — only tool streaming is cleared
expect(store.internal_toggleToolCallingStreaming).toHaveBeenCalledWith('msg-1', undefined);
// Tool execution
// Tool execution — tool_end still refreshes from DB to pick up the
// server-created tool message row.
handler(makeEvent('tool_start', { parentMessageId: 'msg-1', toolCalling: tools[0] }));
handler(makeEvent('tool_end', { isSuccess: true }));
await flush();
expect(store.replaceMessages).toHaveBeenCalled();
// Step 2: Next LLM call with new assistant message
// Step 2: Next LLM call with new assistant message — native stream_start
// carries the id directly, so it must NOT trigger a DB refetch
// (LOBE-9501). Only the association switch happens.
vi.clearAllMocks();
handler(makeEvent('stream_start', { assistantMessage: { id: 'msg-2' } }));
await flush();
expect(store.replaceMessages).toHaveBeenCalled();
expect(store.associateMessageWithOperation).toHaveBeenCalledWith('msg-2', 'op-1');
expect(messageService.getMessages).not.toHaveBeenCalled();
expect(store.replaceMessages).not.toHaveBeenCalled();
handler(makeEvent('stream_chunk', { chunkType: 'text', content: 'Here are the results.' }));
await flush();
@@ -7,7 +7,12 @@ import type {
ToolExecuteData,
ToolStartData,
} from '@lobechat/agent-gateway-client';
import type { BuiltinToolResult, ChatMessageError, ConversationContext } from '@lobechat/types';
import type {
BuiltinToolResult,
ChatMessageError,
ConversationContext,
UIChatMessage,
} from '@lobechat/types';
import { AgentRuntimeErrorType } from '@lobechat/types';
import { messageService } from '@/services/message';
@@ -276,24 +281,35 @@ export const createGatewayEventHandler = (
accumulatedContent = '';
accumulatedReasoning = '';
// Heterogeneous CLI adapters emit `stream_start { newStep: true }`
// without a server-side assistant id. Pull the freshly created step
// assistant from DB so subsequent live chunks update the RIGHT row
// instead of appending onto the previous step's assistant.
const messages = await fetchAndReplaceMessages(get, context).catch((error) => {
console.error(error);
return undefined;
});
// Skip the DB read ONLY for native gateway streams — those carry
// `assistantMessage.id` directly on stream_start AND the preceding
// `step_start` already carried the SoT uiMessages snapshot, so
// chunks have a valid target in `dbMessagesMap` already. Removing
// the await here is what un-blocks the enqueue chain so live
// chunks can land mid-stream (LOBE-9501).
//
// Hetero CLI adapters (Claude Code / Codex) never set
// `assistantMessage.id` on stream_start, so the DB read stays
// mandatory for them — it (a) pulls the executor-created
// placeholder into `dbMessagesMap` so subsequent chunks can
// dispatch to it, and (b) resolves the next-step assistant id for
// the `newStep` fallback.
if (!newAssistantMessageId) {
const messages = await fetchAndReplaceMessages(get, context).catch((error) => {
console.error(error);
return undefined;
});
if (!newAssistantMessageId && data?.newStep) {
const resolvedAssistantMessageId = findNextAssistantMessageId(
messages as GatewayMessageLike[] | undefined,
currentAssistantMessageId,
);
if (data?.newStep) {
const resolvedAssistantMessageId = findNextAssistantMessageId(
messages as GatewayMessageLike[] | undefined,
currentAssistantMessageId,
);
if (resolvedAssistantMessageId) {
currentAssistantMessageId = resolvedAssistantMessageId;
get().associateMessageWithOperation(currentAssistantMessageId, operationId);
if (resolvedAssistantMessageId) {
currentAssistantMessageId = resolvedAssistantMessageId;
get().associateMessageWithOperation(currentAssistantMessageId, operationId);
}
}
}
@@ -406,8 +422,18 @@ export const createGatewayEventHandler = (
pendingToolsCalling?: unknown[];
phase?: string;
requiresApproval?: boolean;
uiMessages?: UIChatMessage[];
};
// Server attaches the canonical UIChatMessage[] snapshot at every
// step boundary (agent-runtime #15152). Use it as Source of Truth
// instead of issuing a DB refetch — the refetch returns a stale
// assistant placeholder while DB fan-out is still in flight, which
// clobbers the in-memory streamed assistantGroup (LOBE-9501).
if (Array.isArray(data?.uiMessages)) {
get().replaceMessages(data.uiMessages, { action: 'gateway/step_start', context });
}
if (data?.phase === 'human_approval' && data.requiresApproval && data.pendingToolsCalling) {
void notifyDesktopHumanApprovalRequired(get, context);
// Persist a paused marker so the sidebar reflects "waiting on user" across reload.
@@ -475,6 +501,8 @@ export const createGatewayEventHandler = (
case 'agent_runtime_end': {
enqueue(async () => {
const data = event.data as { uiMessages?: UIChatMessage[] } | undefined;
void emitClientAgentSignalSourceEvent({
payload: {
agentId: context.agentId,
@@ -499,7 +527,18 @@ export const createGatewayEventHandler = (
get().markUnreadCompleted(completedOp.context.agentId, completedOp.context.topicId);
}
await fetchAndReplaceMessages(get, context).catch(console.error);
// Terminal step has no later step_start to carry SoT — server
// pushes the canonical snapshot directly on this event. Fall back
// to a DB refetch only if the snapshot is absent (older server
// builds, or push-event delivery edge cases).
if (Array.isArray(data?.uiMessages)) {
get().replaceMessages(data.uiMessages, {
action: 'gateway/agent_runtime_end',
context,
});
} else {
await fetchAndReplaceMessages(get, context).catch(console.error);
}
});
break;
}
+18 -1
View File
@@ -106,8 +106,24 @@ export class MessageQueryActionImpl {
useFetchMessages = (
context: ConversationContext,
skipFetch?: boolean,
options?: {
/**
* Skip the fetch entirely (e.g. while another flow owns the data).
* Equivalent to passing a null SWR key.
*/
skipFetch?: boolean;
/**
* Revalidate when the window regains focus. Defaults to SWR's
* client-data default (true). Pass `false` to suppress the focus
* refetch — used during streaming so the in-memory stream payload
* (Source of Truth) isn't clobbered by a stale DB read while DB
* fan-out writes are still in flight.
*/
revalidateOnFocus?: boolean;
},
): SWRResponse<UIChatMessage[]> => {
const { skipFetch, revalidateOnFocus } = options ?? {};
// Skip fetch when skipFetch is true or required fields are missing
const shouldFetch = !skipFetch && !!context.agentId && !!context.topicId;
@@ -121,6 +137,7 @@ export class MessageQueryActionImpl {
// Use replaceMessages to store the fetched messages
this.#get().replaceMessages(data, { action: 'useFetchMessages', context });
},
...(revalidateOnFocus !== undefined && { revalidateOnFocus }),
},
);
};
@@ -122,6 +122,9 @@ exports[`settingsSelectors > defaultAgent > should merge DEFAULT_AGENT and s.set
"provider": "deepseek",
},
"searchMode": "auto",
"selfIteration": {
"enabled": false,
},
},
"model": "gpt-3.5-turbo",
"openingQuestions": [],
@@ -166,6 +169,9 @@ exports[`settingsSelectors > defaultAgentConfig > should merge DEFAULT_AGENT_CON
"provider": "deepseek",
},
"searchMode": "auto",
"selfIteration": {
"enabled": false,
},
},
"model": "gpt-4",
"openingQuestions": [],