@swarmclawai/swarmclaw 0.7.2 → 0.7.4
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 +116 -50
- package/bin/package-manager.js +157 -0
- package/bin/package-manager.test.js +90 -0
- package/bin/server-cmd.js +38 -7
- package/bin/swarmclaw.js +54 -4
- package/bin/update-cmd.js +48 -10
- package/bin/update-cmd.test.js +55 -0
- package/package.json +8 -3
- package/scripts/postinstall.mjs +26 -0
- package/src/app/api/agents/[id]/route.ts +43 -0
- package/src/app/api/agents/[id]/thread/route.ts +39 -8
- package/src/app/api/agents/route.ts +35 -2
- package/src/app/api/auth/route.ts +77 -8
- package/src/app/api/chatrooms/[id]/chat/route.ts +22 -6
- package/src/app/api/chatrooms/[id]/pins/route.ts +2 -1
- package/src/app/api/chatrooms/[id]/reactions/route.ts +2 -1
- package/src/app/api/chatrooms/[id]/route.ts +6 -0
- package/src/app/api/chats/[id]/browser/route.ts +5 -1
- package/src/app/api/chats/[id]/chat/route.ts +7 -3
- package/src/app/api/chats/[id]/messages/route.ts +19 -13
- package/src/app/api/chats/[id]/route.ts +30 -0
- package/src/app/api/chats/[id]/stop/route.ts +6 -1
- package/src/app/api/chats/heartbeat/route.ts +2 -1
- package/src/app/api/chats/route.ts +23 -1
- package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
- package/src/app/api/connectors/doctor/route.ts +13 -0
- package/src/app/api/external-agents/[id]/heartbeat/route.ts +33 -0
- package/src/app/api/external-agents/[id]/route.ts +31 -0
- package/src/app/api/external-agents/register/route.ts +3 -0
- package/src/app/api/external-agents/route.ts +66 -0
- package/src/app/api/files/open/route.ts +16 -14
- package/src/app/api/gateways/[id]/health/route.ts +28 -0
- package/src/app/api/gateways/[id]/route.ts +79 -0
- package/src/app/api/gateways/route.ts +57 -0
- package/src/app/api/memory/maintenance/route.ts +11 -1
- package/src/app/api/openclaw/agent-files/route.ts +27 -4
- package/src/app/api/openclaw/gateway/route.ts +10 -7
- package/src/app/api/openclaw/skills/route.ts +12 -4
- package/src/app/api/plugins/dependencies/route.ts +24 -0
- package/src/app/api/plugins/install/route.ts +15 -92
- package/src/app/api/plugins/route.ts +3 -26
- package/src/app/api/plugins/settings/route.ts +17 -12
- package/src/app/api/plugins/ui/route.ts +1 -0
- package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
- package/src/app/api/schedules/[id]/route.ts +38 -9
- package/src/app/api/schedules/route.ts +51 -28
- package/src/app/api/settings/route.ts +55 -17
- package/src/app/api/setup/doctor/route.ts +6 -4
- package/src/app/api/tasks/[id]/route.ts +16 -6
- package/src/app/api/tasks/bulk/route.ts +3 -3
- package/src/app/api/tasks/route.ts +9 -4
- package/src/app/api/webhooks/[id]/route.ts +8 -1
- package/src/app/page.tsx +135 -17
- package/src/cli/binary.test.js +142 -0
- package/src/cli/index.js +38 -11
- package/src/cli/index.test.js +195 -0
- package/src/cli/index.ts +21 -12
- package/src/cli/server-cmd.test.js +59 -0
- package/src/cli/spec.js +20 -2
- package/src/components/agents/agent-card.tsx +15 -12
- package/src/components/agents/agent-chat-list.tsx +101 -1
- package/src/components/agents/agent-list.tsx +46 -9
- package/src/components/agents/agent-sheet.tsx +456 -23
- package/src/components/agents/inspector-panel.tsx +110 -49
- package/src/components/agents/sandbox-env-panel.tsx +4 -1
- package/src/components/auth/access-key-gate.tsx +36 -97
- package/src/components/auth/setup-wizard.tsx +970 -275
- package/src/components/chat/chat-area.tsx +70 -27
- package/src/components/chat/chat-card.tsx +6 -21
- package/src/components/chat/chat-header.tsx +263 -366
- package/src/components/chat/chat-list.tsx +62 -26
- package/src/components/chat/checkpoint-timeline.tsx +1 -1
- package/src/components/chat/message-list.tsx +145 -19
- package/src/components/chatrooms/chatroom-input.tsx +96 -33
- package/src/components/chatrooms/chatroom-list.tsx +141 -72
- package/src/components/chatrooms/chatroom-message.tsx +7 -6
- package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
- package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
- package/src/components/chatrooms/chatroom-view.tsx +422 -209
- package/src/components/chatrooms/reaction-picker.tsx +38 -33
- package/src/components/connectors/connector-list.tsx +265 -127
- package/src/components/connectors/connector-sheet.tsx +217 -0
- package/src/components/gateways/gateway-sheet.tsx +567 -0
- package/src/components/home/home-view.tsx +128 -4
- package/src/components/input/chat-input.tsx +135 -86
- package/src/components/layout/app-layout.tsx +385 -194
- package/src/components/layout/mobile-header.tsx +26 -8
- package/src/components/memory/memory-browser.tsx +71 -6
- package/src/components/memory/memory-card.tsx +18 -0
- package/src/components/memory/memory-detail.tsx +58 -31
- package/src/components/memory/memory-sheet.tsx +32 -4
- package/src/components/plugins/plugin-list.tsx +15 -3
- package/src/components/plugins/plugin-sheet.tsx +118 -9
- package/src/components/projects/project-detail.tsx +189 -1
- package/src/components/providers/provider-list.tsx +158 -2
- package/src/components/providers/provider-sheet.tsx +81 -70
- package/src/components/shared/agent-picker-list.tsx +2 -2
- package/src/components/shared/bottom-sheet.tsx +31 -15
- package/src/components/shared/command-palette.tsx +111 -24
- package/src/components/shared/confirm-dialog.tsx +45 -30
- package/src/components/shared/model-combobox.tsx +90 -8
- package/src/components/shared/settings/plugin-manager.tsx +20 -4
- package/src/components/shared/settings/section-capability-policy.tsx +105 -0
- package/src/components/shared/settings/section-heartbeat.tsx +88 -6
- package/src/components/shared/settings/section-orchestrator.tsx +6 -3
- package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
- package/src/components/shared/settings/section-secrets.tsx +6 -6
- package/src/components/shared/settings/section-user-preferences.tsx +1 -1
- package/src/components/shared/settings/section-voice.tsx +5 -1
- package/src/components/shared/settings/section-web-search.tsx +10 -2
- package/src/components/shared/settings/settings-page.tsx +248 -47
- package/src/components/tasks/approvals-panel.tsx +211 -18
- package/src/components/tasks/task-board.tsx +242 -46
- package/src/components/ui/dialog.tsx +2 -2
- package/src/components/usage/metrics-dashboard.tsx +74 -1
- package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
- package/src/components/wallets/wallet-panel.tsx +17 -5
- package/src/components/webhooks/webhook-sheet.tsx +7 -7
- package/src/lib/auth.ts +17 -0
- package/src/lib/chat-streaming-state.test.ts +108 -0
- package/src/lib/chat-streaming-state.ts +108 -0
- package/src/lib/heartbeat-defaults.ts +48 -0
- package/src/lib/memory-presentation.ts +59 -0
- package/src/lib/openclaw-agent-id.test.ts +14 -0
- package/src/lib/openclaw-agent-id.ts +31 -0
- package/src/lib/provider-model-discovery-client.ts +29 -0
- package/src/lib/providers/index.ts +12 -5
- package/src/lib/runtime-loop.ts +105 -3
- package/src/lib/safe-storage.ts +6 -1
- package/src/lib/server/agent-assignment.test.ts +112 -0
- package/src/lib/server/agent-assignment.ts +169 -0
- package/src/lib/server/agent-runtime-config.test.ts +141 -0
- package/src/lib/server/agent-runtime-config.ts +277 -0
- package/src/lib/server/approval-connector-notify.test.ts +253 -0
- package/src/lib/server/approvals-auto-approve.test.ts +264 -0
- package/src/lib/server/approvals.ts +483 -75
- package/src/lib/server/autonomy-runtime.test.ts +341 -0
- package/src/lib/server/browser-state.test.ts +118 -0
- package/src/lib/server/browser-state.ts +123 -0
- package/src/lib/server/build-llm.test.ts +44 -0
- package/src/lib/server/build-llm.ts +11 -4
- package/src/lib/server/builtin-plugins.ts +34 -0
- package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
- package/src/lib/server/chat-execution-tool-events.test.ts +219 -0
- package/src/lib/server/chat-execution.ts +402 -125
- package/src/lib/server/chatroom-health.test.ts +26 -0
- package/src/lib/server/chatroom-health.ts +2 -3
- package/src/lib/server/chatroom-helpers.test.ts +74 -2
- package/src/lib/server/chatroom-helpers.ts +144 -11
- package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
- package/src/lib/server/connectors/discord.ts +175 -11
- package/src/lib/server/connectors/doctor.test.ts +80 -0
- package/src/lib/server/connectors/doctor.ts +116 -0
- package/src/lib/server/connectors/manager.ts +994 -130
- package/src/lib/server/connectors/policy.test.ts +222 -0
- package/src/lib/server/connectors/policy.ts +452 -0
- package/src/lib/server/connectors/slack.ts +189 -10
- package/src/lib/server/connectors/telegram.ts +65 -15
- package/src/lib/server/connectors/thread-context.test.ts +44 -0
- package/src/lib/server/connectors/thread-context.ts +72 -0
- package/src/lib/server/connectors/types.ts +41 -11
- package/src/lib/server/daemon-state.ts +62 -3
- package/src/lib/server/data-dir.ts +13 -0
- package/src/lib/server/delegation-jobs.test.ts +140 -0
- package/src/lib/server/delegation-jobs.ts +248 -0
- package/src/lib/server/document-utils.test.ts +47 -0
- package/src/lib/server/document-utils.ts +397 -0
- package/src/lib/server/eval/agent-regression.test.ts +47 -0
- package/src/lib/server/eval/agent-regression.ts +1742 -0
- package/src/lib/server/eval/runner.ts +11 -1
- package/src/lib/server/eval/store.ts +2 -1
- package/src/lib/server/heartbeat-service.ts +23 -43
- package/src/lib/server/heartbeat-source.test.ts +22 -0
- package/src/lib/server/heartbeat-source.ts +7 -0
- package/src/lib/server/identity-continuity.test.ts +77 -0
- package/src/lib/server/identity-continuity.ts +127 -0
- package/src/lib/server/mailbox-utils.ts +347 -0
- package/src/lib/server/main-agent-loop.ts +31 -964
- package/src/lib/server/memory-db.ts +4 -6
- package/src/lib/server/memory-tiers.ts +40 -0
- package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
- package/src/lib/server/openclaw-agent-resolver.ts +128 -0
- package/src/lib/server/openclaw-exec-config.ts +6 -5
- package/src/lib/server/openclaw-gateway.ts +123 -36
- package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
- package/src/lib/server/openclaw-skills-normalize.ts +136 -0
- package/src/lib/server/openclaw-sync.ts +3 -2
- package/src/lib/server/orchestrator-lg.ts +18 -8
- package/src/lib/server/orchestrator.ts +5 -4
- package/src/lib/server/playwright-proxy.mjs +27 -3
- package/src/lib/server/plugins.test.ts +215 -0
- package/src/lib/server/plugins.ts +832 -69
- package/src/lib/server/provider-health.ts +33 -3
- package/src/lib/server/provider-model-discovery.ts +481 -0
- package/src/lib/server/queue.ts +4 -21
- package/src/lib/server/runtime-settings.test.ts +119 -0
- package/src/lib/server/runtime-settings.ts +12 -92
- package/src/lib/server/schedule-normalization.ts +187 -0
- package/src/lib/server/scheduler.ts +2 -0
- package/src/lib/server/session-archive-memory.test.ts +85 -0
- package/src/lib/server/session-archive-memory.ts +230 -0
- package/src/lib/server/session-mailbox.ts +8 -18
- package/src/lib/server/session-reset-policy.test.ts +99 -0
- package/src/lib/server/session-reset-policy.ts +311 -0
- package/src/lib/server/session-run-manager.ts +33 -80
- package/src/lib/server/session-tools/autonomy-tools.test.ts +128 -0
- package/src/lib/server/session-tools/calendar.ts +2 -12
- package/src/lib/server/session-tools/connector.ts +109 -8
- package/src/lib/server/session-tools/context.ts +14 -2
- package/src/lib/server/session-tools/crawl.ts +447 -0
- package/src/lib/server/session-tools/crud.ts +96 -34
- package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
- package/src/lib/server/session-tools/delegate.ts +406 -20
- package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
- package/src/lib/server/session-tools/discovery.ts +40 -12
- package/src/lib/server/session-tools/document.ts +283 -0
- package/src/lib/server/session-tools/email.ts +1 -3
- package/src/lib/server/session-tools/extract.ts +137 -0
- package/src/lib/server/session-tools/file-normalize.test.ts +98 -0
- package/src/lib/server/session-tools/file-send.test.ts +84 -1
- package/src/lib/server/session-tools/file.ts +243 -24
- package/src/lib/server/session-tools/http.ts +9 -3
- package/src/lib/server/session-tools/human-loop.ts +227 -0
- package/src/lib/server/session-tools/image-gen.ts +1 -3
- package/src/lib/server/session-tools/index.ts +87 -2
- package/src/lib/server/session-tools/mailbox.ts +276 -0
- package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
- package/src/lib/server/session-tools/memory.ts +35 -3
- package/src/lib/server/session-tools/monitor.ts +162 -12
- package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
- package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
- package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
- package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
- package/src/lib/server/session-tools/platform.ts +142 -4
- package/src/lib/server/session-tools/plugin-creator.ts +95 -25
- package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
- package/src/lib/server/session-tools/replicate.ts +1 -3
- package/src/lib/server/session-tools/sandbox.ts +51 -92
- package/src/lib/server/session-tools/schedule.ts +20 -10
- package/src/lib/server/session-tools/session-info.ts +58 -4
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +54 -17
- package/src/lib/server/session-tools/shell.ts +2 -2
- package/src/lib/server/session-tools/subagent.ts +195 -27
- package/src/lib/server/session-tools/table.ts +587 -0
- package/src/lib/server/session-tools/wallet.ts +13 -10
- package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
- package/src/lib/server/session-tools/web.ts +947 -108
- package/src/lib/server/storage.ts +255 -10
- package/src/lib/server/stream-agent-chat.test.ts +61 -0
- package/src/lib/server/stream-agent-chat.ts +185 -25
- package/src/lib/server/structured-extract.test.ts +72 -0
- package/src/lib/server/structured-extract.ts +373 -0
- package/src/lib/server/task-mention.test.ts +16 -2
- package/src/lib/server/task-mention.ts +61 -11
- package/src/lib/server/tool-aliases.ts +80 -12
- package/src/lib/server/tool-capability-policy.ts +7 -1
- package/src/lib/server/tool-retry.ts +2 -0
- package/src/lib/server/watch-jobs.test.ts +173 -0
- package/src/lib/server/watch-jobs.ts +532 -0
- package/src/lib/server/ws-hub.ts +5 -3
- package/src/lib/setup-defaults.ts +352 -11
- package/src/lib/tool-definitions.ts +3 -4
- package/src/lib/validation/schemas.test.ts +26 -0
- package/src/lib/validation/schemas.ts +62 -1
- package/src/lib/ws-client.ts +14 -12
- package/src/proxy.ts +5 -5
- package/src/stores/use-app-store.ts +43 -7
- package/src/stores/use-chat-store.ts +31 -2
- package/src/stores/use-chatroom-store.ts +153 -26
- package/src/types/index.ts +470 -44
- package/src/app/api/chats/[id]/main-loop/route.ts +0 -94
- package/src/components/chat/new-chat-sheet.tsx +0 -253
- package/src/lib/server/main-session.ts +0 -17
- package/src/lib/server/session-run-manager.test.ts +0 -26
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { describe, it } from 'node:test'
|
|
7
|
+
|
|
8
|
+
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
|
|
9
|
+
|
|
10
|
+
function runWithTempDataDir(script: string) {
|
|
11
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-autonomy-test-'))
|
|
12
|
+
try {
|
|
13
|
+
const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
|
|
14
|
+
cwd: repoRoot,
|
|
15
|
+
env: {
|
|
16
|
+
...process.env,
|
|
17
|
+
DATA_DIR: tempDir,
|
|
18
|
+
WORKSPACE_DIR: path.join(tempDir, 'workspace'),
|
|
19
|
+
},
|
|
20
|
+
encoding: 'utf-8',
|
|
21
|
+
})
|
|
22
|
+
assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
|
|
23
|
+
const lines = (result.stdout || '')
|
|
24
|
+
.trim()
|
|
25
|
+
.split('\n')
|
|
26
|
+
.map((line) => line.trim())
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
|
|
29
|
+
return JSON.parse(jsonLine || '{}')
|
|
30
|
+
} finally {
|
|
31
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('browser session persistence', () => {
|
|
36
|
+
it('isolates browser profiles by default and stores observations', () => {
|
|
37
|
+
const output = runWithTempDataDir(`
|
|
38
|
+
const storage = (await import('./src/lib/server/storage.ts')).default
|
|
39
|
+
const browserState = (await import('./src/lib/server/browser-state.ts')).default
|
|
40
|
+
|
|
41
|
+
const now = Date.now()
|
|
42
|
+
storage.saveSessions({
|
|
43
|
+
parent: {
|
|
44
|
+
id: 'parent',
|
|
45
|
+
name: 'parent',
|
|
46
|
+
cwd: process.cwd(),
|
|
47
|
+
user: 'tester',
|
|
48
|
+
provider: 'openai',
|
|
49
|
+
model: 'gpt-test',
|
|
50
|
+
claudeSessionId: null,
|
|
51
|
+
messages: [],
|
|
52
|
+
createdAt: now,
|
|
53
|
+
lastActiveAt: now,
|
|
54
|
+
browserProfileId: 'shared-profile',
|
|
55
|
+
},
|
|
56
|
+
child: {
|
|
57
|
+
id: 'child',
|
|
58
|
+
name: 'child',
|
|
59
|
+
cwd: process.cwd(),
|
|
60
|
+
user: 'tester',
|
|
61
|
+
provider: 'openai',
|
|
62
|
+
model: 'gpt-test',
|
|
63
|
+
claudeSessionId: null,
|
|
64
|
+
messages: [],
|
|
65
|
+
createdAt: now,
|
|
66
|
+
lastActiveAt: now,
|
|
67
|
+
parentSessionId: 'parent',
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const resolved = browserState.ensureSessionBrowserProfileId('child')
|
|
72
|
+
browserState.upsertBrowserSessionRecord({ sessionId: 'child', status: 'active', lastAction: 'navigate' })
|
|
73
|
+
browserState.recordBrowserObservation('child', {
|
|
74
|
+
capturedAt: now,
|
|
75
|
+
url: 'https://example.com',
|
|
76
|
+
title: 'Example',
|
|
77
|
+
textPreview: 'hello world',
|
|
78
|
+
links: [],
|
|
79
|
+
forms: [],
|
|
80
|
+
tables: [],
|
|
81
|
+
})
|
|
82
|
+
browserState.markBrowserSessionClosed('child', 'finished')
|
|
83
|
+
|
|
84
|
+
console.log(JSON.stringify({
|
|
85
|
+
resolved,
|
|
86
|
+
session: storage.loadSessions().child,
|
|
87
|
+
state: browserState.loadBrowserSessionRecord('child'),
|
|
88
|
+
}))
|
|
89
|
+
`)
|
|
90
|
+
|
|
91
|
+
assert.equal(output.resolved.profileId, 'child')
|
|
92
|
+
assert.equal(output.resolved.inheritedFromSessionId, null)
|
|
93
|
+
assert.equal(output.session.browserProfileId, 'child')
|
|
94
|
+
assert.equal(output.state.currentUrl, 'https://example.com')
|
|
95
|
+
assert.equal(output.state.pageTitle, 'Example')
|
|
96
|
+
assert.equal(output.state.status, 'error')
|
|
97
|
+
assert.equal(output.state.lastError, 'finished')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('isolates subagent browser profiles by default unless sharing is explicitly requested', () => {
|
|
101
|
+
const output = runWithTempDataDir(`
|
|
102
|
+
const mod = await import('./src/lib/server/session-tools/subagent.ts')
|
|
103
|
+
const { resolveSubagentBrowserProfileId } = mod.default || mod['module.exports'] || mod
|
|
104
|
+
|
|
105
|
+
const parent = {
|
|
106
|
+
id: 'parent-session',
|
|
107
|
+
browserProfileId: 'shared-profile',
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.log(JSON.stringify({
|
|
111
|
+
isolated: resolveSubagentBrowserProfileId(parent, 'child-session', false),
|
|
112
|
+
shared: resolveSubagentBrowserProfileId(parent, 'child-session', true),
|
|
113
|
+
}))
|
|
114
|
+
`)
|
|
115
|
+
|
|
116
|
+
assert.equal(output.isolated, 'child-session')
|
|
117
|
+
assert.equal(output.shared, 'shared-profile')
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('durable watch jobs', () => {
|
|
122
|
+
it('triggers time, file, task, http, and webhook watches', () => {
|
|
123
|
+
const output = runWithTempDataDir(`
|
|
124
|
+
import fs from 'node:fs'
|
|
125
|
+
import path from 'node:path'
|
|
126
|
+
const storage = (await import('./src/lib/server/storage.ts')).default
|
|
127
|
+
const watchJobs = (await import('./src/lib/server/watch-jobs.ts')).default
|
|
128
|
+
|
|
129
|
+
const watchFile = path.join(process.env.DATA_DIR, 'watch.txt')
|
|
130
|
+
fs.writeFileSync(watchFile, 'build succeeded')
|
|
131
|
+
|
|
132
|
+
storage.saveTasks({
|
|
133
|
+
task_done: {
|
|
134
|
+
id: 'task_done',
|
|
135
|
+
title: 'Done',
|
|
136
|
+
status: 'completed',
|
|
137
|
+
result: 'ok',
|
|
138
|
+
createdAt: Date.now(),
|
|
139
|
+
updatedAt: Date.now(),
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
globalThis.fetch = async () => new Response('service healthy', { status: 200 })
|
|
144
|
+
|
|
145
|
+
const timeJob = await watchJobs.createWatchJob({
|
|
146
|
+
type: 'time',
|
|
147
|
+
resumeMessage: 'time wake',
|
|
148
|
+
target: {},
|
|
149
|
+
condition: {},
|
|
150
|
+
runAt: Date.now() - 1000,
|
|
151
|
+
})
|
|
152
|
+
const fileJob = await watchJobs.createWatchJob({
|
|
153
|
+
type: 'file',
|
|
154
|
+
resumeMessage: 'file wake',
|
|
155
|
+
target: { path: watchFile },
|
|
156
|
+
condition: { containsText: 'succeeded' },
|
|
157
|
+
})
|
|
158
|
+
const taskJob = await watchJobs.createWatchJob({
|
|
159
|
+
type: 'task',
|
|
160
|
+
resumeMessage: 'task wake',
|
|
161
|
+
target: { taskId: 'task_done' },
|
|
162
|
+
condition: { statusIn: ['completed'] },
|
|
163
|
+
})
|
|
164
|
+
const httpJob = await watchJobs.createWatchJob({
|
|
165
|
+
type: 'http',
|
|
166
|
+
resumeMessage: 'http wake',
|
|
167
|
+
target: { url: 'https://example.com/health' },
|
|
168
|
+
condition: { regex: 'healthy', threshold: 0 },
|
|
169
|
+
})
|
|
170
|
+
const webhookJob = await watchJobs.createWatchJob({
|
|
171
|
+
type: 'webhook',
|
|
172
|
+
resumeMessage: 'webhook wake',
|
|
173
|
+
target: { webhookId: 'wh_test' },
|
|
174
|
+
condition: { event: 'deploy.finished' },
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const summary = await watchJobs.processDueWatchJobs(Date.now())
|
|
178
|
+
const webhookTriggered = watchJobs.triggerWebhookWatchJobs({
|
|
179
|
+
webhookId: 'wh_test',
|
|
180
|
+
event: 'deploy.finished',
|
|
181
|
+
payloadPreview: '{"ok":true}',
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
console.log(JSON.stringify({
|
|
185
|
+
summary,
|
|
186
|
+
time: watchJobs.getWatchJob(timeJob.id),
|
|
187
|
+
file: watchJobs.getWatchJob(fileJob.id),
|
|
188
|
+
task: watchJobs.getWatchJob(taskJob.id),
|
|
189
|
+
http: watchJobs.getWatchJob(httpJob.id),
|
|
190
|
+
webhook: watchJobs.getWatchJob(webhookJob.id),
|
|
191
|
+
webhookTriggeredCount: webhookTriggered.length,
|
|
192
|
+
}))
|
|
193
|
+
`)
|
|
194
|
+
|
|
195
|
+
assert.equal(output.summary.triggered >= 4, true)
|
|
196
|
+
assert.equal(output.time.status, 'triggered')
|
|
197
|
+
assert.equal(output.file.status, 'triggered')
|
|
198
|
+
assert.equal(output.task.status, 'triggered')
|
|
199
|
+
assert.equal(output.http.status, 'triggered')
|
|
200
|
+
assert.equal(output.http.result.regex, 'healthy')
|
|
201
|
+
assert.equal(output.webhook.status, 'triggered')
|
|
202
|
+
assert.equal(output.webhookTriggeredCount, 1)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('triggers mailbox and approval waits from human-loop events', () => {
|
|
206
|
+
const output = runWithTempDataDir(`
|
|
207
|
+
const storage = (await import('./src/lib/server/storage.ts')).default
|
|
208
|
+
const watchJobs = (await import('./src/lib/server/watch-jobs.ts')).default
|
|
209
|
+
const mailboxMod = await import('./src/lib/server/session-mailbox.ts')
|
|
210
|
+
const approvalsMod = await import('./src/lib/server/approvals.ts')
|
|
211
|
+
const mailbox = mailboxMod.default || mailboxMod
|
|
212
|
+
const approvals = approvalsMod.default || approvalsMod
|
|
213
|
+
|
|
214
|
+
const now = Date.now()
|
|
215
|
+
storage.saveSessions({
|
|
216
|
+
human: {
|
|
217
|
+
id: 'human',
|
|
218
|
+
name: 'Human Session',
|
|
219
|
+
cwd: process.cwd(),
|
|
220
|
+
user: 'tester',
|
|
221
|
+
provider: 'openai',
|
|
222
|
+
model: 'gpt-test',
|
|
223
|
+
claudeSessionId: null,
|
|
224
|
+
messages: [],
|
|
225
|
+
mailbox: [],
|
|
226
|
+
createdAt: now,
|
|
227
|
+
lastActiveAt: now,
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
const mailboxJob = await watchJobs.createWatchJob({
|
|
232
|
+
type: 'mailbox',
|
|
233
|
+
resumeMessage: 'mailbox wake',
|
|
234
|
+
target: { sessionId: 'human' },
|
|
235
|
+
condition: { type: 'human_reply', correlationId: 'corr-1' },
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
const approval = approvals.requestApproval({
|
|
239
|
+
category: 'human_loop',
|
|
240
|
+
sessionId: 'human',
|
|
241
|
+
title: 'Approve deployment',
|
|
242
|
+
data: { env: 'prod' },
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
const approvalJob = await watchJobs.createWatchJob({
|
|
246
|
+
type: 'approval',
|
|
247
|
+
resumeMessage: 'approval wake',
|
|
248
|
+
target: { approvalId: approval.id },
|
|
249
|
+
condition: { statusIn: ['approved'] },
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
const envelope = mailbox.sendMailboxEnvelope({
|
|
253
|
+
toSessionId: 'human',
|
|
254
|
+
type: 'human_reply',
|
|
255
|
+
payload: 'ship it',
|
|
256
|
+
correlationId: 'corr-1',
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
await approvals.submitDecision(approval.id, true)
|
|
260
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
261
|
+
|
|
262
|
+
console.log(JSON.stringify({
|
|
263
|
+
envelope,
|
|
264
|
+
mailboxJob: watchJobs.getWatchJob(mailboxJob.id),
|
|
265
|
+
approvalJob: watchJobs.getWatchJob(approvalJob.id),
|
|
266
|
+
approval: storage.loadApprovals()[approval.id],
|
|
267
|
+
mailboxState: storage.loadSessions().human.mailbox,
|
|
268
|
+
}))
|
|
269
|
+
`)
|
|
270
|
+
|
|
271
|
+
assert.equal(output.envelope.type, 'human_reply')
|
|
272
|
+
assert.equal(output.mailboxJob.status, 'triggered')
|
|
273
|
+
assert.equal(output.mailboxJob.result.type, 'human_reply')
|
|
274
|
+
assert.equal(output.approvalJob.status, 'triggered')
|
|
275
|
+
assert.equal(output.approvalJob.result.status, 'approved')
|
|
276
|
+
assert.equal(output.approval.status, 'approved')
|
|
277
|
+
assert.equal(Array.isArray(output.mailboxState), true)
|
|
278
|
+
assert.equal(output.mailboxState.length, 1)
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
describe('delegation jobs', () => {
|
|
283
|
+
it('preserves cancellation and recovers stale jobs', () => {
|
|
284
|
+
const output = runWithTempDataDir(`
|
|
285
|
+
const delegationJobs = (await import('./src/lib/server/delegation-jobs.ts')).default
|
|
286
|
+
const storage = (await import('./src/lib/server/storage.ts')).default
|
|
287
|
+
|
|
288
|
+
let cancelledCalls = 0
|
|
289
|
+
const cancelledJob = delegationJobs.createDelegationJob({
|
|
290
|
+
kind: 'delegate',
|
|
291
|
+
backend: 'codex',
|
|
292
|
+
parentSessionId: 'session-1',
|
|
293
|
+
task: 'cancel me',
|
|
294
|
+
})
|
|
295
|
+
delegationJobs.startDelegationJob(cancelledJob.id, { backend: 'codex' })
|
|
296
|
+
delegationJobs.registerDelegationRuntime(cancelledJob.id, {
|
|
297
|
+
cancel: () => { cancelledCalls += 1 },
|
|
298
|
+
})
|
|
299
|
+
delegationJobs.cancelDelegationJob(cancelledJob.id)
|
|
300
|
+
delegationJobs.completeDelegationJob(cancelledJob.id, 'should not override')
|
|
301
|
+
delegationJobs.failDelegationJob(cancelledJob.id, 'should also not override')
|
|
302
|
+
|
|
303
|
+
const completedJob = delegationJobs.createDelegationJob({
|
|
304
|
+
kind: 'subagent',
|
|
305
|
+
parentSessionId: 'session-1',
|
|
306
|
+
task: 'complete me',
|
|
307
|
+
})
|
|
308
|
+
delegationJobs.startDelegationJob(completedJob.id, { childSessionId: 'session-2' })
|
|
309
|
+
delegationJobs.completeDelegationJob(completedJob.id, 'done')
|
|
310
|
+
|
|
311
|
+
const staleJob = delegationJobs.createDelegationJob({
|
|
312
|
+
kind: 'delegate',
|
|
313
|
+
parentSessionId: 'session-1',
|
|
314
|
+
task: 'stale work',
|
|
315
|
+
})
|
|
316
|
+
delegationJobs.startDelegationJob(staleJob.id)
|
|
317
|
+
const staleRecord = delegationJobs.getDelegationJob(staleJob.id)
|
|
318
|
+
storage.upsertDelegationJob(staleJob.id, {
|
|
319
|
+
...staleRecord,
|
|
320
|
+
updatedAt: Date.now() - 20 * 60_000,
|
|
321
|
+
})
|
|
322
|
+
const recovered = delegationJobs.recoverStaleDelegationJobs(15 * 60_000)
|
|
323
|
+
|
|
324
|
+
console.log(JSON.stringify({
|
|
325
|
+
cancelledCalls,
|
|
326
|
+
cancelled: delegationJobs.getDelegationJob(cancelledJob.id),
|
|
327
|
+
completed: delegationJobs.getDelegationJob(completedJob.id),
|
|
328
|
+
stale: delegationJobs.getDelegationJob(staleJob.id),
|
|
329
|
+
recovered,
|
|
330
|
+
}))
|
|
331
|
+
`)
|
|
332
|
+
|
|
333
|
+
assert.equal(output.cancelledCalls, 1)
|
|
334
|
+
assert.equal(output.cancelled.status, 'cancelled')
|
|
335
|
+
assert.equal(output.cancelled.result, null)
|
|
336
|
+
assert.equal(output.completed.status, 'completed')
|
|
337
|
+
assert.equal(output.completed.resultPreview, 'done')
|
|
338
|
+
assert.equal(output.stale.status, 'failed')
|
|
339
|
+
assert.equal(output.recovered, 1)
|
|
340
|
+
})
|
|
341
|
+
})
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { after, before, describe, it } from 'node:test'
|
|
6
|
+
|
|
7
|
+
const originalEnv = {
|
|
8
|
+
BROWSER_PROFILES_DIR: process.env.BROWSER_PROFILES_DIR,
|
|
9
|
+
DATA_DIR: process.env.DATA_DIR,
|
|
10
|
+
WORKSPACE_DIR: process.env.WORKSPACE_DIR,
|
|
11
|
+
SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let tempDir = ''
|
|
15
|
+
let browserState: typeof import('./browser-state')
|
|
16
|
+
let storage: typeof import('./storage')
|
|
17
|
+
|
|
18
|
+
function baseSession(id: string, extra: Record<string, unknown> = {}) {
|
|
19
|
+
const now = Date.now()
|
|
20
|
+
return {
|
|
21
|
+
id,
|
|
22
|
+
name: id,
|
|
23
|
+
cwd: process.cwd(),
|
|
24
|
+
user: 'tester',
|
|
25
|
+
provider: 'openai',
|
|
26
|
+
model: 'gpt-test',
|
|
27
|
+
claudeSessionId: null,
|
|
28
|
+
messages: [],
|
|
29
|
+
createdAt: now,
|
|
30
|
+
lastActiveAt: now,
|
|
31
|
+
...extra,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
before(async () => {
|
|
36
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-browser-state-'))
|
|
37
|
+
process.env.DATA_DIR = path.join(tempDir, 'data')
|
|
38
|
+
process.env.BROWSER_PROFILES_DIR = path.join(tempDir, 'browser-profiles')
|
|
39
|
+
process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace')
|
|
40
|
+
process.env.SWARMCLAW_BUILD_MODE = '1'
|
|
41
|
+
browserState = await import('./browser-state')
|
|
42
|
+
storage = await import('./storage')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
after(() => {
|
|
46
|
+
if (originalEnv.BROWSER_PROFILES_DIR === undefined) delete process.env.BROWSER_PROFILES_DIR
|
|
47
|
+
else process.env.BROWSER_PROFILES_DIR = originalEnv.BROWSER_PROFILES_DIR
|
|
48
|
+
if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
|
|
49
|
+
else process.env.DATA_DIR = originalEnv.DATA_DIR
|
|
50
|
+
if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
|
|
51
|
+
else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
|
|
52
|
+
if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
|
|
53
|
+
else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
|
|
54
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('browser-state', () => {
|
|
58
|
+
it('defaults child sessions to their own browser profile id unless sharing is explicit', () => {
|
|
59
|
+
storage.saveSessions({
|
|
60
|
+
parent: baseSession('parent', { browserProfileId: 'shared-browser' }),
|
|
61
|
+
child: baseSession('child', { parentSessionId: 'parent' }),
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const resolved = browserState.ensureSessionBrowserProfileId('child')
|
|
65
|
+
const sessions = storage.loadSessions()
|
|
66
|
+
|
|
67
|
+
assert.equal(resolved.profileId, 'child')
|
|
68
|
+
assert.equal(resolved.inheritedFromSessionId, null)
|
|
69
|
+
assert.equal(sessions.child.browserProfileId, 'child')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('persists browser observations and close state', () => {
|
|
73
|
+
storage.saveSessions({
|
|
74
|
+
solo: baseSession('solo'),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const profile = browserState.ensureSessionBrowserProfileId('solo')
|
|
78
|
+
const profileDir = browserState.getBrowserProfileDir(profile.profileId)
|
|
79
|
+
assert.equal(fs.existsSync(profileDir), true)
|
|
80
|
+
|
|
81
|
+
browserState.upsertBrowserSessionRecord({
|
|
82
|
+
sessionId: 'solo',
|
|
83
|
+
profileId: profile.profileId,
|
|
84
|
+
profileDir,
|
|
85
|
+
status: 'active',
|
|
86
|
+
lastAction: 'browser_open',
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
browserState.recordBrowserObservation('solo', {
|
|
90
|
+
capturedAt: Date.now(),
|
|
91
|
+
url: 'https://example.com',
|
|
92
|
+
title: 'Example',
|
|
93
|
+
textPreview: 'Example domain',
|
|
94
|
+
links: [{ text: 'More information', href: 'https://iana.org/domains/example' }],
|
|
95
|
+
forms: [],
|
|
96
|
+
tables: [],
|
|
97
|
+
errors: [],
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const observed = browserState.loadBrowserSessionRecord('solo')
|
|
101
|
+
assert.equal(observed?.currentUrl, 'https://example.com')
|
|
102
|
+
assert.equal(observed?.pageTitle, 'Example')
|
|
103
|
+
assert.equal(observed?.lastObservation?.links?.length, 1)
|
|
104
|
+
|
|
105
|
+
const closed = browserState.markBrowserSessionClosed('solo', 'browser crashed')
|
|
106
|
+
assert.equal(closed?.status, 'error')
|
|
107
|
+
assert.equal(closed?.lastError, 'browser crashed')
|
|
108
|
+
|
|
109
|
+
browserState.removeBrowserSessionRecord('solo')
|
|
110
|
+
assert.equal(browserState.loadBrowserSessionRecord('solo'), null)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('creates profile directories under the configured data dir', () => {
|
|
114
|
+
const dir = browserState.getBrowserProfileDir('profile with spaces')
|
|
115
|
+
assert.equal(fs.existsSync(dir), true)
|
|
116
|
+
assert.equal(dir.startsWith(process.env.BROWSER_PROFILES_DIR!), true)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import type { BrowserObservation, BrowserSessionRecord, Session } from '@/types'
|
|
4
|
+
import { BROWSER_PROFILES_DIR } from './data-dir'
|
|
5
|
+
import {
|
|
6
|
+
deleteBrowserSession,
|
|
7
|
+
loadBrowserSessions,
|
|
8
|
+
loadSessions,
|
|
9
|
+
saveSessions,
|
|
10
|
+
upsertBrowserSession,
|
|
11
|
+
} from './storage'
|
|
12
|
+
|
|
13
|
+
function sanitizeToken(value: string): string {
|
|
14
|
+
const trimmed = value.trim()
|
|
15
|
+
const safe = trimmed.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
|
16
|
+
return safe || 'default'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function normalizeBrowserProfileId(value: unknown): string {
|
|
20
|
+
return typeof value === 'string' && value.trim() ? sanitizeToken(value) : ''
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getBrowserProfileDir(profileId: string): string {
|
|
24
|
+
if (!fs.existsSync(BROWSER_PROFILES_DIR)) fs.mkdirSync(BROWSER_PROFILES_DIR, { recursive: true })
|
|
25
|
+
const dir = path.join(BROWSER_PROFILES_DIR, sanitizeToken(profileId))
|
|
26
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
27
|
+
return dir
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveBrowserProfileInfo(session: Session | Record<string, unknown> | null | undefined): {
|
|
31
|
+
profileId: string
|
|
32
|
+
inheritedFromSessionId: string | null
|
|
33
|
+
} {
|
|
34
|
+
const current = session && typeof session === 'object' ? session as Record<string, unknown> : {}
|
|
35
|
+
const direct = normalizeBrowserProfileId(current.browserProfileId)
|
|
36
|
+
if (direct) return { profileId: direct, inheritedFromSessionId: null }
|
|
37
|
+
|
|
38
|
+
const sessionId = typeof current.id === 'string' && current.id.trim() ? current.id.trim() : 'default'
|
|
39
|
+
return { profileId: sanitizeToken(sessionId), inheritedFromSessionId: null }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function ensureSessionBrowserProfileId(sessionId: string): {
|
|
43
|
+
profileId: string
|
|
44
|
+
inheritedFromSessionId: string | null
|
|
45
|
+
} {
|
|
46
|
+
const sessions = loadSessions()
|
|
47
|
+
const session = sessions[sessionId]
|
|
48
|
+
if (!session) return { profileId: sanitizeToken(sessionId), inheritedFromSessionId: null }
|
|
49
|
+
const resolved = resolveBrowserProfileInfo(session)
|
|
50
|
+
if (session.browserProfileId !== resolved.profileId) {
|
|
51
|
+
session.browserProfileId = resolved.profileId
|
|
52
|
+
session.updatedAt = Date.now()
|
|
53
|
+
sessions[sessionId] = session
|
|
54
|
+
saveSessions(sessions)
|
|
55
|
+
}
|
|
56
|
+
return resolved
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function loadBrowserSessionRecord(sessionId: string): BrowserSessionRecord | null {
|
|
60
|
+
const all = loadBrowserSessions()
|
|
61
|
+
const raw = all[sessionId]
|
|
62
|
+
if (!raw || typeof raw !== 'object') return null
|
|
63
|
+
return raw as BrowserSessionRecord
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function mergeArtifacts(current: BrowserSessionRecord['artifacts'], next: BrowserSessionRecord['artifacts']): BrowserSessionRecord['artifacts'] {
|
|
67
|
+
const merged = [...(current || []), ...(next || [])]
|
|
68
|
+
return merged.slice(-24)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function upsertBrowserSessionRecord(input: Partial<BrowserSessionRecord> & { sessionId: string }): BrowserSessionRecord {
|
|
72
|
+
const now = Date.now()
|
|
73
|
+
const current = loadBrowserSessionRecord(input.sessionId)
|
|
74
|
+
const baseProfile = input.profileId
|
|
75
|
+
|| current?.profileId
|
|
76
|
+
|| ensureSessionBrowserProfileId(input.sessionId).profileId
|
|
77
|
+
const next: BrowserSessionRecord = {
|
|
78
|
+
id: input.sessionId,
|
|
79
|
+
sessionId: input.sessionId,
|
|
80
|
+
profileId: baseProfile,
|
|
81
|
+
profileDir: input.profileDir || current?.profileDir || getBrowserProfileDir(baseProfile),
|
|
82
|
+
status: input.status || current?.status || 'idle',
|
|
83
|
+
inheritedFromSessionId: input.inheritedFromSessionId ?? current?.inheritedFromSessionId ?? null,
|
|
84
|
+
currentUrl: input.currentUrl ?? current?.currentUrl ?? null,
|
|
85
|
+
pageTitle: input.pageTitle ?? current?.pageTitle ?? null,
|
|
86
|
+
activeTabIndex: input.activeTabIndex ?? current?.activeTabIndex ?? null,
|
|
87
|
+
tabs: input.tabs ?? current?.tabs ?? [],
|
|
88
|
+
lastAction: input.lastAction ?? current?.lastAction ?? null,
|
|
89
|
+
lastError: input.lastError ?? current?.lastError ?? null,
|
|
90
|
+
lastObservation: input.lastObservation ?? current?.lastObservation ?? null,
|
|
91
|
+
artifacts: mergeArtifacts(current?.artifacts, input.artifacts),
|
|
92
|
+
createdAt: current?.createdAt || input.createdAt || now,
|
|
93
|
+
updatedAt: now,
|
|
94
|
+
lastUsedAt: input.lastUsedAt || now,
|
|
95
|
+
}
|
|
96
|
+
upsertBrowserSession(next.id, next)
|
|
97
|
+
return next
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function recordBrowserObservation(sessionId: string, observation: BrowserObservation): BrowserSessionRecord {
|
|
101
|
+
return upsertBrowserSessionRecord({
|
|
102
|
+
sessionId,
|
|
103
|
+
currentUrl: observation.url ?? null,
|
|
104
|
+
pageTitle: observation.title ?? null,
|
|
105
|
+
activeTabIndex: observation.activeTabIndex ?? null,
|
|
106
|
+
tabs: observation.tabs ?? [],
|
|
107
|
+
lastObservation: observation,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function markBrowserSessionClosed(sessionId: string, error?: string | null): BrowserSessionRecord | null {
|
|
112
|
+
const current = loadBrowserSessionRecord(sessionId)
|
|
113
|
+
if (!current) return null
|
|
114
|
+
return upsertBrowserSessionRecord({
|
|
115
|
+
sessionId,
|
|
116
|
+
status: error ? 'error' : 'closed',
|
|
117
|
+
lastError: error ?? null,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function removeBrowserSessionRecord(sessionId: string): void {
|
|
122
|
+
deleteBrowserSession(sessionId)
|
|
123
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { ChatOpenAI } from '@langchain/openai'
|
|
4
|
+
import {
|
|
5
|
+
buildChatModel,
|
|
6
|
+
OPENAI_COMPAT_MODEL_MAX_RETRIES,
|
|
7
|
+
OPENAI_COMPAT_MODEL_TIMEOUT_MS,
|
|
8
|
+
} from './build-llm'
|
|
9
|
+
|
|
10
|
+
type ChatOpenAiInternals = ChatOpenAI & {
|
|
11
|
+
timeout?: number
|
|
12
|
+
caller?: { maxRetries?: number }
|
|
13
|
+
clientConfig?: { defaultHeaders?: Record<string, string> }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('buildChatModel', () => {
|
|
17
|
+
it('applies bounded timeout and disables internal retries for openai-compatible models', () => {
|
|
18
|
+
const llm = buildChatModel({
|
|
19
|
+
provider: 'openai',
|
|
20
|
+
model: 'gpt-4o',
|
|
21
|
+
apiKey: 'test-key',
|
|
22
|
+
})
|
|
23
|
+
const model = llm as ChatOpenAiInternals
|
|
24
|
+
|
|
25
|
+
assert.equal(llm instanceof ChatOpenAI, true)
|
|
26
|
+
assert.equal(model.timeout, OPENAI_COMPAT_MODEL_TIMEOUT_MS)
|
|
27
|
+
assert.equal(model.caller?.maxRetries, OPENAI_COMPAT_MODEL_MAX_RETRIES)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('preserves openclaw headers while applying the same timeout policy', () => {
|
|
31
|
+
const llm = buildChatModel({
|
|
32
|
+
provider: 'openclaw',
|
|
33
|
+
model: 'gpt-4o',
|
|
34
|
+
apiKey: 'test-key',
|
|
35
|
+
apiEndpoint: 'https://example.com/v1',
|
|
36
|
+
})
|
|
37
|
+
const model = llm as ChatOpenAiInternals
|
|
38
|
+
|
|
39
|
+
assert.equal(llm instanceof ChatOpenAI, true)
|
|
40
|
+
assert.equal(model.timeout, OPENAI_COMPAT_MODEL_TIMEOUT_MS)
|
|
41
|
+
assert.equal(model.caller?.maxRetries, OPENAI_COMPAT_MODEL_MAX_RETRIES)
|
|
42
|
+
assert.deepEqual(model.clientConfig?.defaultHeaders, { 'Content-Type': 'text/plain' })
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -7,6 +7,8 @@ import { NON_LANGGRAPH_PROVIDER_IDS } from '../provider-sets'
|
|
|
7
7
|
|
|
8
8
|
const OLLAMA_CLOUD_URL = 'https://ollama.com/v1'
|
|
9
9
|
const OLLAMA_LOCAL_URL = 'http://localhost:11434/v1'
|
|
10
|
+
export const OPENAI_COMPAT_MODEL_TIMEOUT_MS = 180_000
|
|
11
|
+
export const OPENAI_COMPAT_MODEL_MAX_RETRIES = 0
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Build a LangChain chat model from provider config.
|
|
@@ -50,12 +52,19 @@ export function buildChatModel(opts: {
|
|
|
50
52
|
return new ChatOpenAI({
|
|
51
53
|
model: model || 'qwen3.5',
|
|
52
54
|
apiKey: apiKey || 'ollama',
|
|
55
|
+
timeout: OPENAI_COMPAT_MODEL_TIMEOUT_MS,
|
|
56
|
+
maxRetries: OPENAI_COMPAT_MODEL_MAX_RETRIES,
|
|
53
57
|
configuration: { baseURL },
|
|
54
58
|
})
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
// All other providers — OpenAI-compatible with their registered endpoint
|
|
58
|
-
const config: any = {
|
|
62
|
+
const config: any = {
|
|
63
|
+
model: model || 'gpt-4o',
|
|
64
|
+
apiKey: apiKey || undefined,
|
|
65
|
+
timeout: OPENAI_COMPAT_MODEL_TIMEOUT_MS,
|
|
66
|
+
maxRetries: OPENAI_COMPAT_MODEL_MAX_RETRIES,
|
|
67
|
+
}
|
|
59
68
|
// Map thinking level to reasoning_effort for OpenAI o-series models
|
|
60
69
|
if (thinkingLevel && provider === 'openai' && /^o\d/.test(model || '')) {
|
|
61
70
|
const effortMap = { minimal: 'low', low: 'low', medium: 'medium', high: 'high' }
|
|
@@ -70,9 +79,7 @@ export function buildChatModel(opts: {
|
|
|
70
79
|
config.configuration.defaultHeaders = { 'Content-Type': 'text/plain' }
|
|
71
80
|
}
|
|
72
81
|
}
|
|
73
|
-
return config
|
|
74
|
-
? new ChatOpenAI(config)
|
|
75
|
-
: new ChatOpenAI({ model: config.model, apiKey: config.apiKey })
|
|
82
|
+
return new ChatOpenAI(config)
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
function resolveApiKeyFromCredential(credentialId: string | null | undefined): string | null {
|