@swarmclawai/swarmclaw 1.0.7 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +6 -6
  2. package/package.json +1 -1
  3. package/src/app/api/learned-skills/route.ts +24 -0
  4. package/src/cli/index.js +7 -0
  5. package/src/components/agents/agent-chat-list.tsx +2 -0
  6. package/src/components/agents/agent-list.tsx +3 -1
  7. package/src/components/agents/agent-sheet.tsx +8 -2
  8. package/src/components/chat/message-list.tsx +1 -1
  9. package/src/components/chat/tool-events-section.test.ts +25 -0
  10. package/src/components/chat/tool-events-section.tsx +29 -19
  11. package/src/components/layout/dashboard-shell.tsx +9 -5
  12. package/src/components/settings/gateway-disconnect-overlay.tsx +13 -2
  13. package/src/components/shared/bottom-sheet.tsx +1 -1
  14. package/src/components/ui/dialog.tsx +1 -1
  15. package/src/components/ui/sheet.tsx +1 -1
  16. package/src/hooks/use-app-bootstrap.ts +36 -21
  17. package/src/lib/server/agents/agent-runtime-config.test.ts +27 -0
  18. package/src/lib/server/agents/agent-runtime-config.ts +12 -11
  19. package/src/lib/server/agents/agent-thread-session.test.ts +76 -1
  20. package/src/lib/server/agents/agent-thread-session.ts +20 -1
  21. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +43 -8
  22. package/src/lib/server/agents/main-agent-loop.ts +21 -14
  23. package/src/lib/server/agents/task-session.ts +9 -2
  24. package/src/lib/server/build-llm.test.ts +117 -0
  25. package/src/lib/server/build-llm.ts +30 -9
  26. package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +92 -0
  27. package/src/lib/server/chat-execution/chat-execution.ts +10 -1
  28. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +6 -0
  29. package/src/lib/server/chat-execution/stream-agent-chat.ts +8 -1
  30. package/src/lib/server/chatrooms/chatroom-helpers.test.ts +29 -0
  31. package/src/lib/server/chatrooms/chatroom-helpers.ts +11 -9
  32. package/src/lib/server/connectors/manager.ts +3 -0
  33. package/src/lib/server/provider-endpoint.ts +69 -0
  34. package/src/lib/server/runtime/session-run-manager.ts +12 -1
  35. package/src/lib/server/session-tools/context-mgmt.ts +7 -1
  36. package/src/lib/server/session-tools/skill-runtime.ts +3 -0
  37. package/src/lib/server/session-tools/skills.ts +3 -0
  38. package/src/lib/server/skills/learned-skills.test.ts +336 -0
  39. package/src/lib/server/skills/learned-skills.ts +719 -0
  40. package/src/lib/server/skills/runtime-skill-resolver.test.ts +70 -1
  41. package/src/lib/server/skills/runtime-skill-resolver.ts +71 -2
  42. package/src/lib/server/skills/skill-suggestions.ts +49 -5
  43. package/src/lib/server/storage.ts +14 -0
  44. package/src/stores/use-app-store.test.ts +11 -0
  45. package/src/stores/use-chat-store.test.ts +151 -0
  46. package/src/stores/use-chat-store.ts +31 -10
  47. package/src/types/index.ts +52 -0
package/README.md CHANGED
@@ -17,13 +17,12 @@ Extension tutorial: https://swarmclaw.ai/docs/extension-tutorial
17
17
 
18
18
  ## Release Notes
19
19
 
20
- ### v1.0.7 Highlights
20
+ ### v1.0.8 Highlights
21
21
 
22
- - **Connector inbox + isolation**: external connector conversations now live in a dedicated Inbox with owner routing, allow/deny controls, pairing management, sender avatars, and strict sender-scoped memory.
23
- - **Schedules as an operations surface**: schedules now use explicit session routing, archive/cancel cascades, `cancelled` task outcomes, and a proper `Live / Archived / Runs` console instead of a simple list.
24
- - **Delegation, not orchestration**: orchestration is no longer a special product/runtime concept. Agents either delegate or they do not, and background AI work now uses the current agent/session model config rather than a separate orchestration engine.
25
- - **Tools + extensions cutover**: built-in capabilities are native tools, external add-ons are extensions, and persisted agent/session data now stores canonical `tools` + `extensions` fields only.
26
- - Breaking change: agent and session records now persist `tools` and `extensions` only. The legacy `plugins` field is no longer part of the runtime data model.
22
+ - **Learned skills and self-healing**: SwarmClaw now keeps agent-scoped learned skills and shadow revisions so repeated successes and repeated external integration failures can harden into reusable local behavior without silently mutating the shared skill library.
23
+ - **Direct chat stability**: chat-origin runs now stop after the visible answer instead of enqueueing hidden follow-up loops, leaked control tokens such as `NO_MESSAGE` stay out of the user transcript, and repeated internal reruns no longer replace the reply the user already saw.
24
+ - **OpenClaw and Ollama route hardening**: agent thread sessions now repair stale credential/endpoint resolution more aggressively, including Ollama Cloud vs local endpoint selection and OpenClaw gateway fallback behavior.
25
+ - **Operator UX fixes**: new agents appear in the list immediately, gateway-disconnected chat CTAs now route to the current agent's settings instead of global settings, setup-wizard flicker after access-key login is gone, and screenshot-heavy tool runs no longer render duplicate previews in chat.
27
26
 
28
27
  ## What SwarmClaw Focuses On
29
28
 
@@ -100,6 +99,7 @@ Then open `http://localhost:3456`.
100
99
  - Or open **Skills** and use **Draft From Current Chat**.
101
100
  - New agents keep **Conversation Skill Drafting** enabled by default, and you can switch it off per agent.
102
101
  - SwarmClaw turns useful work into a **draft suggestion**, not a live self-modifying skill.
102
+ - Learned skills stay **user/agent scoped** by default. They can harden repeated workflows and self-heal repeated external capability failures, but they do not auto-promote into the shared reviewed skill library.
103
103
  - Review the suggested name, rationale, summary, and transcript snippet.
104
104
  - Approve it to save it into the normal skill library, or dismiss it.
105
105
  - Runtime skill recommendations can use **keyword** or **embedding** ranking from **Settings → Memory & AI → Skills**.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "Self-hosted AI runtime for OpenClaw, delegation, autonomy, runtime skills, crypto wallets, and chat platform connectors.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -0,0 +1,24 @@
1
+ import { NextResponse } from 'next/server'
2
+
3
+ import { listLearnedSkills } from '@/lib/server/skills/learned-skills'
4
+
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ export async function GET(req: Request) {
8
+ const url = new URL(req.url)
9
+ const agentId = url.searchParams.get('agentId') || undefined
10
+ const sessionId = url.searchParams.get('sessionId') || undefined
11
+ const lifecycle = url.searchParams.get('lifecycle') || undefined
12
+
13
+ return NextResponse.json(listLearnedSkills({
14
+ agentId,
15
+ sessionId,
16
+ lifecycle: lifecycle === 'candidate'
17
+ || lifecycle === 'active'
18
+ || lifecycle === 'shadow'
19
+ || lifecycle === 'demoted'
20
+ || lifecycle === 'review_ready'
21
+ ? lifecycle
22
+ : undefined,
23
+ }))
24
+ }
package/src/cli/index.js CHANGED
@@ -571,6 +571,13 @@ const COMMAND_GROUPS = [
571
571
  cmd('openclaw-device', 'GET', '/setup/openclaw-device', 'Show the local OpenClaw device ID'),
572
572
  ],
573
573
  },
574
+ {
575
+ name: 'learned-skills',
576
+ description: 'Inspect agent-scoped learned skills',
577
+ commands: [
578
+ cmd('list', 'GET', '/learned-skills', 'List learned skills'),
579
+ ],
580
+ },
574
581
  {
575
582
  name: 'skills',
576
583
  description: 'Manage reusable skills',
@@ -6,6 +6,7 @@ import { useChatStore } from '@/stores/use-chat-store'
6
6
  import { useChatroomStore } from '@/stores/use-chatroom-store'
7
7
  import { useNow } from '@/hooks/use-now'
8
8
  import { useMountedRef } from '@/hooks/use-mounted-ref'
9
+ import { useWs } from '@/hooks/use-ws'
9
10
  import { useNavigate } from '@/lib/app/navigation'
10
11
  import { api } from '@/lib/app/api-client'
11
12
  import { ConfirmDialog } from '@/components/shared/confirm-dialog'
@@ -86,6 +87,7 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
86
87
  }, [])
87
88
 
88
89
  useEffect(() => { loadAgents() }, [loadAgents])
90
+ useWs('agents', loadAgents, 30_000)
89
91
 
90
92
  useEffect(() => {
91
93
  return () => {
@@ -8,6 +8,7 @@ import { useApprovalStore } from '@/stores/use-approval-store'
8
8
  import { Skeleton } from '@/components/shared/skeleton'
9
9
  import { EmptyState } from '@/components/shared/empty-state'
10
10
  import { getEnabledCapabilityIds } from '@/lib/capability-selection'
11
+ import { useWs } from '@/hooks/use-ws'
11
12
 
12
13
  interface Props {
13
14
  inSidebar?: boolean
@@ -47,7 +48,8 @@ export function AgentList({ inSidebar }: Props) {
47
48
  }, [updateSettings])
48
49
 
49
50
  const [loaded, setLoaded] = useState(Object.keys(agents).length > 0)
50
- useEffect(() => { loadAgents().then(() => setLoaded(true)) }, [])
51
+ useEffect(() => { loadAgents().then(() => setLoaded(true)) }, [loadAgents])
52
+ useWs('agents', loadAgents, 30_000)
51
53
 
52
54
  // Compute which agents are "running" (have active sessions)
53
55
  const runningAgentIds = useMemo(() => {
@@ -42,6 +42,7 @@ const AUTO_SYNC_MODEL_PROVIDER_IDS = new Set<ProviderType>([
42
42
  'fireworks',
43
43
  'ollama',
44
44
  ])
45
+ const CONNECTION_TEST_TIMEOUT_MS = 40_000
45
46
 
46
47
  type SafeAgentWallet = Omit<AgentWallet, 'encryptedPrivateKey'> & {
47
48
  balanceAtomic?: string
@@ -156,6 +157,7 @@ export function AgentSheet() {
156
157
  const setEditingId = useAppStore((s) => s.setEditingAgentId)
157
158
  const agents = useAppStore((s) => s.agents)
158
159
  const loadAgents = useAppStore((s) => s.loadAgents)
160
+ const updateAgentInStore = useAppStore((s) => s.updateAgentInStore)
159
161
  const activeSessionId = useAppStore(selectActiveSessionId)
160
162
  const currentSession = useAppStore((s) => {
161
163
  const id = selectActiveSessionId(s)
@@ -697,11 +699,13 @@ export function AgentSheet() {
697
699
  monthlyBudget: parsedMonthlyBudget && parsedMonthlyBudget > 0 ? parsedMonthlyBudget : null,
698
700
  budgetAction: budgetEnabled ? budgetAction : undefined,
699
701
  }
702
+ const savedAgent = editing
703
+ ? await updateAgent(editing.id, data)
704
+ : await createAgent(data)
705
+ updateAgentInStore(savedAgent)
700
706
  if (editing) {
701
- await updateAgent(editing.id, data)
702
707
  toast.success('Agent saved')
703
708
  } else {
704
- await createAgent(data)
705
709
  toast.success('Agent created')
706
710
  }
707
711
  await loadAgents()
@@ -806,6 +810,8 @@ export function AgentSheet() {
806
810
  credentialId,
807
811
  endpoint: apiEndpoint,
808
812
  model,
813
+ }, {
814
+ timeoutMs: CONNECTION_TEST_TIMEOUT_MS,
809
815
  })
810
816
  if (result.deviceId) setTestDeviceId(result.deviceId)
811
817
  if (result.ok) {
@@ -838,7 +838,7 @@ export function MessageList({ messages, streaming, connectorFilter = null, loadi
838
838
  )}
839
839
  </div>
840
840
  </div>
841
- {showGatewayOverlay && <GatewayDisconnectOverlay />}
841
+ {showGatewayOverlay && <GatewayDisconnectOverlay agentId={agent?.id || null} />}
842
842
  {showScrollToBottom && (
843
843
  <button
844
844
  onClick={handleScrollToBottom}
@@ -0,0 +1,25 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+ import { collectCollapsedMedia } from './tool-events-section'
4
+
5
+ describe('collectCollapsedMedia', () => {
6
+ const screenshotEvent = {
7
+ id: 'tool-1',
8
+ name: 'browser',
9
+ input: '{"action":"screenshot"}',
10
+ output: '![Screenshot](/api/uploads/screenshot-123.png)',
11
+ status: 'done' as const,
12
+ }
13
+
14
+ it('collects explicit screenshot media when enabled', () => {
15
+ const media = collectCollapsedMedia([screenshotEvent], { showCollapsedMedia: true })
16
+
17
+ assert.deepEqual(media?.images, ['/api/uploads/screenshot-123.png'])
18
+ })
19
+
20
+ it('skips collapsed media previews when disabled', () => {
21
+ const media = collectCollapsedMedia([screenshotEvent], { showCollapsedMedia: false })
22
+
23
+ assert.equal(media, null)
24
+ })
25
+ })
@@ -95,7 +95,33 @@ const ToolSummaryRow = memo(function ToolSummaryRow({ event, caption }: { event:
95
95
  )
96
96
  })
97
97
 
98
- export const ToolEventsSection = memo(function ToolEventsSection({ toolEvents }: { toolEvents: ToolEvent[] }) {
98
+ export function collectCollapsedMedia(toolEvents: ToolEvent[], opts?: { expanded?: boolean; showCollapsedMedia?: boolean }) {
99
+ if (opts?.expanded || opts?.showCollapsedMedia === false) return null
100
+ const seen = new Set<string>()
101
+ const images: string[] = []
102
+ const videos: string[] = []
103
+ const pdfs: { name: string; url: string }[] = []
104
+ const files: { name: string; url: string }[] = []
105
+ for (const ev of toolEvents) {
106
+ if (!ev.output) continue
107
+ if (!isExplicitScreenshot(ev.name, ev.input)) continue
108
+ const media = extractMedia(ev.output)
109
+ for (const url of media.images) { if (!seen.has(url)) { seen.add(url); images.push(url) } }
110
+ for (const url of media.videos) { if (!seen.has(url)) { seen.add(url); videos.push(url) } }
111
+ for (const pdf of media.pdfs) { if (!seen.has(pdf.url)) { seen.add(pdf.url); pdfs.push(pdf) } }
112
+ for (const file of media.files) { if (!seen.has(file.url)) { seen.add(file.url); files.push(file) } }
113
+ }
114
+ if (!images.length && !videos.length && !pdfs.length && !files.length) return null
115
+ return { images, videos, pdfs, files }
116
+ }
117
+
118
+ export const ToolEventsSection = memo(function ToolEventsSection({
119
+ toolEvents,
120
+ showCollapsedMedia = false,
121
+ }: {
122
+ toolEvents: ToolEvent[]
123
+ showCollapsedMedia?: boolean
124
+ }) {
99
125
  const [expanded, setExpanded] = useState(false)
100
126
  const summary = useMemo(() => {
101
127
  let running = 0
@@ -125,24 +151,8 @@ export const ToolEventsSection = memo(function ToolEventsSection({ toolEvents }:
125
151
  }, [spotlightEvent, toolEvents])
126
152
 
127
153
  const collapsedMedia = useMemo(() => {
128
- if (expanded) return null
129
- const seen = new Set<string>()
130
- const images: string[] = []
131
- const videos: string[] = []
132
- const pdfs: { name: string; url: string }[] = []
133
- const files: { name: string; url: string }[] = []
134
- for (const ev of toolEvents) {
135
- if (!ev.output) continue
136
- if (!isExplicitScreenshot(ev.name, ev.input)) continue
137
- const media = extractMedia(ev.output)
138
- for (const url of media.images) { if (!seen.has(url)) { seen.add(url); images.push(url) } }
139
- for (const url of media.videos) { if (!seen.has(url)) { seen.add(url); videos.push(url) } }
140
- for (const pdf of media.pdfs) { if (!seen.has(pdf.url)) { seen.add(pdf.url); pdfs.push(pdf) } }
141
- for (const file of media.files) { if (!seen.has(file.url)) { seen.add(file.url); files.push(file) } }
142
- }
143
- if (!images.length && !videos.length && !pdfs.length && !files.length) return null
144
- return { images, videos, pdfs, files }
145
- }, [expanded, toolEvents])
154
+ return collectCollapsedMedia(toolEvents, { expanded, showCollapsedMedia })
155
+ }, [expanded, showCollapsedMedia, toolEvents])
146
156
 
147
157
  return (
148
158
  <div className="max-w-[85%] md:max-w-[72%] mb-2" data-testid="tool-activity">
@@ -38,6 +38,7 @@ export function DashboardShell({ children }: { children: React.ReactNode }) {
38
38
  authenticated,
39
39
  setAuthenticated,
40
40
  currentUser,
41
+ userReady,
41
42
  setupDone,
42
43
  agentReady
43
44
  } = useAppBootstrap()
@@ -78,7 +79,9 @@ export function DashboardShell({ children }: { children: React.ReactNode }) {
78
79
  ? 'Restoring local session'
79
80
  : !authChecked
80
81
  ? 'Checking access'
81
- : authenticated && currentUser && setupDone === null
82
+ : authenticated && !userReady
83
+ ? 'Restoring profile'
84
+ : authenticated && setupDone === null
82
85
  ? 'Loading setup state'
83
86
  : authenticated && currentUser && !agentReady
84
87
  ? 'Restoring agent workspace'
@@ -109,15 +112,16 @@ export function DashboardShell({ children }: { children: React.ReactNode }) {
109
112
  if (!hydrated || !authChecked) return
110
113
  if (isAuthPage) {
111
114
  // Reverse redirect: already authenticated with user → leave auth pages
112
- if (authenticated && currentUser && setupDone !== false) {
115
+ if (authenticated && userReady && currentUser && setupDone !== false) {
113
116
  router.replace('/home')
114
117
  }
115
118
  return
116
119
  }
117
120
  if (!authenticated) { router.replace('/login'); return }
118
- if (!currentUser) { router.replace('/setup'); return }
121
+ if (!userReady) return
119
122
  if (setupDone === false) { router.replace('/setup'); return }
120
- }, [hydrated, authChecked, authenticated, currentUser, setupDone, router, isAuthPage])
123
+ if (!currentUser) { router.replace('/setup'); return }
124
+ }, [hydrated, authChecked, authenticated, currentUser, setupDone, router, isAuthPage, userReady])
121
125
 
122
126
  // Star notification (one-time)
123
127
  useEffect(() => {
@@ -235,7 +239,7 @@ export function DashboardShell({ children }: { children: React.ReactNode }) {
235
239
  }
236
240
 
237
241
  // Redirect happens in effect above; show loader while waiting
238
- if (!authenticated || !currentUser || setupDone === null || !agentReady || setupDone === false) {
242
+ if (!authenticated || !userReady || !currentUser || setupDone === null || !agentReady || setupDone === false) {
239
243
  return (
240
244
  <FullScreenLoader
241
245
  stage={bootStage}
@@ -49,9 +49,15 @@ export function useGatewayStatus() {
49
49
  return status
50
50
  }
51
51
 
52
- export function GatewayDisconnectOverlay() {
52
+ interface GatewayDisconnectOverlayProps {
53
+ agentId?: string | null
54
+ }
55
+
56
+ export function GatewayDisconnectOverlay({ agentId = null }: GatewayDisconnectOverlayProps) {
53
57
  const navigateTo = useNavigate()
54
58
  const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
59
+ const setAgentSheetOpen = useAppStore((s) => s.setAgentSheetOpen)
60
+ const setEditingAgentId = useAppStore((s) => s.setEditingAgentId)
55
61
 
56
62
  return (
57
63
  <div className="absolute inset-0 z-10 flex items-center justify-center bg-bg/60 backdrop-blur-sm">
@@ -67,13 +73,18 @@ export function GatewayDisconnectOverlay() {
67
73
  </div>
68
74
  <button
69
75
  onClick={() => {
76
+ if (agentId) {
77
+ setEditingAgentId(agentId)
78
+ setAgentSheetOpen(true)
79
+ return
80
+ }
70
81
  navigateTo('settings')
71
82
  setSidebarOpen(true)
72
83
  }}
73
84
  className="px-5 py-2 rounded-[10px] border-none bg-accent-bright text-white text-[13px] font-600 cursor-pointer transition-all hover:brightness-110"
74
85
  style={{ fontFamily: 'inherit' }}
75
86
  >
76
- Connect Gateway
87
+ {agentId ? 'Open Agent Settings' : 'Open Settings'}
77
88
  </button>
78
89
  </div>
79
90
  </div>
@@ -40,7 +40,7 @@ export function BottomSheet({ open, onClose, children, wide, title, description
40
40
  </DialogPrimitive.Description>
41
41
  ) : null}
42
42
  <DialogPrimitive.Close
43
- className="absolute right-4 top-3.5 inline-flex h-9 w-9 items-center justify-center rounded-[12px] border border-white/[0.06] bg-white/[0.03] text-text-3 transition-all hover:bg-white/[0.06] hover:text-text-2 focus:outline-none focus:ring-2 focus:ring-accent-bright/30 sm:right-5 sm:top-5"
43
+ className="absolute right-4 top-3.5 inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-[12px] border border-white/[0.06] bg-white/[0.03] text-text-3 transition-all hover:bg-white/[0.06] hover:text-text-2 focus:outline-none focus:ring-2 focus:ring-accent-bright/30 sm:right-5 sm:top-5"
44
44
  >
45
45
  <XIcon className="size-4" />
46
46
  <span className="sr-only">Close</span>
@@ -71,7 +71,7 @@ function DialogContent({
71
71
  {showCloseButton && (
72
72
  <DialogPrimitive.Close
73
73
  data-slot="dialog-close"
74
- className="absolute top-4 right-4 inline-flex h-9 w-9 items-center justify-center rounded-[12px] border border-white/[0.06] bg-white/[0.03] text-text-3 transition-all hover:bg-white/[0.06] hover:text-text-2 focus:outline-none focus:ring-2 focus:ring-accent-bright/30 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
74
+ className="absolute top-4 right-4 inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-[12px] border border-white/[0.06] bg-white/[0.03] text-text-3 transition-all hover:bg-white/[0.06] hover:text-text-2 focus:outline-none focus:ring-2 focus:ring-accent-bright/30 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
75
75
  >
76
76
  <XIcon />
77
77
  <span className="sr-only">Close</span>
@@ -76,7 +76,7 @@ function SheetContent({
76
76
  >
77
77
  {children}
78
78
  {showCloseButton && (
79
- <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
79
+ <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 cursor-pointer rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
80
80
  <XIcon className="size-4" />
81
81
  <span className="sr-only">Close</span>
82
82
  </SheetPrimitive.Close>
@@ -29,6 +29,7 @@ export function useAppBootstrap() {
29
29
  const [authChecked, setAuthChecked] = useState(false)
30
30
  const [authenticated, setAuthenticated] = useState(false)
31
31
  const [setupDone, setSetupDone] = useState<boolean | null>(null)
32
+ const [userReady, setUserReady] = useState(false)
32
33
  const [agentReady, setAgentReady] = useState(false)
33
34
  const mountedRef = useMountedRef()
34
35
 
@@ -86,21 +87,6 @@ export function useAppBootstrap() {
86
87
  }
87
88
  }, [mountedRef])
88
89
 
89
- const syncUserFromServer = useCallback(async () => {
90
- if (currentUser) return
91
- try {
92
- const settings = await api<{ userName?: string }>('GET', '/settings', undefined, {
93
- timeoutMs: POST_AUTH_BOOTSTRAP_TIMEOUT_MS,
94
- retries: 0,
95
- })
96
- if (settings.userName) {
97
- setUser(settings.userName)
98
- }
99
- } catch (err) {
100
- console.warn('Failed to sync user from server:', err)
101
- }
102
- }, [currentUser, setUser])
103
-
104
90
  useEffect(() => {
105
91
  hydrate()
106
92
  }, [hydrate])
@@ -119,20 +105,48 @@ export function useAppBootstrap() {
119
105
  if (hydrated) checkAuth()
120
106
  }, [hydrated, checkAuth])
121
107
 
108
+ useEffect(() => {
109
+ if (!authenticated) {
110
+ setUserReady(false)
111
+ setAgentReady(false)
112
+ return
113
+ }
114
+ let cancelled = false
115
+ ;(async () => {
116
+ if (currentUser) {
117
+ if (!cancelled && mountedRef.current) setUserReady(true)
118
+ return
119
+ }
120
+ try {
121
+ const settings = await api<{ userName?: string }>('GET', '/settings', undefined, {
122
+ timeoutMs: POST_AUTH_BOOTSTRAP_TIMEOUT_MS,
123
+ retries: 0,
124
+ })
125
+ if (settings.userName) {
126
+ setUser(settings.userName)
127
+ }
128
+ } catch (err) {
129
+ console.warn('Failed to sync user from server:', err)
130
+ } finally {
131
+ if (!cancelled && mountedRef.current) setUserReady(true)
132
+ }
133
+ })()
134
+ return () => { cancelled = true }
135
+ }, [authenticated, currentUser, mountedRef, setUser])
136
+
122
137
  useEffect(() => {
123
138
  if (!authenticated) return
124
139
  connectWs()
125
- syncUserFromServer()
126
140
  loadNetworkInfo()
127
141
  loadSettings()
128
142
  loadSessions()
129
143
  return () => { disconnectWs() }
130
- }, [authenticated, loadNetworkInfo, loadSessions, loadSettings, syncUserFromServer])
144
+ }, [authenticated, loadNetworkInfo, loadSessions, loadSettings])
131
145
 
132
146
  useWs('sessions', loadSessions, 15000)
133
147
 
134
148
  useEffect(() => {
135
- if (!authenticated || !currentUser) return
149
+ if (!authenticated || !userReady || !currentUser) return
136
150
  let cancelled = false
137
151
  ;(async () => {
138
152
  try {
@@ -159,10 +173,10 @@ export function useAppBootstrap() {
159
173
  if (!cancelled && mountedRef.current) setAgentReady(true)
160
174
  })()
161
175
  return () => { cancelled = true }
162
- }, [authenticated, currentUser, mountedRef])
176
+ }, [authenticated, currentUser, mountedRef, userReady])
163
177
 
164
178
  useEffect(() => {
165
- if (!authenticated || !currentUser) return
179
+ if (!authenticated || !userReady) return
166
180
  let cancelled = false
167
181
  ;(async () => {
168
182
  try {
@@ -190,7 +204,7 @@ export function useAppBootstrap() {
190
204
  }
191
205
  })()
192
206
  return () => { cancelled = true }
193
- }, [authenticated, currentUser, mountedRef])
207
+ }, [authenticated, mountedRef, userReady])
194
208
 
195
209
  return {
196
210
  hydrated,
@@ -198,6 +212,7 @@ export function useAppBootstrap() {
198
212
  authenticated,
199
213
  setAuthenticated,
200
214
  currentUser,
215
+ userReady,
201
216
  setupDone,
202
217
  setSetupDone,
203
218
  agentReady
@@ -139,3 +139,30 @@ test('applyResolvedRoute copies gateway, endpoint, and fallback credentials onto
139
139
  gatewayProfileId: 'gateway-1',
140
140
  })
141
141
  })
142
+
143
+ test('resolveAgentRouteCandidatesWithProfiles repairs a stale Ollama credential reference when only one provider credential exists', async () => {
144
+ const storage = await import('@/lib/server/storage')
145
+ const now = Date.now()
146
+ storage.saveCredentials({
147
+ 'cred-ollama-cloud': {
148
+ id: 'cred-ollama-cloud',
149
+ provider: 'ollama',
150
+ name: 'Ollama Cloud',
151
+ encryptedKey: storage.encryptKey('ollama-cloud-key'),
152
+ createdAt: now,
153
+ },
154
+ })
155
+
156
+ const [route] = resolveAgentRouteCandidatesWithProfiles(makeAgent({
157
+ provider: 'ollama',
158
+ model: 'glm-5:cloud',
159
+ credentialId: 'stale-ollama-cred',
160
+ apiEndpoint: null,
161
+ }), [])
162
+
163
+ assert.ok(route)
164
+ assert.equal(route.provider, 'ollama')
165
+ assert.equal(route.model, 'glm-5:cloud')
166
+ assert.equal(route.credentialId, 'cred-ollama-cloud')
167
+ assert.equal(route.apiEndpoint, 'https://ollama.com')
168
+ })
@@ -6,7 +6,7 @@ import type {
6
6
  ProviderType,
7
7
  } from '@/types'
8
8
  import { deriveOpenClawWsUrl, normalizeProviderEndpoint } from '@/lib/openclaw/openclaw-endpoint'
9
- import { getProvider } from '@/lib/providers'
9
+ import { resolveProviderApiEndpoint, resolveProviderCredentialId } from '@/lib/server/provider-endpoint'
10
10
  import { loadGatewayProfiles } from '@/lib/server/storage'
11
11
  import { isProviderCoolingDown } from '@/lib/server/provider-health'
12
12
 
@@ -250,12 +250,6 @@ function dedupeCredentialIds(primary: string | null | undefined, candidates: str
250
250
  return result
251
251
  }
252
252
 
253
- function resolveProviderDefaultEndpoint(provider: string): string | null {
254
- const info = getProvider(provider)
255
- if (!info?.defaultEndpoint) return null
256
- return normalizeProviderEndpoint(provider, info.defaultEndpoint) || info.defaultEndpoint.replace(/\/+$/, '')
257
- }
258
-
259
253
  function buildRouteFromSeed(
260
254
  seed: RouteSeed,
261
255
  gatewayProfiles: GatewayProfile[],
@@ -276,13 +270,20 @@ function buildRouteFromSeed(
276
270
  const gatewayProfileId = gatewayProfile?.id ?? seed.gatewayProfileId ?? agentGatewayProfileId ?? null
277
271
 
278
272
  const providerFromGateway = gatewayProfile?.provider === 'openclaw' ? 'openclaw' : provider
279
- const explicitEndpoint = seed.apiEndpoint ?? gatewayProfile?.endpoint ?? null
280
- const apiEndpoint = normalizeProviderEndpoint(providerFromGateway, explicitEndpoint)
281
- ?? resolveProviderDefaultEndpoint(providerFromGateway)
282
273
  const model = (seed.model || '').trim() || (providerFromGateway === 'openclaw' ? DEFAULT_OPENCLAW_MODEL : '')
283
274
  if (!providerFromGateway || !model) return null
284
275
 
285
- const credentialId = seed.credentialId ?? gatewayProfile?.credentialId ?? null
276
+ const credentialId = resolveProviderCredentialId({
277
+ provider: providerFromGateway,
278
+ credentialId: seed.credentialId ?? gatewayProfile?.credentialId ?? null,
279
+ })
280
+ const explicitEndpoint = seed.apiEndpoint ?? gatewayProfile?.endpoint ?? null
281
+ const apiEndpoint = resolveProviderApiEndpoint({
282
+ provider: providerFromGateway,
283
+ model,
284
+ credentialId,
285
+ apiEndpoint: explicitEndpoint,
286
+ })
286
287
  return {
287
288
  id: seed.id,
288
289
  label: seed.label?.trim() || (gatewayProfile?.name || `${providerFromGateway}:${model}`),
@@ -86,7 +86,6 @@ describe('ensureAgentThreadSession', () => {
86
86
  assert.equal(output.session.memoryScopeMode, 'agent')
87
87
  assert.equal(output.session.memoryTierPreference, 'blended')
88
88
  assert.equal(output.session.projectId, 'proj-1')
89
- assert.deepEqual(output.session.plugins, ['memory', 'web_search'])
90
89
  })
91
90
 
92
91
  it('does not create a new shortcut chat when the agent is disabled', () => {
@@ -238,4 +237,80 @@ describe('ensureAgentThreadSession', () => {
238
237
 
239
238
  assert.equal(output.connectorContext, null)
240
239
  })
240
+
241
+ it('repairs an existing Ollama Cloud thread session away from a stale local endpoint', () => {
242
+ const output = runWithTempDataDir(`
243
+ const storageMod = await import('@/lib/server/storage')
244
+ const storage = storageMod.default || storageMod['module.exports'] || storageMod
245
+ const helperMod = await import('@/lib/server/agents/agent-thread-session')
246
+ const ensureAgentThreadSession = helperMod.ensureAgentThreadSession
247
+ || helperMod.default?.ensureAgentThreadSession
248
+ || helperMod['module.exports']?.ensureAgentThreadSession
249
+
250
+ const now = Date.now()
251
+ storage.saveCredentials({
252
+ 'cred-ollama-cloud': {
253
+ id: 'cred-ollama-cloud',
254
+ provider: 'ollama',
255
+ name: 'Ollama Cloud',
256
+ encryptedKey: storage.encryptKey('ollama-cloud-key'),
257
+ createdAt: now,
258
+ },
259
+ })
260
+ storage.saveAgents({
261
+ hal: {
262
+ id: 'hal',
263
+ name: 'Hal2k',
264
+ provider: 'ollama',
265
+ model: 'glm-5:cloud',
266
+ credentialId: 'cred-ollama-cloud',
267
+ apiEndpoint: null,
268
+ fallbackCredentialIds: [],
269
+ heartbeatEnabled: true,
270
+ heartbeatIntervalSec: 600,
271
+ threadSessionId: 'agent-chat-hal-existing',
272
+ createdAt: now,
273
+ updatedAt: now,
274
+ },
275
+ })
276
+ storage.saveSessions({
277
+ 'agent-chat-hal-existing': {
278
+ id: 'agent-chat-hal-existing',
279
+ name: 'Hal2k',
280
+ cwd: process.env.WORKSPACE_DIR,
281
+ user: 'default',
282
+ provider: 'ollama',
283
+ model: 'glm-5:cloud',
284
+ credentialId: 'cred-ollama-cloud',
285
+ apiEndpoint: 'http://localhost:11434',
286
+ claudeSessionId: null,
287
+ codexThreadId: null,
288
+ opencodeSessionId: null,
289
+ delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
290
+ messages: [],
291
+ createdAt: now,
292
+ lastActiveAt: now,
293
+ sessionType: 'human',
294
+ agentId: 'hal',
295
+ shortcutForAgentId: 'hal',
296
+ },
297
+ })
298
+
299
+ const session = ensureAgentThreadSession('hal')
300
+ const persisted = storage.loadSessions()[session.id]
301
+ const healedAgent = storage.loadAgents().hal
302
+
303
+ console.log(JSON.stringify({
304
+ sessionId: session.id,
305
+ apiEndpoint: persisted.apiEndpoint || null,
306
+ credentialId: persisted.credentialId || null,
307
+ agentCredentialId: healedAgent?.credentialId || null,
308
+ }))
309
+ `)
310
+
311
+ assert.equal(output.sessionId, 'agent-chat-hal-existing')
312
+ assert.equal(output.credentialId, 'cred-ollama-cloud')
313
+ assert.equal(output.agentCredentialId, 'cred-ollama-cloud')
314
+ assert.equal(output.apiEndpoint, 'https://ollama.com')
315
+ })
241
316
  })