@swarmclawai/swarmclaw 1.2.6 → 1.2.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 (112) hide show
  1. package/README.md +24 -17
  2. package/next.config.ts +1 -0
  3. package/package.json +3 -2
  4. package/scripts/easy-setup.mjs +1 -1
  5. package/scripts/postinstall.mjs +1 -1
  6. package/skills/swarmclaw.md +115 -0
  7. package/skills/tools/browser.md +131 -0
  8. package/skills/tools/execute.md +98 -0
  9. package/skills/tools/files.md +98 -0
  10. package/skills/tools/memory.md +104 -0
  11. package/skills/tools/platform.md +144 -0
  12. package/skills/tools/skills.md +83 -0
  13. package/src/app/api/chats/[id]/messages/route.ts +23 -19
  14. package/src/app/api/chats/messages-route.test.ts +105 -51
  15. package/src/app/api/mcp-servers/[id]/test/route.ts +3 -2
  16. package/src/app/api/openclaw/deploy/route.ts +2 -0
  17. package/src/app/api/setup/doctor/route.ts +4 -4
  18. package/src/components/agents/agent-chat-list.tsx +23 -1
  19. package/src/components/agents/inspector-panel.tsx +165 -48
  20. package/src/components/chat/chat-area.tsx +38 -9
  21. package/src/components/chat/message-list.tsx +33 -19
  22. package/src/components/gateways/gateway-sheet.tsx +5 -2
  23. package/src/lib/agent-execute-defaults.test.ts +24 -0
  24. package/src/lib/agent-execute-defaults.ts +62 -0
  25. package/src/lib/chat/queued-message-queue.test.ts +134 -1
  26. package/src/lib/chat/queued-message-queue.ts +77 -2
  27. package/src/lib/server/agents/agent-service.ts +5 -0
  28. package/src/lib/server/builtin-extensions.ts +1 -0
  29. package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +1 -1
  30. package/src/lib/server/chat-execution/chat-execution-tool-events.test.ts +1 -0
  31. package/src/lib/server/chat-execution/chat-execution-utils.ts +2 -2
  32. package/src/lib/server/chat-execution/chat-turn-preparation.ts +79 -42
  33. package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +4 -0
  34. package/src/lib/server/chat-execution/continuation-evaluator.ts +8 -0
  35. package/src/lib/server/chat-execution/memory-mutation-tools.ts +1 -1
  36. package/src/lib/server/chat-execution/message-classifier.ts +11 -1
  37. package/src/lib/server/chat-execution/prompt-builder.test.ts +28 -0
  38. package/src/lib/server/chat-execution/prompt-builder.ts +14 -1
  39. package/src/lib/server/chat-execution/prompt-mode.test.ts +24 -0
  40. package/src/lib/server/chat-execution/prompt-mode.ts +5 -1
  41. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +6 -4
  42. package/src/lib/server/chat-execution/stream-agent-chat.ts +45 -16
  43. package/src/lib/server/chatrooms/chatroom-routing.test.ts +4 -0
  44. package/src/lib/server/connectors/discord.ts +2 -2
  45. package/src/lib/server/connectors/matrix.ts +3 -2
  46. package/src/lib/server/connectors/signal.ts +5 -4
  47. package/src/lib/server/connectors/slack.ts +10 -9
  48. package/src/lib/server/connectors/teams.ts +3 -2
  49. package/src/lib/server/connectors/telegram.ts +4 -4
  50. package/src/lib/server/connectors/whatsapp.ts +2 -2
  51. package/src/lib/server/daemon/controller.ts +7 -0
  52. package/src/lib/server/gateways/gateway-profile-service.ts +19 -1
  53. package/src/lib/server/messages/message-repository.test.ts +70 -0
  54. package/src/lib/server/messages/message-repository.ts +11 -6
  55. package/src/lib/server/openclaw/deploy.ts +32 -2
  56. package/src/lib/server/plugins-advanced.test.ts +1 -2
  57. package/src/lib/server/provider-health.ts +1 -1
  58. package/src/lib/server/runtime/process-manager.ts +13 -9
  59. package/src/lib/server/runtime/session-run-manager/queries.ts +15 -0
  60. package/src/lib/server/runtime/session-run-manager.test.ts +58 -0
  61. package/src/lib/server/sandbox/session-runtime.test.ts +18 -1
  62. package/src/lib/server/sandbox/session-runtime.ts +40 -28
  63. package/src/lib/server/session-tools/autonomy-tools.test.ts +7 -9
  64. package/src/lib/server/session-tools/context.ts +1 -1
  65. package/src/lib/server/session-tools/credential-env.ts +109 -0
  66. package/src/lib/server/session-tools/crud.ts +3 -3
  67. package/src/lib/server/session-tools/edit_file.ts +3 -2
  68. package/src/lib/server/session-tools/execute.test.ts +58 -0
  69. package/src/lib/server/session-tools/execute.ts +334 -0
  70. package/src/lib/server/session-tools/files-tool.ts +635 -0
  71. package/src/lib/server/session-tools/index.ts +14 -4
  72. package/src/lib/server/session-tools/memory-tool.ts +242 -0
  73. package/src/lib/server/session-tools/memory.ts +1 -1
  74. package/src/lib/server/session-tools/openclaw-nodes.ts +3 -2
  75. package/src/lib/server/session-tools/openclaw-workspace.ts +3 -2
  76. package/src/lib/server/session-tools/platform-tool.ts +617 -0
  77. package/src/lib/server/session-tools/session-info.ts +3 -2
  78. package/src/lib/server/session-tools/session-tools-wiring.test.ts +3 -4
  79. package/src/lib/server/session-tools/shell.ts +7 -122
  80. package/src/lib/server/session-tools/skills-tool.ts +396 -0
  81. package/src/lib/server/session-tools/web.ts +2 -2
  82. package/src/lib/server/storage-normalization.ts +2 -0
  83. package/src/lib/server/tool-aliases.ts +2 -1
  84. package/src/lib/server/tool-capability-policy-advanced.test.ts +9 -2
  85. package/src/lib/server/tool-capability-policy.test.ts +2 -1
  86. package/src/lib/server/tool-capability-policy.ts +60 -33
  87. package/src/lib/server/tool-planning.ts +11 -0
  88. package/src/lib/setup-defaults.ts +5 -0
  89. package/src/lib/tool-definitions.ts +1 -0
  90. package/src/lib/validation/schemas.test.ts +16 -0
  91. package/src/lib/validation/schemas.ts +16 -0
  92. package/src/stores/use-chat-store.test.ts +231 -0
  93. package/src/stores/use-chat-store.ts +62 -13
  94. package/src/types/agent.ts +348 -0
  95. package/src/types/app-settings.ts +175 -0
  96. package/src/types/approval.ts +27 -0
  97. package/src/types/connector.ts +187 -0
  98. package/src/types/extension.ts +386 -0
  99. package/src/types/index.ts +16 -3555
  100. package/src/types/message.ts +57 -0
  101. package/src/types/misc.ts +739 -0
  102. package/src/types/mission.ts +185 -0
  103. package/src/types/protocol.ts +422 -0
  104. package/src/types/provider.ts +52 -0
  105. package/src/types/run.ts +183 -0
  106. package/src/types/schedule.ts +59 -0
  107. package/src/types/session.ts +265 -0
  108. package/src/types/skill.ts +157 -0
  109. package/src/types/task.ts +140 -0
  110. package/src/types/working-state.ts +211 -0
  111. package/src/views/settings/section-heartbeat.tsx +2 -2
  112. package/src/lib/server/session-tools/sandbox.ts +0 -281
@@ -20,11 +20,12 @@ test('capability policy balanced mode blocks destructive delete_file', () => {
20
20
 
21
21
  test('capability policy strict mode blocks execution/platform families', () => {
22
22
  const decision = resolveSessionToolPolicy(
23
- ['shell', 'manage_tasks', 'web_search', 'memory'],
23
+ ['shell', 'execute', 'manage_tasks', 'web_search', 'memory'],
24
24
  { capabilityPolicyMode: 'strict' },
25
25
  )
26
26
  assert.deepEqual(decision.enabledExtensions, ['web_search', 'memory'])
27
27
  assert.equal(decision.blockedExtensions.some((entry) => entry.tool === 'shell'), true)
28
+ assert.equal(decision.blockedExtensions.some((entry) => entry.tool === 'execute'), true)
28
29
  assert.equal(decision.blockedExtensions.some((entry) => entry.tool === 'manage_tasks'), true)
29
30
  })
30
31
 
@@ -1,5 +1,6 @@
1
1
  import type { AppSettings } from '@/types'
2
2
  import { dedup } from '@/lib/shared-utils'
3
+ import { canonicalizeExtensionId } from './tool-aliases'
3
4
 
4
5
  export type CapabilityPolicyMode = 'permissive' | 'balanced' | 'strict'
5
6
 
@@ -37,6 +38,7 @@ interface ToolDescriptor {
37
38
 
38
39
  const TOOL_DESCRIPTORS: Record<string, ToolDescriptor> = {
39
40
  shell: { categories: ['execution'], concreteTools: ['shell', 'execute_command'] },
41
+ execute: { categories: ['execution'], concreteTools: ['execute'] },
40
42
  process: { categories: ['execution'], concreteTools: ['process', 'process_tool'] },
41
43
  files: { categories: ['filesystem'], concreteTools: ['files', 'read_file', 'write_file', 'list_files', 'send_file'] },
42
44
  read_file: { categories: ['filesystem'], concreteTools: ['read_file'] },
@@ -57,7 +59,6 @@ const TOOL_DESCRIPTORS: Record<string, ToolDescriptor> = {
57
59
  opencode_cli: { categories: ['delegation', 'execution'], concreteTools: ['delegate_to_opencode_cli'] },
58
60
  gemini_cli: { categories: ['delegation', 'execution'], concreteTools: ['delegate_to_gemini_cli'] },
59
61
  memory: { categories: ['memory'], concreteTools: ['memory', 'memory_tool', 'memory_search', 'memory_get', 'memory_store', 'memory_update', 'context_status', 'context_summarize'] },
60
- // sandbox_exec/sandbox_list_runtimes routed through shell; git uses shell
61
62
  // http_request consolidated into web 'api' action — no separate descriptor
62
63
  canvas: { categories: ['filesystem'], concreteTools: ['canvas'] },
63
64
  wallet: { categories: ['outbound'], concreteTools: ['wallet', 'wallet_tool'] },
@@ -118,6 +119,55 @@ function normalizeList(value: unknown): string[] {
118
119
  return dedup(names)
119
120
  }
120
121
 
122
+ function getDescriptor(toolName: string): ToolDescriptor | undefined {
123
+ const normalized = normalizeName(toolName)
124
+ if (!normalized) return undefined
125
+ return TOOL_DESCRIPTORS[normalized] || TOOL_DESCRIPTORS[normalizeName(canonicalizeExtensionId(normalized))]
126
+ }
127
+
128
+ function addComparableName(names: Set<string>, value: string | null | undefined): void {
129
+ const normalized = normalizeName(value)
130
+ if (!normalized) return
131
+ names.add(normalized)
132
+ const canonical = normalizeName(canonicalizeExtensionId(normalized))
133
+ if (canonical) names.add(canonical)
134
+ for (const mappedTool of CONCRETE_TOOL_TO_SESSION_TOOLS.get(normalized) || []) {
135
+ names.add(mappedTool)
136
+ }
137
+ }
138
+
139
+ function collectRequestedExtensionNames(toolName: string, descriptor?: ToolDescriptor): string[] {
140
+ const names = new Set<string>()
141
+ addComparableName(names, toolName)
142
+ for (const concreteName of descriptor?.concreteTools || []) {
143
+ addComparableName(names, concreteName)
144
+ }
145
+ return Array.from(names)
146
+ }
147
+
148
+ function entryMatchesSessionTool(entry: string, sessionTool: string): boolean {
149
+ const normalizedEntry = normalizeName(entry)
150
+ const normalizedTool = normalizeName(sessionTool)
151
+ if (!normalizedEntry || !normalizedTool) return false
152
+ if (normalizedEntry === normalizedTool) return true
153
+ if (!CONCRETE_TOOL_TO_SESSION_TOOLS.has(normalizedEntry)) {
154
+ return normalizeName(canonicalizeExtensionId(normalizedEntry)) === normalizedTool
155
+ }
156
+ return false
157
+ }
158
+
159
+ function matchesConcreteToolSetting(configuredNames: Set<string>, concreteToolName: string): boolean {
160
+ const normalizedName = normalizeName(concreteToolName)
161
+ if (!normalizedName || configuredNames.size === 0) return false
162
+ if (configuredNames.has(normalizedName)) return true
163
+ for (const sessionTool of CONCRETE_TOOL_TO_SESSION_TOOLS.get(normalizedName) || []) {
164
+ for (const entry of configuredNames) {
165
+ if (entryMatchesSessionTool(entry, sessionTool)) return true
166
+ }
167
+ }
168
+ return false
169
+ }
170
+
121
171
  function getSettingsList(settings: Record<string, unknown>, key: string): string[] {
122
172
  return normalizeList(settings[key])
123
173
  }
@@ -138,29 +188,11 @@ function modeBlocksTool(mode: CapabilityPolicyMode, toolName: string, descriptor
138
188
  }
139
189
 
140
190
  function safetyMatchesTool(safetyBlocked: Set<string>, toolName: string, descriptor?: ToolDescriptor): boolean {
141
- if (safetyBlocked.has(toolName)) return true
142
- if (!descriptor) return false
143
- for (const concreteName of descriptor.concreteTools) {
144
- if (safetyBlocked.has(concreteName)) return true
145
- }
146
- if (toolName === 'memory' && safetyBlocked.has('memory_tool')) return true
147
- if (toolName === 'manage_connectors' && safetyBlocked.has('connector_message_tool')) return true
148
- if (toolName === 'manage_sessions' && (
149
- safetyBlocked.has('sessions_tool')
150
- || safetyBlocked.has('search_history_tool')
151
- || safetyBlocked.has('whoami_tool')
152
- )) return true
153
- if (toolName === 'claude_code' && safetyBlocked.has('delegate_to_claude_code')) return true
154
- if (toolName === 'codex_cli' && safetyBlocked.has('delegate_to_codex_cli')) return true
155
- if (toolName === 'opencode_cli' && safetyBlocked.has('delegate_to_opencode_cli')) return true
156
- if (toolName === 'gemini_cli' && safetyBlocked.has('delegate_to_gemini_cli')) return true
157
- return false
191
+ return collectRequestedExtensionNames(toolName, descriptor).some((name) => safetyBlocked.has(name))
158
192
  }
159
193
 
160
194
  function policyMatchesTool(blockedNames: Set<string>, toolName: string, descriptor?: ToolDescriptor): boolean {
161
- if (blockedNames.has(toolName)) return true
162
- if (!descriptor) return false
163
- return descriptor.concreteTools.some((concreteName) => blockedNames.has(concreteName))
195
+ return collectRequestedExtensionNames(toolName, descriptor).some((name) => blockedNames.has(name))
164
196
  }
165
197
 
166
198
  function categoryBlockReason(blockedCategories: Set<string>, descriptor?: ToolDescriptor): string | null {
@@ -232,7 +264,7 @@ export function resolveSessionToolPolicy(
232
264
  const blockedExtensions: CapabilityPolicyBlock[] = []
233
265
 
234
266
  for (const extensionName of requestedExtensions) {
235
- const descriptor = TOOL_DESCRIPTORS[extensionName]
267
+ const descriptor = getDescriptor(extensionName)
236
268
  const settingsReason = settingsBlockReason(extensionName, normalizedSettings)
237
269
 
238
270
  if (settingsReason) {
@@ -296,24 +328,19 @@ export function resolveConcreteToolPolicyBlock(
296
328
 
297
329
  if (settingsReason) return settingsReason
298
330
 
299
- if (safetyBlocked.has(name)) return 'blocked by safety policy'
300
-
301
331
  const mappedTools = CONCRETE_TOOL_TO_SESSION_TOOLS.get(name) || []
302
- const safetyBlockedFamily = mappedTools.find((tool) => safetyBlocked.has(tool))
303
- if (safetyBlockedFamily) return `blocked because "${safetyBlockedFamily}" is safety-blocked`
304
-
305
- if (policyBlockedNames.has(name)) return 'blocked by explicit policy rule'
306
- const policyBlockedFamily = mappedTools.find((tool) => policyBlockedNames.has(tool) && !policyAllowedNames.has(tool))
307
- if (policyBlockedFamily) {
308
- return `blocked because "${policyBlockedFamily}" is policy-blocked`
332
+ if (matchesConcreteToolSetting(safetyBlocked, name)) return 'blocked by safety policy'
333
+ const explicitlyAllowed = matchesConcreteToolSetting(policyAllowedNames, name)
334
+ if (matchesConcreteToolSetting(policyBlockedNames, name) && !explicitlyAllowed) {
335
+ return 'blocked by explicit policy rule'
309
336
  }
310
337
 
311
338
  if (mappedTools.length > 0) {
312
- const enabledRoot = mappedTools.find((tool) => decision.enabledExtensions.includes(tool))
339
+ const enabledRoot = mappedTools.find((tool) => decision.enabledExtensions.some((entry) => entryMatchesSessionTool(entry, tool)))
313
340
  if (enabledRoot) return null
314
341
 
315
342
  const blockedRoot = mappedTools
316
- .map((tool) => decision.blockedExtensions.find((entry) => entry.tool === tool))
343
+ .map((tool) => decision.blockedExtensions.find((entry) => entryMatchesSessionTool(entry.tool, tool)))
317
344
  .find(Boolean)
318
345
  if (blockedRoot) return blockedRoot.reason
319
346
 
@@ -58,6 +58,17 @@ const CORE_TOOL_PLANNING: Record<string, LegacyToolPlanningEntry[]> = {
58
58
  requestMatchers: [],
59
59
  },
60
60
  ],
61
+ execute: [
62
+ {
63
+ toolName: 'execute',
64
+ capabilities: ['runtime.execute'],
65
+ disciplineGuidance: [
66
+ 'For `execute`, pass the full bash script in `{"code":"..."}`. Use it for sandboxed command execution, curl-based fetches, and one-shot scripts.',
67
+ 'Use `persistent=true` only when the agent is explicitly configured for host execution. Otherwise use `files` for persistent writes.',
68
+ ],
69
+ requestMatchers: [],
70
+ },
71
+ ],
61
72
  web: [
62
73
  {
63
74
  toolName: 'web_search',
@@ -204,6 +204,7 @@ export const SETUP_PROVIDERS: SetupProviderOption[] = [
204
204
  export const STARTER_AGENT_TOOLS = [
205
205
  'memory',
206
206
  'files',
207
+ 'execute',
207
208
  'web_search',
208
209
  'web_fetch',
209
210
  'browser',
@@ -363,6 +364,7 @@ export interface StarterKit {
363
364
  const PERSONAL_AGENT_TOOLS = [
364
365
  'memory',
365
366
  'files',
367
+ 'execute',
366
368
  'web_search',
367
369
  'web_fetch',
368
370
  'browser',
@@ -374,6 +376,7 @@ const PERSONAL_AGENT_TOOLS = [
374
376
  const RESEARCH_AGENT_TOOLS = [
375
377
  'memory',
376
378
  'files',
379
+ 'execute',
377
380
  'web_search',
378
381
  'web_fetch',
379
382
  'browser',
@@ -384,6 +387,7 @@ const RESEARCH_AGENT_TOOLS = [
384
387
  const BUILDER_AGENT_TOOLS = [
385
388
  'memory',
386
389
  'files',
390
+ 'execute',
387
391
  'web_search',
388
392
  'web_fetch',
389
393
  'browser',
@@ -397,6 +401,7 @@ const OPERATOR_AGENT_TOOLS = STARTER_AGENT_TOOLS
397
401
  const OPENCLAW_AGENT_TOOLS = [
398
402
  'memory',
399
403
  'files',
404
+ 'execute',
400
405
  'web_search',
401
406
  'web_fetch',
402
407
  'browser',
@@ -16,6 +16,7 @@ export interface ToolDefinition {
16
16
  */
17
17
  export const AVAILABLE_TOOLS: ToolDefinition[] = [
18
18
  { id: 'shell', label: 'Shell', description: 'Execute commands in the working directory and manage background processes' },
19
+ { id: 'execute', label: 'Execute', description: 'Run sandboxed bash scripts with just-bash, with optional host execution when explicitly enabled' },
19
20
  { id: 'files', label: 'Files', description: 'Complete file management: read, write, list, move, copy, delete, and send' },
20
21
  { id: 'edit_file', label: 'Edit File', description: 'Surgical search-and-replace within files' },
21
22
  { id: 'web', label: 'Web', description: 'Search the web, fetch content, and make HTTP API calls' },
@@ -57,4 +57,20 @@ describe('AgentCreateSchema', () => {
57
57
  assert.equal(parsed.orchestratorMaxCyclesPerDay, 12)
58
58
  assert.equal(parsed.sessionResetMode, 'isolated')
59
59
  })
60
+
61
+ it('accepts executeConfig for sandboxed execute defaults', () => {
62
+ const parsed = AgentCreateSchema.parse({
63
+ name: 'Builder',
64
+ provider: 'openai',
65
+ executeConfig: {
66
+ backend: 'sandbox',
67
+ network: { enabled: true },
68
+ timeout: 45,
69
+ },
70
+ })
71
+
72
+ assert.equal(parsed.executeConfig?.backend, 'sandbox')
73
+ assert.equal(parsed.executeConfig?.network?.enabled, true)
74
+ assert.equal(parsed.executeConfig?.timeout, 45)
75
+ })
60
76
  })
@@ -41,6 +41,21 @@ const AgentSandboxConfigSchema = z.object({
41
41
  prune: AgentSandboxPruneSchema,
42
42
  }).nullable().optional()
43
43
 
44
+ const AgentExecuteConfigSchema = z.object({
45
+ backend: z.enum(['sandbox', 'host']).optional(),
46
+ network: z.object({
47
+ enabled: z.boolean(),
48
+ allowedUrls: z.array(z.string()).optional(),
49
+ }).optional(),
50
+ runtimes: z.object({
51
+ python: z.boolean().optional(),
52
+ javascript: z.boolean().optional(),
53
+ sqlite: z.boolean().optional(),
54
+ }).optional(),
55
+ timeout: z.number().int().positive().optional(),
56
+ credentials: z.array(z.string()).optional(),
57
+ }).nullable().optional()
58
+
44
59
  const AgentRoutingTargetSchema = z.object({
45
60
  id: z.string().min(1),
46
61
  label: z.string().optional(),
@@ -110,6 +125,7 @@ export const AgentCreateSchema = z.object({
110
125
  avatarSeed: z.string().optional(),
111
126
  avatarUrl: z.string().nullable().optional().default(null),
112
127
  sandboxConfig: AgentSandboxConfigSchema,
128
+ executeConfig: AgentExecuteConfigSchema,
113
129
  autoRecovery: z.boolean().optional().default(false),
114
130
  monthlyBudget: z.number().positive().nullable().optional().default(null),
115
131
  dailyBudget: z.number().positive().nullable().optional().default(null),
@@ -451,6 +451,103 @@ describe('useChatStore control-token hygiene', () => {
451
451
  assert.equal(useAppStore.getState().sessions['session-1']?.currentRunId, 'run-active')
452
452
  })
453
453
 
454
+ it('hydrates the active turn from the backend queue snapshot as a sending placeholder', async () => {
455
+ const now = Date.now()
456
+ const session = makeSession()
457
+ useAppStore.setState({
458
+ agents: { 'agent-1': makeAgent() },
459
+ sessions: { [session.id]: session },
460
+ currentAgentId: 'agent-1',
461
+ })
462
+ useChatStore.setState({
463
+ messages: [],
464
+ pendingFiles: [],
465
+ replyingTo: null,
466
+ toolEvents: [],
467
+ streamText: '',
468
+ displayText: '',
469
+ streaming: false,
470
+ streamingSessionId: null,
471
+ streamSource: null,
472
+ assistantRenderId: null,
473
+ streamPhase: 'thinking',
474
+ streamToolName: '',
475
+ thinkingText: '',
476
+ thinkingStartTime: 0,
477
+ queuedMessages: [],
478
+ agentStatus: null,
479
+ lastUsage: null,
480
+ hasMoreMessages: false,
481
+ loadingMore: false,
482
+ totalMessages: 0,
483
+ })
484
+
485
+ global.fetch = (async (input: RequestInfo | URL) => {
486
+ const url = String(input)
487
+ if (url === '/api/chats/session-1/queue') {
488
+ return jsonResponse({
489
+ sessionId: 'session-1',
490
+ activeRunId: 'run-active',
491
+ activeTurn: {
492
+ runId: 'run-active',
493
+ sessionId: 'session-1',
494
+ text: 'Already running',
495
+ queuedAt: now,
496
+ position: 0,
497
+ },
498
+ queueLength: 1,
499
+ items: [
500
+ { runId: 'run-queued-2', sessionId: 'session-1', text: 'Then refine it', queuedAt: now + 1, position: 1 },
501
+ ],
502
+ })
503
+ }
504
+ throw new Error(`Unexpected fetch: ${url}`)
505
+ }) as unknown as typeof fetch
506
+
507
+ await useChatStore.getState().loadQueuedMessages('session-1')
508
+
509
+ const state = useChatStore.getState()
510
+ assert.deepEqual(
511
+ state.queuedMessages.map((item) => [item.runId, item.sending === true]),
512
+ [['run-active', true], ['run-queued-2', false]],
513
+ )
514
+ assert.equal(useAppStore.getState().sessions['session-1']?.queuedCount, 1)
515
+ assert.equal(useAppStore.getState().sessions['session-1']?.currentRunId, 'run-active')
516
+ })
517
+
518
+ it('clears sending placeholders when a persisted user message with the same runId arrives', () => {
519
+ useChatStore.setState({
520
+ messages: [],
521
+ assistantRenderId: null,
522
+ queuedMessages: [
523
+ { runId: 'run-active', sessionId: 'session-1', text: 'Already running', queuedAt: 9, position: 0, sending: true },
524
+ ],
525
+ toolEvents: [],
526
+ streamText: '',
527
+ displayText: '',
528
+ streaming: false,
529
+ streamingSessionId: null,
530
+ streamSource: null,
531
+ streamPhase: 'thinking',
532
+ streamToolName: '',
533
+ thinkingText: '',
534
+ thinkingStartTime: 0,
535
+ agentStatus: null,
536
+ lastUsage: null,
537
+ hasMoreMessages: false,
538
+ loadingMore: false,
539
+ totalMessages: 0,
540
+ })
541
+
542
+ useChatStore.getState().setMessages([
543
+ { role: 'user', text: 'Already running', time: 10, runId: 'run-active' },
544
+ ])
545
+
546
+ const state = useChatStore.getState()
547
+ assert.equal(state.queuedMessages.length, 0)
548
+ assert.equal(state.messages[0]?.runId, 'run-active')
549
+ })
550
+
454
551
  it('removes optimistic queued items again when the backend enqueue fails', async () => {
455
552
  const session = makeSession()
456
553
  useAppStore.setState({
@@ -533,4 +630,138 @@ describe('useChatStore control-token hygiene', () => {
533
630
  assert.equal(state.assistantRenderId, 'render-1')
534
631
  assert.equal(state.messages[1]?.clientRenderId, 'render-1')
535
632
  })
633
+
634
+ it('preserves transcript totals on local message updates for paginated windows', () => {
635
+ useChatStore.setState({
636
+ messages: [
637
+ { role: 'user', text: 'Ninth', time: 9, bookmarked: false },
638
+ { role: 'assistant', text: 'Tenth', time: 10 },
639
+ ],
640
+ messageStartIndex: 8,
641
+ assistantRenderId: null,
642
+ toolEvents: [],
643
+ streamText: '',
644
+ displayText: '',
645
+ streaming: false,
646
+ streamingSessionId: null,
647
+ streamSource: null,
648
+ streamPhase: 'thinking',
649
+ streamToolName: '',
650
+ thinkingText: '',
651
+ thinkingStartTime: 0,
652
+ queuedMessages: [],
653
+ agentStatus: null,
654
+ lastUsage: null,
655
+ hasMoreMessages: true,
656
+ loadingMore: false,
657
+ totalMessages: 10,
658
+ })
659
+
660
+ useChatStore.getState().setMessages([
661
+ { role: 'user', text: 'Ninth', time: 9, bookmarked: true },
662
+ { role: 'assistant', text: 'Tenth', time: 10 },
663
+ ])
664
+
665
+ const state = useChatStore.getState()
666
+ assert.equal(state.messageStartIndex, 8)
667
+ assert.equal(state.totalMessages, 10)
668
+ assert.equal(state.hasMoreMessages, true)
669
+ assert.equal(state.messages[0]?.bookmarked, true)
670
+ })
671
+
672
+ it('does not timeout sending items before 60 seconds', () => {
673
+ const thirtySecondsAgo = Date.now() - 30_000
674
+ useChatStore.setState({
675
+ messages: [],
676
+ assistantRenderId: null,
677
+ toolEvents: [],
678
+ streamText: '',
679
+ displayText: '',
680
+ streaming: false,
681
+ streamingSessionId: null,
682
+ streamSource: null,
683
+ streamPhase: 'thinking',
684
+ streamToolName: '',
685
+ thinkingText: '',
686
+ thinkingStartTime: 0,
687
+ queuedMessages: [
688
+ { runId: 'run-old', sessionId: 'session-1', text: 'Waiting', queuedAt: thirtySecondsAgo, position: 0, sending: true },
689
+ ],
690
+ agentStatus: null,
691
+ lastUsage: null,
692
+ hasMoreMessages: false,
693
+ loadingMore: false,
694
+ totalMessages: 0,
695
+ })
696
+
697
+ // setMessages with no matching persisted message — item should survive
698
+ useChatStore.getState().setMessages([
699
+ { role: 'user', text: 'Unrelated', time: Date.now() },
700
+ ])
701
+
702
+ const state = useChatStore.getState()
703
+ assert.equal(state.queuedMessages.length, 1)
704
+ assert.equal(state.queuedMessages[0]?.runId, 'run-old')
705
+ })
706
+
707
+ it('tracks messageStartIndex when loading more paginated history', async () => {
708
+ const session = makeSession()
709
+ useAppStore.setState({
710
+ agents: { 'agent-1': makeAgent() },
711
+ sessions: { [session.id]: session },
712
+ currentAgentId: 'agent-1',
713
+ })
714
+ useChatStore.setState({
715
+ messages: [
716
+ { role: 'user', text: 'Ninth', time: 9 },
717
+ { role: 'assistant', text: 'Tenth', time: 10 },
718
+ ],
719
+ messageStartIndex: 8,
720
+ pendingFiles: [],
721
+ replyingTo: null,
722
+ toolEvents: [],
723
+ streamText: '',
724
+ displayText: '',
725
+ streaming: false,
726
+ streamingSessionId: null,
727
+ streamSource: null,
728
+ assistantRenderId: null,
729
+ streamPhase: 'thinking',
730
+ streamToolName: '',
731
+ thinkingText: '',
732
+ thinkingStartTime: 0,
733
+ queuedMessages: [],
734
+ agentStatus: null,
735
+ lastUsage: null,
736
+ hasMoreMessages: true,
737
+ loadingMore: false,
738
+ totalMessages: 10,
739
+ })
740
+
741
+ global.fetch = (async (input: RequestInfo | URL) => {
742
+ const url = String(input)
743
+ if (url === '/api/chats/session-1/messages?limit=100&before=8') {
744
+ return jsonResponse({
745
+ messages: [
746
+ { role: 'user', text: 'Seventh', time: 7 },
747
+ { role: 'assistant', text: 'Eighth', time: 8 },
748
+ ],
749
+ total: 10,
750
+ hasMore: true,
751
+ startIndex: 6,
752
+ })
753
+ }
754
+ throw new Error(`Unexpected fetch: ${url}`)
755
+ }) as unknown as typeof fetch
756
+
757
+ await useChatStore.getState().loadMoreMessages()
758
+
759
+ const state = useChatStore.getState()
760
+ assert.equal(state.messageStartIndex, 6)
761
+ assert.equal(state.totalMessages, 10)
762
+ assert.deepEqual(
763
+ state.messages.map((message) => message.text),
764
+ ['Seventh', 'Eighth', 'Ninth', 'Tenth'],
765
+ )
766
+ })
536
767
  })
@@ -66,7 +66,8 @@ interface ChatState {
66
66
  agentStatus: { goal?: string; status?: string; summary?: string; nextAction?: string } | null
67
67
 
68
68
  messages: Message[]
69
- setMessages: (msgs: Message[]) => void
69
+ messageStartIndex: number
70
+ setMessages: (msgs: Message[], options?: { startIndex?: number; totalMessages?: number }) => void
70
71
 
71
72
  toolEvents: ToolEvent[]
72
73
  clearToolEvents: () => void
@@ -136,6 +137,10 @@ interface ChatState {
136
137
  loadMoreMessages: () => Promise<void>
137
138
  }
138
139
 
140
+ /** Safety-net timeout for "sending" queue items. Normally cleaned up by
141
+ * matchesPersistedQueuedMessage well before this — only fires if matching fails. */
142
+ const SENDING_ITEM_TIMEOUT_MS = 60_000
143
+
139
144
  const CONTROL_TOKEN_PREFIX_RE = /^\s*(?:NO_MESSAGE|HEARTBEAT_OK)(?:(?=[\s.,:;!?()[\]{}"'`-]|$)|(?=[A-Z]))\s*/i
140
145
  const CONTROL_TOKEN_LINE_RE = /(^|\n)\s*(?:NO_MESSAGE|HEARTBEAT_OK)\s*(\n|$)/gi
141
146
 
@@ -165,6 +170,29 @@ function reconcileMessagesForState(
165
170
  return { messages, assistantRenderId: nextAssistantRenderId }
166
171
  }
167
172
 
173
+ function attachedFilesEqual(left: string[] | undefined, right: string[] | undefined): boolean {
174
+ if (!left?.length && !right?.length) return true
175
+ if ((left?.length || 0) !== (right?.length || 0)) return false
176
+ for (let index = 0; index < (left?.length || 0); index += 1) {
177
+ if (left?.[index] !== right?.[index]) return false
178
+ }
179
+ return true
180
+ }
181
+
182
+ function matchesPersistedQueuedMessage(message: Message, queued: QueuedSessionMessage): boolean {
183
+ if (message.role !== 'user') return false
184
+ const messageRunId = typeof message.runId === 'string' && message.runId.trim() ? message.runId : null
185
+ const queuedRunId = typeof queued.runId === 'string' && queued.runId.trim() ? queued.runId : null
186
+ if (messageRunId && queuedRunId) return messageRunId === queuedRunId
187
+ return (
188
+ message.text === queued.text
189
+ && message.replyToId === queued.replyToId
190
+ && message.imagePath === queued.imagePath
191
+ && message.imageUrl === queued.imageUrl
192
+ && attachedFilesEqual(message.attachedFiles, queued.attachedFiles)
193
+ )
194
+ }
195
+
168
196
  function syncSessionQueueState(sessionId: string, params: {
169
197
  queuedCount: number
170
198
  currentRunId?: string | null
@@ -203,13 +231,14 @@ export const useChatStore = create<ChatState>((set, get) => ({
203
231
  displayText: '',
204
232
  agentStatus: null,
205
233
  messages: [],
206
- setMessages: (msgs) => set((s) => {
234
+ messageStartIndex: 0,
235
+ setMessages: (msgs, options) => set((s) => {
207
236
  const next = reconcileMessagesForState(msgs, s.messages, s.assistantRenderId)
208
237
  // Clear "sending" queue items whose text now appears in the message list
209
238
  const queuedMessages = s.queuedMessages.filter((item) => {
210
239
  if (!item.sending) return true
211
- if (next.messages.some((m) => m.role === 'user' && m.text === item.text)) return false
212
- if (Date.now() - item.queuedAt > 15_000) return false
240
+ if (next.messages.some((message) => matchesPersistedQueuedMessage(message, item))) return false
241
+ if (Date.now() - item.queuedAt > SENDING_ITEM_TIMEOUT_MS) return false
213
242
  return true
214
243
  })
215
244
  const patch: Partial<ChatState> = {
@@ -217,9 +246,29 @@ export const useChatStore = create<ChatState>((set, get) => ({
217
246
  assistantRenderId: next.assistantRenderId,
218
247
  queuedMessages,
219
248
  }
249
+ if (typeof options?.startIndex === 'number' && Number.isFinite(options.startIndex)) {
250
+ patch.messageStartIndex = Math.max(0, Math.trunc(options.startIndex))
251
+ } else if (next.messages.length === 0) {
252
+ patch.messageStartIndex = 0
253
+ }
220
254
  if (s.toolEvents.length > 0) patch.toolEvents = []
221
- if (s.hasMoreMessages) patch.hasMoreMessages = false
222
- if (s.totalMessages !== next.messages.length) patch.totalMessages = next.messages.length
255
+ if (next.messages.length === 0) {
256
+ patch.hasMoreMessages = false
257
+ } else if (
258
+ typeof options?.startIndex === 'number'
259
+ && Number.isFinite(options.startIndex)
260
+ && Math.trunc(options.startIndex) === 0
261
+ && typeof options?.totalMessages === 'number'
262
+ && Number.isFinite(options.totalMessages)
263
+ && Math.max(0, Math.trunc(options.totalMessages)) === next.messages.length
264
+ ) {
265
+ patch.hasMoreMessages = false
266
+ }
267
+ if (typeof options?.totalMessages === 'number' && Number.isFinite(options.totalMessages)) {
268
+ patch.totalMessages = Math.max(0, Math.trunc(options.totalMessages))
269
+ } else if (next.messages.length === 0 && s.totalMessages !== 0) {
270
+ patch.totalMessages = 0
271
+ }
223
272
  return patch
224
273
  }),
225
274
  toolEvents: [],
@@ -253,8 +302,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
253
302
  const messages = s.messages
254
303
  const cleaned = next.filter((item) => {
255
304
  if (!item.sending || item.sessionId !== sessionId) return true
256
- if (messages.some((m) => m.role === 'user' && m.text === item.text)) return false
257
- if (Date.now() - item.queuedAt > 15_000) return false
305
+ if (messages.some((message) => matchesPersistedQueuedMessage(message, item))) return false
306
+ if (Date.now() - item.queuedAt > SENDING_ITEM_TIMEOUT_MS) return false
258
307
  return true
259
308
  })
260
309
  return { queuedMessages: cleaned }
@@ -675,7 +724,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
675
724
  })
676
725
  if (msgsRes.ok) {
677
726
  const msgs = await msgsRes.json()
678
- get().setMessages(msgs)
727
+ get().setMessages(msgs, { startIndex: 0, totalMessages: msgs.length })
679
728
  }
680
729
  // Re-send with the new text
681
730
  await get().sendMessage(newText)
@@ -703,7 +752,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
703
752
  })
704
753
  if (msgsRes.ok) {
705
754
  const msgs = await msgsRes.json()
706
- get().setMessages(msgs)
755
+ get().setMessages(msgs, { startIndex: 0, totalMessages: msgs.length })
707
756
  }
708
757
  // Re-send the last user message through the normal SSE flow
709
758
  if (imagePath) {
@@ -817,15 +866,14 @@ export const useChatStore = create<ChatState>((set, get) => ({
817
866
  loadingMore: false,
818
867
  totalMessages: 0,
819
868
  loadMoreMessages: async () => {
820
- const { messages, loadingMore, hasMoreMessages, totalMessages } = get()
869
+ const { loadingMore, hasMoreMessages, messageStartIndex } = get()
821
870
  if (loadingMore || !hasMoreMessages) return
822
871
  const sessionId = selectActiveSessionId(useAppStore.getState())
823
872
  if (!sessionId) return
824
873
  set({ loadingMore: true })
825
874
  try {
826
875
  const key = getStoredAccessKey()
827
- // Find the earliest message's original index (startIndex tracked on initial load)
828
- const currentStartIndex = totalMessages - messages.length
876
+ const currentStartIndex = messageStartIndex
829
877
  const res = await fetch(`/api/chats/${sessionId}/messages?limit=100&before=${currentStartIndex}`, {
830
878
  headers: key ? { 'X-Access-Key': key } : undefined,
831
879
  })
@@ -840,6 +888,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
840
888
  return {
841
889
  messages: next.messages,
842
890
  assistantRenderId: next.assistantRenderId,
891
+ messageStartIndex: data.startIndex,
843
892
  hasMoreMessages: data.hasMore,
844
893
  totalMessages: data.total,
845
894
  loadingMore: false,