@swarmclawai/swarmclaw 1.9.37 → 1.9.39

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 (32) hide show
  1. package/README.md +43 -1
  2. package/package.json +2 -2
  3. package/src/app/api/chats/[id]/context-status/route.ts +2 -0
  4. package/src/app/api/chats/context-status-route.test.ts +59 -0
  5. package/src/app/api/setup/check-provider/route.test.ts +12 -0
  6. package/src/app/api/setup/check-provider/route.ts +6 -0
  7. package/src/lib/providers/index.ts +23 -0
  8. package/src/lib/server/autonomy/supervisor-reflection.test.ts +10 -1
  9. package/src/lib/server/connectors/outbox.ts +22 -2
  10. package/src/lib/server/context-manager.ts +4 -0
  11. package/src/lib/server/openrouter-model-context.test.ts +205 -0
  12. package/src/lib/server/openrouter-model-context.ts +169 -0
  13. package/src/lib/server/provider-health.ts +1 -0
  14. package/src/lib/server/runtime/queue/core.ts +160 -18
  15. package/src/lib/server/runtime/queue/orphan-recovery.test.ts +49 -0
  16. package/src/lib/server/runtime/queue/orphan-recovery.ts +32 -0
  17. package/src/lib/server/runtime/scheduled-run-preflight.test.ts +73 -0
  18. package/src/lib/server/runtime/scheduled-run-preflight.ts +83 -0
  19. package/src/lib/server/schedules/schedule-lifecycle.test.ts +44 -0
  20. package/src/lib/server/schedules/schedule-lifecycle.ts +27 -0
  21. package/src/lib/server/storage-normalization.ts +13 -0
  22. package/src/lib/server/tasks/task-followups.test.ts +124 -41
  23. package/src/lib/server/tasks/task-followups.ts +28 -3
  24. package/src/lib/server/tasks/task-lifecycle.test.ts +25 -0
  25. package/src/lib/server/tasks/task-lifecycle.ts +6 -0
  26. package/src/lib/server/tasks/task-result.test.ts +25 -1
  27. package/src/lib/server/tasks/task-result.ts +22 -0
  28. package/src/lib/server/workspace-paths.test.ts +72 -0
  29. package/src/lib/server/workspace-paths.ts +60 -0
  30. package/src/lib/setup-defaults.test.ts +10 -1
  31. package/src/lib/setup-defaults.ts +20 -0
  32. package/src/types/provider.ts +1 -1
package/README.md CHANGED
@@ -84,7 +84,7 @@ Available for macOS (Apple Silicon & Intel), Windows, and Linux (AppImage + .deb
84
84
  The release workflow supports Developer ID signing and notarization when Apple
85
85
  credentials are configured. If a macOS build is still ad-hoc signed, first
86
86
  launch may need one manual approval:
87
- - **macOS:** right-click the app in Finder → **Open** → **Open** to bypass Gatekeeper. If macOS instead reports *"SwarmClaw is damaged and can't be opened"* (common when the dmg was quarantined by Safari), strip the quarantine attribute and relaunch:
87
+ - **macOS:** signed/notarized releases publish both `.dmg` and `.zip`; unsigned fallback releases publish `.zip` only to avoid the damaged unsigned DMG path. Right-click the app in Finder → **Open** → **Open** to bypass Gatekeeper. If macOS instead reports *"SwarmClaw is damaged and can't be opened"* (common when a downloaded app was quarantined by Safari), strip the quarantine attribute and relaunch:
88
88
  ```bash
89
89
  xattr -dr com.apple.quarantine /Applications/SwarmClaw.app
90
90
  ```
@@ -151,6 +151,27 @@ openclaw skills install swarmclaw
151
151
 
152
152
  [Browse on ClawHub](https://clawhub.ai/waydelyle/swarmclaw)
153
153
 
154
+ ## v1.9.39 Highlights
155
+
156
+ Scheduled-run reliability release: stale workspace rebinding, fail-fast credential checks, and durable delivery evidence for schedule-driven sends.
157
+
158
+ - **Legacy workspace cwd migration.** Scheduled reruns and reused schedule sessions pinned to a pre-migration workspace root (e.g. `~/.swarmclaw/workspace`) are now rebound to the current `WORKSPACE_DIR` automatically; intentional custom working directories are never touched.
159
+ - **Orphan recovery dedup.** Startup queue recovery now recovers each orphaned task once instead of re-logging it every tick, and dead-letters tasks that repeatedly return to the queue without starting.
160
+ - **Schedule delivery status.** Every scheduled run now writes `lastDeliveryStatus`/`lastDeliveryError` back to its schedule, so failures are visible without digging through task records.
161
+ - **Credential preflight for schedules.** Scheduled runs on API-key providers fail fast with an actionable error when no credential resolves, instead of dying on a 401 deep in execution.
162
+ - **Empty-run classification.** Runs that produce no text, no tool calls, and no error now fail with a clear provider-configuration message instead of a generic validation failure.
163
+ - **Durable connector delivery evidence.** Task follow-up sends route through the connector outbox with task/schedule linkage, retries with backoff, and per-run dedupe, so triage can prove whether a scheduled send succeeded, failed, or was never attempted.
164
+ - **Regression coverage.** Added tests for workspace path normalization, orphan recovery, schedule outcome writes, credential preflight, empty-run classification, and outbox-backed follow-ups.
165
+
166
+ ## v1.9.38 Highlights
167
+
168
+ PR integration release for provider catalog coverage, OpenRouter context meters, and safer unsigned macOS desktop artifacts.
169
+
170
+ - **TokenMix provider.** Added TokenMix as a built-in OpenAI-compatible provider with setup metadata, starter-agent defaults, and provider health checks.
171
+ - **OpenRouter context meters.** Chat context status now uses cached OpenRouter model metadata when available so routed model context windows display accurately.
172
+ - **macOS unsigned artifact fallback.** Desktop releases publish zip-only macOS artifacts when signing/notarization inputs are missing, avoiding the unsigned DMG damaged-app path.
173
+ - **Regression coverage.** Added targeted tests for TokenMix setup, OpenRouter context metadata caching, and macOS target selection.
174
+
154
175
  ## v1.9.37 Highlights
155
176
 
156
177
  Theme and memory-pressure release for lighter UI preferences and leaner chat history storage.
@@ -479,6 +500,27 @@ Operational docs: https://swarmclaw.ai/docs/observability
479
500
 
480
501
  ## Releases
481
502
 
503
+ ### v1.9.39 Highlights
504
+
505
+ Scheduled-run reliability release: stale workspace rebinding, fail-fast credential checks, and durable delivery evidence for schedule-driven sends.
506
+
507
+ - **Legacy workspace cwd migration.** Scheduled reruns and reused schedule sessions pinned to a pre-migration workspace root (e.g. `~/.swarmclaw/workspace`) are now rebound to the current `WORKSPACE_DIR` automatically; intentional custom working directories are never touched.
508
+ - **Orphan recovery dedup.** Startup queue recovery now recovers each orphaned task once instead of re-logging it every tick, and dead-letters tasks that repeatedly return to the queue without starting.
509
+ - **Schedule delivery status.** Every scheduled run now writes `lastDeliveryStatus`/`lastDeliveryError` back to its schedule, so failures are visible without digging through task records.
510
+ - **Credential preflight for schedules.** Scheduled runs on API-key providers fail fast with an actionable error when no credential resolves, instead of dying on a 401 deep in execution.
511
+ - **Empty-run classification.** Runs that produce no text, no tool calls, and no error now fail with a clear provider-configuration message instead of a generic validation failure.
512
+ - **Durable connector delivery evidence.** Task follow-up sends route through the connector outbox with task/schedule linkage, retries with backoff, and per-run dedupe, so triage can prove whether a scheduled send succeeded, failed, or was never attempted.
513
+ - **Regression coverage.** Added tests for workspace path normalization, orphan recovery, schedule outcome writes, credential preflight, empty-run classification, and outbox-backed follow-ups.
514
+
515
+ ### v1.9.38 Highlights
516
+
517
+ PR integration release for provider catalog coverage, OpenRouter context meters, and safer unsigned macOS desktop artifacts.
518
+
519
+ - **TokenMix provider.** Added TokenMix as a built-in OpenAI-compatible provider with setup metadata, starter-agent defaults, and provider health checks.
520
+ - **OpenRouter context meters.** Chat context status now uses cached OpenRouter model metadata when available so routed model context windows display accurately.
521
+ - **macOS unsigned artifact fallback.** Desktop releases publish zip-only macOS artifacts when signing/notarization inputs are missing, avoiding the unsigned DMG damaged-app path.
522
+ - **Regression coverage.** Added targeted tests for TokenMix setup, OpenRouter context metadata caching, and macOS target selection.
523
+
482
524
  ### v1.9.37 Highlights
483
525
 
484
526
  Theme and memory-pressure release for lighter UI preferences and leaner chat history storage.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.9.37",
3
+ "version": "1.9.39",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -88,7 +88,7 @@
88
88
  "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/electron-signing-config.test.mjs scripts/ensure-sandbox-browser-image.test.mjs scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
89
89
  "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
90
90
  "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/gateways/gateway-topology.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/gateways/topology-route.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
91
- "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/agent-planning-mode.test.ts src/lib/agent-config-history.test.ts src/lib/autonomy/supervisor-settings.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/autonomy/supervisor-reflection.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/provider-diagnostics.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/memory/dream-service.test.ts src/lib/server/memory/memory-consolidation.test.ts src/lib/server/messages/message-repository.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/compaction-generation-preference.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/prompt-sections.planning-mode.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/chats/session-context-pack.test.ts src/lib/server/connectors/email.test.ts src/lib/server/connectors/slack.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/runs/run-handoff.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/server/schedules/schedule-timing.test.ts src/lib/server/schedules/schedule-preview.test.ts src/lib/quality/release-readiness.test.ts src/lib/quality/architecture-health.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-execution-policy.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/server/session-tools/web-crawl.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-pack-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/config-versions/config-versions-route.test.ts src/app/api/runs/run-handoff-route.test.ts src/app/api/search/route.test.ts src/app/api/settings/settings-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/gateways/control-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/preview/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
91
+ "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/agent-planning-mode.test.ts src/lib/agent-config-history.test.ts src/lib/autonomy/supervisor-settings.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/autonomy/supervisor-reflection.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/provider-diagnostics.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/memory/dream-service.test.ts src/lib/server/memory/memory-consolidation.test.ts src/lib/server/messages/message-repository.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/compaction-generation-preference.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/prompt-sections.planning-mode.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/chats/session-context-pack.test.ts src/lib/server/connectors/email.test.ts src/lib/server/connectors/slack.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/runs/run-handoff.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/server/schedules/schedule-timing.test.ts src/lib/server/schedules/schedule-preview.test.ts src/lib/quality/release-readiness.test.ts src/lib/quality/architecture-health.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-execution-policy.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/tasks/task-lifecycle.test.ts src/lib/server/tasks/task-followups.test.ts src/lib/server/tasks/task-result.test.ts src/lib/server/schedules/schedule-lifecycle.test.ts src/lib/server/workspace-paths.test.ts src/lib/server/runtime/queue/orphan-recovery.test.ts src/lib/server/runtime/scheduled-run-preflight.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/server/session-tools/web-crawl.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-pack-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/config-versions/config-versions-route.test.ts src/app/api/runs/run-handoff-route.test.ts src/app/api/search/route.test.ts src/app/api/settings/settings-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/gateways/control-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/preview/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
92
92
  "test:builder": "tsx --test src/features/protocols/builder/utils/builder-template-access.test.ts src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
93
93
  "test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
94
94
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
@@ -3,6 +3,7 @@ import { getSession } from '@/lib/server/sessions/session-repository'
3
3
  import { getMessages } from '@/lib/server/messages/message-repository'
4
4
  import { getContextStatus } from '@/lib/server/context-manager'
5
5
  import { notFound } from '@/lib/server/collection-helpers'
6
+ import { ensureOpenRouterModelContextCache } from '@/lib/server/openrouter-model-context'
6
7
 
7
8
  const SYSTEM_PROMPT_TOKEN_ESTIMATE = 2000
8
9
 
@@ -11,6 +12,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
11
12
  const session = getSession(id)
12
13
  if (!session) return notFound()
13
14
  const messages = getMessages(id)
15
+ await ensureOpenRouterModelContextCache(session.provider as string)
14
16
  const status = getContextStatus(
15
17
  messages,
16
18
  SYSTEM_PROMPT_TOKEN_ESTIMATE,
@@ -66,3 +66,62 @@ test('GET /api/chats/[id]/context-status returns token usage summary', () => {
66
66
  assert.ok(['ok', 'warning', 'critical'].includes(output.strategy))
67
67
  assert.equal(output.missingStatus, 404)
68
68
  })
69
+
70
+ test('GET /api/chats/[id]/context-status uses OpenRouter model metadata context window', () => {
71
+ const output = runWithTempDataDir<{
72
+ status: number
73
+ contextWindow: number
74
+ }>(`
75
+ const fs = await import('node:fs')
76
+ const path = await import('node:path')
77
+ const cachePath = path.join(process.env.DATA_DIR, 'openrouter-model-context.json')
78
+ fs.writeFileSync(cachePath, JSON.stringify({
79
+ loadedAt: Date.now(),
80
+ models: { 'minimax/minimax-m3': 524288 },
81
+ }))
82
+
83
+ globalThis.fetch = async () => {
84
+ throw new Error('route should use seeded OpenRouter model metadata cache')
85
+ }
86
+
87
+ const storageMod = await import('./src/lib/server/storage')
88
+ const repoMod = await import('@/lib/server/messages/message-repository')
89
+ const routeMod = await import('./src/app/api/chats/[id]/context-status/route')
90
+ const storage = storageMod.default || storageMod
91
+ const repo = repoMod.default || repoMod
92
+ const route = routeMod.default || routeMod
93
+
94
+ const now = Date.now()
95
+ storage.saveSessions({
96
+ sess_ctx_openrouter: {
97
+ id: 'sess_ctx_openrouter',
98
+ name: 'OpenRouter context status test',
99
+ cwd: process.env.WORKSPACE_DIR,
100
+ user: 'tester',
101
+ provider: 'openrouter',
102
+ model: 'minimax/minimax-m3',
103
+ claudeSessionId: null,
104
+ messages: [],
105
+ createdAt: now,
106
+ lastActiveAt: now,
107
+ },
108
+ })
109
+
110
+ repo.appendMessage('sess_ctx_openrouter', { role: 'user', text: 'hello world', time: now })
111
+
112
+ const response = await route.GET(
113
+ new Request('http://local/api/chats/sess_ctx_openrouter/context-status'),
114
+ { params: Promise.resolve({ id: 'sess_ctx_openrouter' }) },
115
+ )
116
+ const payload = await response.json()
117
+
118
+ console.log(JSON.stringify({
119
+ status: response.status,
120
+ contextWindow: payload.contextWindow,
121
+ }))
122
+ `, { prefix: 'swarmclaw-context-status-route-' })
123
+
124
+ assert.equal(output.status, 200)
125
+ assert.equal(output.contextWindow, 524_288)
126
+ assert.notEqual(output.contextWindow, 8_192)
127
+ })
@@ -161,3 +161,15 @@ test('POST returns provider diagnostics with normalized LM Studio targets and re
161
161
  globalThis.fetch = originalFetch
162
162
  }
163
163
  })
164
+
165
+ test('POST rejects TokenMix setup checks without an API key', async () => {
166
+ const res = await POST(new Request('http://localhost/api/setup/check-provider', {
167
+ method: 'POST',
168
+ body: JSON.stringify({ provider: 'tokenmix' }),
169
+ }))
170
+ const payload = await res.json()
171
+
172
+ assert.equal(res.status, 200)
173
+ assert.equal(payload.ok, false)
174
+ assert.equal(payload.message, 'TokenMix API key is required.')
175
+ })
@@ -501,6 +501,12 @@ export async function POST(req: Request) {
501
501
  const result = await checkOpenAiCompatible(info.name, apiKey, endpoint, info.defaultEndpoint, model)
502
502
  return NextResponse.json(result)
503
503
  }
504
+ case 'tokenmix': {
505
+ if (!apiKey) return NextResponse.json({ ok: false, message: 'TokenMix API key is required.' })
506
+ const info = OPENAI_COMPATIBLE_DEFAULTS.tokenmix
507
+ const result = await checkOpenAiCompatible(info.name, apiKey, endpoint, info.defaultEndpoint, model)
508
+ return NextResponse.json(result)
509
+ }
504
510
  case 'anthropic': {
505
511
  if (!apiKey) return NextResponse.json({ ok: false, message: 'Anthropic API key is required.' })
506
512
  const result = await checkAnthropic(apiKey, endpoint, model)
@@ -128,6 +128,29 @@ export const PROVIDERS: Record<string, BuiltinProviderConfig> = {
128
128
  },
129
129
  },
130
130
  },
131
+ tokenmix: {
132
+ id: 'tokenmix',
133
+ name: 'TokenMix',
134
+ models: [
135
+ 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5',
136
+ 'gpt-5.4', 'gpt-5.4-mini',
137
+ 'gemini-2.5-pro', 'gemini-2.5-flash',
138
+ 'deepseek-chat', 'deepseek-reasoner',
139
+ 'qwen-max',
140
+ ],
141
+ requiresApiKey: true,
142
+ requiresEndpoint: false,
143
+ defaultEndpoint: 'https://api.tokenmix.ai/v1',
144
+ handler: {
145
+ streamChat: (opts) => {
146
+ const patchedSession = {
147
+ ...opts.session,
148
+ apiEndpoint: opts.session.apiEndpoint || 'https://api.tokenmix.ai/v1',
149
+ }
150
+ return streamOpenAiChat({ ...opts, session: patchedSession })
151
+ },
152
+ },
153
+ },
131
154
  anthropic: {
132
155
  id: 'anthropic',
133
156
  name: 'Anthropic',
@@ -5,7 +5,7 @@ import path from 'node:path'
5
5
  import { spawnSync } from 'node:child_process'
6
6
  import { describe, it } from 'node:test'
7
7
 
8
- import { assessAutonomyRun } from '@/lib/server/autonomy/supervisor-reflection'
8
+ import { assessAutonomyRun, classifyRuntimeFailure } from '@/lib/server/autonomy/supervisor-reflection'
9
9
  import type { Session } from '@/types'
10
10
 
11
11
  const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../../..')
@@ -615,4 +615,13 @@ describe('supervisor-reflection', () => {
615
615
  assert.doesNotMatch(String(incident?.details || ''), /<!doctype html>/i)
616
616
  assert.match(String(incident?.details || ''), /singleton is not defined/i)
617
617
  })
618
+
619
+ it('classifies the scheduled-run credential preflight error as provider auth', () => {
620
+ const failure = classifyRuntimeFailure({
621
+ source: 'task',
622
+ message: 'Provider authentication preflight failed: no API credential configured for provider "openai".',
623
+ })
624
+ assert.equal(failure.family, 'provider_auth')
625
+ assert.equal(failure.severity, 'high')
626
+ })
618
627
  })
@@ -43,6 +43,10 @@ export interface ConnectorOutboxEntry extends Record<string, unknown> {
43
43
  threadId?: string
44
44
  ptt?: boolean
45
45
  dedupeKey?: string | null
46
+ /** Originating task, when this entry delivers a task follow-up */
47
+ taskId?: string | null
48
+ /** Originating schedule, when the task was schedule-driven */
49
+ scheduleId?: string | null
46
50
  lastError?: string | null
47
51
  deliveredAt?: number | null
48
52
  lastMessageId?: string | null
@@ -78,8 +82,8 @@ function normalizeEntry(value: unknown): ConnectorOutboxEntry | null {
78
82
  if (!id || !channelId) return null
79
83
  return {
80
84
  id,
81
-
82
-
85
+ connectorId: typeof row.connectorId === 'string' ? row.connectorId : undefined,
86
+ platform: typeof row.platform === 'string' ? row.platform : undefined,
83
87
  channelId,
84
88
  text: typeof row.text === 'string' ? row.text : '',
85
89
  sessionId: typeof row.sessionId === 'string' ? row.sessionId : null,
@@ -99,6 +103,8 @@ function normalizeEntry(value: unknown): ConnectorOutboxEntry | null {
99
103
  attemptCount: typeof row.attemptCount === 'number' ? row.attemptCount : 0,
100
104
  maxAttempts: typeof row.maxAttempts === 'number' ? row.maxAttempts : DEFAULT_MAX_ATTEMPTS,
101
105
  dedupeKey: typeof row.dedupeKey === 'string' ? row.dedupeKey : null,
106
+ taskId: typeof row.taskId === 'string' ? row.taskId : null,
107
+ scheduleId: typeof row.scheduleId === 'string' ? row.scheduleId : null,
102
108
  lastError: typeof row.lastError === 'string' ? row.lastError : null,
103
109
  deliveredAt: typeof row.deliveredAt === 'number' ? row.deliveredAt : null,
104
110
  lastMessageId: typeof row.lastMessageId === 'string' ? row.lastMessageId : null,
@@ -301,6 +307,20 @@ export function enqueueConnectorOutbox(
301
307
  return { outboxId: entry.id, sendAt: entry.sendAt }
302
308
  }
303
309
 
310
+ /**
311
+ * True when a delivery for this dedupe key already reached a successful
312
+ * terminal state. `findPendingConnectorOutboxByDedupe` only sees non-terminal
313
+ * entries, so it cannot prevent re-sends after a success.
314
+ */
315
+ export function hasSentConnectorOutboxForDedupe(dedupeKey: string): boolean {
316
+ const normalizedKey = dedupeKey.trim()
317
+ if (!normalizedKey) return false
318
+ return listEntries().some((entry) =>
319
+ entry.dedupeKey === normalizedKey
320
+ && (entry.status === 'sent' || entry.status === 'suppressed'),
321
+ )
322
+ }
323
+
304
324
  export function findPendingConnectorOutboxByDedupe(dedupeKey: string, now = Date.now()): ConnectorOutboxEntry | null {
305
325
  const normalizedKey = dedupeKey.trim()
306
326
  if (!normalizedKey) return null
@@ -2,6 +2,7 @@ import type { Message, Session } from '@/types'
2
2
  import { getMemoryDb } from '@/lib/server/memory/memory-db'
3
3
  import { extractFactsFromMessages, ensureRunContext, pruneRunContext } from '@/lib/server/run-context'
4
4
  import { getSession, saveSession } from '@/lib/server/sessions/session-repository'
5
+ import { getCachedOpenRouterContextWindow } from '@/lib/server/openrouter-model-context'
5
6
 
6
7
  import { repairTranscriptConsistency } from './transcript-repair'
7
8
 
@@ -97,6 +98,9 @@ const PROVIDER_DEFAULT_WINDOWS: Record<string, number> = {
97
98
 
98
99
  /** Get context window size for a model, falling back to provider default */
99
100
  export function getContextWindowSize(provider: string, model: string): number {
101
+ const openRouterContext = getCachedOpenRouterContextWindow(provider, model)
102
+ if (openRouterContext) return openRouterContext
103
+
100
104
  return PROVIDER_CONTEXT_WINDOWS[model]
101
105
  || PROVIDER_DEFAULT_WINDOWS[provider]
102
106
  || 8_192
@@ -0,0 +1,205 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
5
+
6
+ interface OpenRouterContextResult {
7
+ contextWindow: number | null
8
+ fetchCalls?: number
9
+ }
10
+
11
+ function runOpenRouterContextScript(script: string): OpenRouterContextResult {
12
+ return runWithTempDataDir<OpenRouterContextResult>(script, {
13
+ prefix: 'swarmclaw-openrouter-context-',
14
+ })
15
+ }
16
+
17
+ test('exact OpenRouter model ID returns cached context length', () => {
18
+ const output = runOpenRouterContextScript(`
19
+ const fs = await import('node:fs')
20
+ const path = await import('node:path')
21
+ const cachePath = path.join(process.env.DATA_DIR, 'openrouter-model-context.json')
22
+ fs.writeFileSync(cachePath, JSON.stringify({
23
+ loadedAt: Date.now(),
24
+ models: { 'minimax/minimax-m3': 524288 },
25
+ }))
26
+
27
+ const modImport = await import('./src/lib/server/openrouter-model-context')
28
+ const mod = modImport.default || modImport
29
+ globalThis.fetch = async () => {
30
+ throw new Error('fetch should not run when cache is fresh')
31
+ }
32
+
33
+ await mod.ensureOpenRouterModelContextCache('openrouter')
34
+ console.log(JSON.stringify({
35
+ contextWindow: mod.getCachedOpenRouterContextWindow('openrouter', 'minimax/minimax-m3'),
36
+ }))
37
+ `)
38
+
39
+ assert.equal(output.contextWindow, 524_288)
40
+ })
41
+
42
+ test('top_provider.context_length is preferred over context_length', () => {
43
+ const output = runOpenRouterContextScript(`
44
+ const modImport = await import('./src/lib/server/openrouter-model-context')
45
+ const mod = modImport.default || modImport
46
+ globalThis.fetch = async () => new Response(JSON.stringify({
47
+ data: [{
48
+ id: 'provider/model-a',
49
+ context_length: 8192,
50
+ top_provider: { context_length: 131072 },
51
+ }],
52
+ }), { status: 200 })
53
+
54
+ await mod.ensureOpenRouterModelContextCache('openrouter')
55
+ console.log(JSON.stringify({
56
+ contextWindow: mod.getCachedOpenRouterContextWindow('openrouter', 'provider/model-a'),
57
+ }))
58
+ `)
59
+
60
+ assert.equal(output.contextWindow, 131_072)
61
+ })
62
+
63
+ test('unique suffix match works for unprefixed model IDs', () => {
64
+ const output = runOpenRouterContextScript(`
65
+ const modImport = await import('./src/lib/server/openrouter-model-context')
66
+ const mod = modImport.default || modImport
67
+ globalThis.fetch = async () => new Response(JSON.stringify({
68
+ data: [{ id: 'google/gemini-2.5-pro', context_length: 1048576 }],
69
+ }), { status: 200 })
70
+
71
+ await mod.ensureOpenRouterModelContextCache('openrouter')
72
+ console.log(JSON.stringify({
73
+ contextWindow: mod.getCachedOpenRouterContextWindow('openrouter', 'gemini-2.5-pro'),
74
+ }))
75
+ `)
76
+
77
+ assert.equal(output.contextWindow, 1_048_576)
78
+ })
79
+
80
+ test('ambiguous suffix match returns null', () => {
81
+ const output = runOpenRouterContextScript(`
82
+ const modImport = await import('./src/lib/server/openrouter-model-context')
83
+ const mod = modImport.default || modImport
84
+ globalThis.fetch = async () => new Response(JSON.stringify({
85
+ data: [
86
+ { id: 'provider-a/shared-model', context_length: 32000 },
87
+ { id: 'provider-b/shared-model', context_length: 64000 },
88
+ ],
89
+ }), { status: 200 })
90
+
91
+ await mod.ensureOpenRouterModelContextCache('openrouter')
92
+ console.log(JSON.stringify({
93
+ contextWindow: mod.getCachedOpenRouterContextWindow('openrouter', 'shared-model'),
94
+ }))
95
+ `)
96
+
97
+ assert.equal(output.contextWindow, null)
98
+ })
99
+
100
+ test('non-OpenRouter provider returns null', () => {
101
+ const output = runOpenRouterContextScript(`
102
+ const modImport = await import('./src/lib/server/openrouter-model-context')
103
+ const mod = modImport.default || modImport
104
+ console.log(JSON.stringify({
105
+ contextWindow: mod.getCachedOpenRouterContextWindow('openai', 'minimax/minimax-m3'),
106
+ }))
107
+ `)
108
+
109
+ assert.equal(output.contextWindow, null)
110
+ })
111
+
112
+ test('failed fetch does not throw', () => {
113
+ const output = runOpenRouterContextScript(`
114
+ const modImport = await import('./src/lib/server/openrouter-model-context')
115
+ const mod = modImport.default || modImport
116
+ let fetchCalls = 0
117
+ globalThis.fetch = async () => {
118
+ fetchCalls += 1
119
+ throw new Error('network down')
120
+ }
121
+
122
+ await mod.ensureOpenRouterModelContextCache('openrouter')
123
+ console.log(JSON.stringify({
124
+ contextWindow: mod.getCachedOpenRouterContextWindow('openrouter', 'minimax/minimax-m3'),
125
+ fetchCalls,
126
+ }))
127
+ `)
128
+
129
+ assert.equal(output.contextWindow, null)
130
+ assert.equal(output.fetchCalls, 1)
131
+ })
132
+
133
+ test('timed out fetch does not throw', () => {
134
+ const output = runOpenRouterContextScript(`
135
+ const modImport = await import('./src/lib/server/openrouter-model-context')
136
+ const mod = modImport.default || modImport
137
+ let fetchCalls = 0
138
+ globalThis.fetch = async (_input, init) => {
139
+ fetchCalls += 1
140
+ await new Promise((_resolve, reject) => {
141
+ init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true })
142
+ })
143
+ }
144
+
145
+ await mod.ensureOpenRouterModelContextCache('openrouter')
146
+ console.log(JSON.stringify({
147
+ contextWindow: mod.getCachedOpenRouterContextWindow('openrouter', 'minimax/minimax-m3'),
148
+ fetchCalls,
149
+ }))
150
+ `)
151
+
152
+ assert.equal(output.contextWindow, null)
153
+ assert.equal(output.fetchCalls, 1)
154
+ })
155
+
156
+ test('stale cache is ignored', () => {
157
+ const output = runOpenRouterContextScript(`
158
+ const fs = await import('node:fs')
159
+ const path = await import('node:path')
160
+ const cachePath = path.join(process.env.DATA_DIR, 'openrouter-model-context.json')
161
+ fs.writeFileSync(cachePath, JSON.stringify({
162
+ loadedAt: Date.now() - (25 * 60 * 60 * 1000),
163
+ models: { 'minimax/minimax-m3': 524288 },
164
+ }))
165
+
166
+ const modImport = await import('./src/lib/server/openrouter-model-context')
167
+ const mod = modImport.default || modImport
168
+ let fetchCalls = 0
169
+ globalThis.fetch = async () => {
170
+ fetchCalls += 1
171
+ throw new Error('network down')
172
+ }
173
+
174
+ await mod.ensureOpenRouterModelContextCache('openrouter')
175
+ console.log(JSON.stringify({
176
+ contextWindow: mod.getCachedOpenRouterContextWindow('openrouter', 'minimax/minimax-m3'),
177
+ fetchCalls,
178
+ }))
179
+ `)
180
+
181
+ assert.equal(output.contextWindow, null)
182
+ assert.equal(output.fetchCalls, 1)
183
+ })
184
+
185
+ test('cache write failure does not throw', () => {
186
+ const output = runOpenRouterContextScript(`
187
+ const fs = await import('node:fs')
188
+ const path = await import('node:path')
189
+ const cachePath = path.join(process.env.DATA_DIR, 'openrouter-model-context.json')
190
+ fs.mkdirSync(cachePath)
191
+
192
+ const modImport = await import('./src/lib/server/openrouter-model-context')
193
+ const mod = modImport.default || modImport
194
+ globalThis.fetch = async () => new Response(JSON.stringify({
195
+ data: [{ id: 'provider/model-a', context_length: 65536 }],
196
+ }), { status: 200 })
197
+
198
+ await mod.ensureOpenRouterModelContextCache('openrouter')
199
+ console.log(JSON.stringify({
200
+ contextWindow: mod.getCachedOpenRouterContextWindow('openrouter', 'provider/model-a'),
201
+ }))
202
+ `)
203
+
204
+ assert.equal(output.contextWindow, 65_536)
205
+ })