@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.
- package/README.md +24 -17
- package/next.config.ts +1 -0
- package/package.json +3 -2
- package/scripts/easy-setup.mjs +1 -1
- package/scripts/postinstall.mjs +1 -1
- package/skills/swarmclaw.md +115 -0
- package/skills/tools/browser.md +131 -0
- package/skills/tools/execute.md +98 -0
- package/skills/tools/files.md +98 -0
- package/skills/tools/memory.md +104 -0
- package/skills/tools/platform.md +144 -0
- package/skills/tools/skills.md +83 -0
- package/src/app/api/chats/[id]/messages/route.ts +23 -19
- package/src/app/api/chats/messages-route.test.ts +105 -51
- package/src/app/api/mcp-servers/[id]/test/route.ts +3 -2
- package/src/app/api/openclaw/deploy/route.ts +2 -0
- package/src/app/api/setup/doctor/route.ts +4 -4
- package/src/components/agents/agent-chat-list.tsx +23 -1
- package/src/components/agents/inspector-panel.tsx +165 -48
- package/src/components/chat/chat-area.tsx +38 -9
- package/src/components/chat/message-list.tsx +33 -19
- package/src/components/gateways/gateway-sheet.tsx +5 -2
- package/src/lib/agent-execute-defaults.test.ts +24 -0
- package/src/lib/agent-execute-defaults.ts +62 -0
- package/src/lib/chat/queued-message-queue.test.ts +134 -1
- package/src/lib/chat/queued-message-queue.ts +77 -2
- package/src/lib/server/agents/agent-service.ts +5 -0
- package/src/lib/server/builtin-extensions.ts +1 -0
- package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +1 -1
- package/src/lib/server/chat-execution/chat-execution-tool-events.test.ts +1 -0
- package/src/lib/server/chat-execution/chat-execution-utils.ts +2 -2
- package/src/lib/server/chat-execution/chat-turn-preparation.ts +79 -42
- package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +4 -0
- package/src/lib/server/chat-execution/continuation-evaluator.ts +8 -0
- package/src/lib/server/chat-execution/memory-mutation-tools.ts +1 -1
- package/src/lib/server/chat-execution/message-classifier.ts +11 -1
- package/src/lib/server/chat-execution/prompt-builder.test.ts +28 -0
- package/src/lib/server/chat-execution/prompt-builder.ts +14 -1
- package/src/lib/server/chat-execution/prompt-mode.test.ts +24 -0
- package/src/lib/server/chat-execution/prompt-mode.ts +5 -1
- package/src/lib/server/chat-execution/stream-agent-chat.test.ts +6 -4
- package/src/lib/server/chat-execution/stream-agent-chat.ts +45 -16
- package/src/lib/server/chatrooms/chatroom-routing.test.ts +4 -0
- package/src/lib/server/connectors/discord.ts +2 -2
- package/src/lib/server/connectors/matrix.ts +3 -2
- package/src/lib/server/connectors/signal.ts +5 -4
- package/src/lib/server/connectors/slack.ts +10 -9
- package/src/lib/server/connectors/teams.ts +3 -2
- package/src/lib/server/connectors/telegram.ts +4 -4
- package/src/lib/server/connectors/whatsapp.ts +2 -2
- package/src/lib/server/daemon/controller.ts +7 -0
- package/src/lib/server/gateways/gateway-profile-service.ts +19 -1
- package/src/lib/server/messages/message-repository.test.ts +70 -0
- package/src/lib/server/messages/message-repository.ts +11 -6
- package/src/lib/server/openclaw/deploy.ts +32 -2
- package/src/lib/server/plugins-advanced.test.ts +1 -2
- package/src/lib/server/provider-health.ts +1 -1
- package/src/lib/server/runtime/process-manager.ts +13 -9
- package/src/lib/server/runtime/session-run-manager/queries.ts +15 -0
- package/src/lib/server/runtime/session-run-manager.test.ts +58 -0
- package/src/lib/server/sandbox/session-runtime.test.ts +18 -1
- package/src/lib/server/sandbox/session-runtime.ts +40 -28
- package/src/lib/server/session-tools/autonomy-tools.test.ts +7 -9
- package/src/lib/server/session-tools/context.ts +1 -1
- package/src/lib/server/session-tools/credential-env.ts +109 -0
- package/src/lib/server/session-tools/crud.ts +3 -3
- package/src/lib/server/session-tools/edit_file.ts +3 -2
- package/src/lib/server/session-tools/execute.test.ts +58 -0
- package/src/lib/server/session-tools/execute.ts +334 -0
- package/src/lib/server/session-tools/files-tool.ts +635 -0
- package/src/lib/server/session-tools/index.ts +14 -4
- package/src/lib/server/session-tools/memory-tool.ts +242 -0
- package/src/lib/server/session-tools/memory.ts +1 -1
- package/src/lib/server/session-tools/openclaw-nodes.ts +3 -2
- package/src/lib/server/session-tools/openclaw-workspace.ts +3 -2
- package/src/lib/server/session-tools/platform-tool.ts +617 -0
- package/src/lib/server/session-tools/session-info.ts +3 -2
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +3 -4
- package/src/lib/server/session-tools/shell.ts +7 -122
- package/src/lib/server/session-tools/skills-tool.ts +396 -0
- package/src/lib/server/session-tools/web.ts +2 -2
- package/src/lib/server/storage-normalization.ts +2 -0
- package/src/lib/server/tool-aliases.ts +2 -1
- package/src/lib/server/tool-capability-policy-advanced.test.ts +9 -2
- package/src/lib/server/tool-capability-policy.test.ts +2 -1
- package/src/lib/server/tool-capability-policy.ts +60 -33
- package/src/lib/server/tool-planning.ts +11 -0
- package/src/lib/setup-defaults.ts +5 -0
- package/src/lib/tool-definitions.ts +1 -0
- package/src/lib/validation/schemas.test.ts +16 -0
- package/src/lib/validation/schemas.ts +16 -0
- package/src/stores/use-chat-store.test.ts +231 -0
- package/src/stores/use-chat-store.ts +62 -13
- package/src/types/agent.ts +348 -0
- package/src/types/app-settings.ts +175 -0
- package/src/types/approval.ts +27 -0
- package/src/types/connector.ts +187 -0
- package/src/types/extension.ts +386 -0
- package/src/types/index.ts +16 -3555
- package/src/types/message.ts +57 -0
- package/src/types/misc.ts +739 -0
- package/src/types/mission.ts +185 -0
- package/src/types/protocol.ts +422 -0
- package/src/types/provider.ts +52 -0
- package/src/types/run.ts +183 -0
- package/src/types/schedule.ts +59 -0
- package/src/types/session.ts +265 -0
- package/src/types/skill.ts +157 -0
- package/src/types/task.ts +140 -0
- package/src/types/working-state.ts +211 -0
- package/src/views/settings/section-heartbeat.tsx +2 -2
- 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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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((
|
|
212
|
-
if (Date.now() - item.queuedAt >
|
|
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 (
|
|
222
|
-
|
|
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((
|
|
257
|
-
if (Date.now() - item.queuedAt >
|
|
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 {
|
|
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
|
-
|
|
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,
|