@swarmclawai/swarmclaw 1.5.62 → 1.5.64

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 (39) hide show
  1. package/README.md +25 -10
  2. package/package.json +2 -2
  3. package/src/app/api/chatrooms/[id]/chat/route.test.ts +111 -0
  4. package/src/app/api/chatrooms/[id]/chat/route.ts +47 -28
  5. package/src/app/api/chatrooms/[id]/members/route.ts +0 -8
  6. package/src/app/api/chatrooms/[id]/route.ts +0 -11
  7. package/src/app/api/chatrooms/route.ts +0 -10
  8. package/src/app/api/chats/[id]/clear/route.ts +7 -3
  9. package/src/app/api/chats/[id]/clear/undo/route.ts +23 -0
  10. package/src/app/api/chats/[id]/compact/route.ts +72 -0
  11. package/src/app/api/chats/[id]/context-status/route.ts +21 -0
  12. package/src/app/api/chats/clear-route.test.ts +121 -0
  13. package/src/app/api/chats/compact-route.test.ts +70 -0
  14. package/src/app/api/chats/context-status-route.test.ts +68 -0
  15. package/src/app/api/mcp-servers/[id]/route.ts +5 -0
  16. package/src/app/api/mcp-servers/[id]/test/route.ts +5 -0
  17. package/src/app/api/mcp-servers/[id]/tools-info/route.ts +75 -0
  18. package/src/cli/index.js +5 -1
  19. package/src/cli/spec.js +4 -1
  20. package/src/components/chat/chat-area.tsx +62 -6
  21. package/src/components/chat/chat-header.tsx +13 -1
  22. package/src/components/chat/context-meter-badge.tsx +227 -0
  23. package/src/components/chatrooms/chatroom-sheet.tsx +1 -4
  24. package/src/components/mcp-servers/mcp-server-list.tsx +56 -0
  25. package/src/components/mcp-servers/mcp-server-sheet.tsx +202 -1
  26. package/src/components/mcp-servers/registry-browser.tsx +224 -0
  27. package/src/lib/chat/chats.ts +37 -1
  28. package/src/lib/server/chats/chat-session-service.ts +75 -0
  29. package/src/lib/server/chats/clear-undo-snapshots.test.ts +107 -0
  30. package/src/lib/server/chats/clear-undo-snapshots.ts +92 -0
  31. package/src/lib/server/mcp-connection-pool.test.ts +98 -0
  32. package/src/lib/server/mcp-connection-pool.ts +134 -0
  33. package/src/lib/server/mcp-gateway-runtime.test.ts +177 -0
  34. package/src/lib/server/mcp-gateway-runtime.ts +138 -0
  35. package/src/lib/server/session-tools/chatroom.ts +0 -11
  36. package/src/lib/server/session-tools/index.ts +83 -15
  37. package/src/lib/server/storage-normalization.ts +11 -0
  38. package/src/types/agent.ts +1 -0
  39. package/src/types/misc.ts +7 -0
package/README.md CHANGED
@@ -399,6 +399,31 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
402
+ ### v1.5.64 Highlights
403
+
404
+ Two themes this release. First, **context-window management reaches the chat UI**: a live token-usage meter in every chat header, a one-click LLM-backed compaction that keeps the session alive without nuking history, and a redesigned clear flow with a 30-second undo that restores both transcripts and CLI resume IDs. Second, **MCP token spend is now controllable**: per-server `alwaysExpose` policy, per-agent eager-tool overrides, an in-session `mcp_tool_search` promoter, a long-lived connection pool, a token-cost endpoint per server, and a built-in browser for the public SwarmDock MCP registry.
405
+
406
+ - **Context meter in the chat header.** New `ContextMeterBadge` (`src/components/chat/context-meter-badge.tsx`) renders a live chip showing `N% · Mk` next to the chat title, driven by `GET /api/chats/:id/context-status`. Color thresholds at 70% (amber) and 90% (red). Clicking the chip opens a popover with the full breakdown (used / remaining / messages) plus Compact and Clear buttons. The button row explicitly states: *"Long-term memory, skills, and facts are preserved. Clear only affects this chat transcript."* — so users stop fearing Clear.
407
+ - **User-invokable `/compact` via the popover.** New `POST /api/chats/:id/compact` runs `summarizeAndCompact` with the session's own provider/model via `buildChatModel` as the summarizer. The existing hierarchical-summary pipeline in `context-manager.ts` does the work: tool failures, file ops, and adaptive chunking are all preserved. Accepts `keepLastN` in the body (2-200, default 10). Returns `status: 'no_action' | 'compacted'` plus counts. The popover gates the button below 3 messages so users don't waste LLM calls on trivially short transcripts.
408
+ - **Clear with 30-second undo.** `POST /api/chats/:id/clear` now returns `{ cleared, undoToken, expiresAt }`, and a new `POST /api/chats/:id/clear/undo` restores the snapshot. The undo snapshot (messages + every CLI session ID including `claudeSessionId`, `codexThreadId`, `opencodeSessionId`, `opencodeWebSessionId`, `geminiSessionId`, `copilotSessionId`, `droidSessionId`, `cursorSessionId`, `qwenSessionId`, `acpSessionId`, and `delegateResumeIds`) lives in an HMR-safe in-memory store (`src/lib/server/chats/clear-undo-snapshots.ts`) with a 30-second TTL, 200-entry cap, session-scoped lookups, and single-use tokens. The chat UI wires this to a sonner toast with an Undo action; restoring fires a "Chat restored." confirmation toast.
409
+ - **`alwaysExpose` policy for MCP servers** (`McpServerConfig.alwaysExpose: boolean | string[]`, default `true` for back-compat). Set `false` on a chatty server (e.g. a Playwright MCP with 40 tools that cost thousands of tokens per turn) and the agent binds nothing up front — it can still discover and promote specific tools via the new `mcp_tool_search` meta-tool. Set an allowlist `['query_resources', 'fetch_url']` to eagerly bind a curated subset.
410
+ - **Per-agent `mcpEagerTools` override** (`Agent.mcpEagerTools?: string[]`) lets you force-expose specific tool names for a specific agent regardless of the server's `alwaysExpose`. Precedence: per-agent allowlist > server `alwaysExpose` > session promotions.
411
+ - **`mcp_tool_search` meta-tool** (`src/lib/server/mcp-gateway-runtime.ts`). When a server's tools are lazy, the agent gets a single `mcp_tool_search({ query, limit? })` tool that searches the process-wide discovery cache (bare name substring + description keywords) and promotes matches for the current session. The next turn binds the promoted names for real. `SessionToolPromoter` state is keyed by session ID and HMR-safe. Behavior mirrors `@swarmclawai/mcp-gateway`'s router so users who split MCP fan-out across SwarmClaw and the gateway get consistent semantics.
412
+ - **Long-lived MCP connection pool** (`src/lib/server/mcp-connection-pool.ts`). A single client/transport per server lives for the process lifetime instead of reconnecting every turn. Config-fingerprint tracking rotates stale entries automatically; the `/test` endpoint evicts explicitly so a config change takes effect immediately. Saves ~100-500ms × (servers × turns) per chat. HMR-safe via `hmrSingleton` so dev reloads don't leak child processes.
413
+ - **Token-cost discovery endpoint** (`GET /api/mcp-servers/:id/tools-info`). Connects, lists tools, and reports per-tool schema tokens plus aggregates — using the same `chars / 3.5` formula as `@swarmclawai/mcp-gateway` so numbers line up side by side. Surfaces inside `mcp-server-list.tsx` so you can see which server is the costliest before an agent even runs.
414
+ - **SwarmDock MCP Registry browser** (`src/components/mcp-servers/registry-browser.tsx`). Opens from the New MCP Server sheet and browses the public registry at `https://swarmdock-api.onrender.com/api/v1/mcp/servers`. Selecting a server populates the form with its recommended install command — one-click discovery without leaving SwarmClaw. A new `MCP Gateway (local)` preset is also bundled so users can bootstrap `@swarmclawai/mcp-gateway` in one tap.
415
+ - **4 new CLI commands.** `swarmclaw chats context-status <id>`, `swarmclaw chats compact <id>`, `swarmclaw chats clear-undo <id>`, and the existing `chats clear` now returns the undo token so CLI scripts can build their own clear+undo workflows.
416
+ - **Back-compat normalization.** Existing MCP servers load with `alwaysExpose: true` (historical behavior — every tool bound every turn) via `storage-normalization.ts`. No user action required to upgrade.
417
+ - **Full regression coverage.** New tests: `clear-undo-snapshots.test.ts` (5 cases — TTL, single-use, session isolation, CLI-id preservation, expiry sweep), `clear-route.test.ts` (clear → undo → double-undo 404 → missing-session 404 round-trip), `compact-route.test.ts` (no-action path + 404), `context-status-route.test.ts`, plus `mcp-connection-pool.test.ts` and `mcp-gateway-runtime.test.ts`. `test:runtime` runs 100 tests across 13 suites.
418
+
419
+ ### v1.5.63 Highlights
420
+
421
+ Chatroom fix from @borislavnnikolov: CLI-backed agents (codex-cli, copilot-cli, gemini-cli, and the rest of the `NON_LANGGRAPH_PROVIDER_IDS` set) now work correctly as chatroom members instead of falling through a LangGraph path they cannot run. With the execution path fixed, the worker-only membership blocks are lifted too, so any non-trashed agent can be added to a room.
422
+
423
+ - **Direct provider runtime for CLI chatroom turns.** `src/app/api/chatrooms/[id]/chat/route.ts` now branches on `NON_LANGGRAPH_PROVIDER_IDS` and calls `provider.handler.streamChat()` directly for CLI-backed agents while keeping the LangGraph `streamAgentChat` path for everything else. Streaming, tool events, and persisted messages all flow through unchanged.
424
+ - **Full member selection.** The create, update, members, session-tool, and UI layers (`src/app/api/chatrooms/route.ts`, `src/app/api/chatrooms/[id]/route.ts`, `src/app/api/chatrooms/[id]/members/route.ts`, `src/lib/server/session-tools/chatroom.ts`, `src/components/chatrooms/chatroom-sheet.tsx`) no longer reject or hide worker-only agents. Any non-trashed agent is eligible.
425
+ - **Regression test.** `src/app/api/chatrooms/[id]/chat/route.test.ts` proves a `codex-cli`-backed chatroom turn bypasses `streamAgentChat`, streams a response through the provider handler, and persists one assistant reply.
426
+
402
427
  ### v1.5.62 Highlights
403
428
 
404
429
  Hardens parallel sub-agent dispatch with a concurrency cap, a quorum join policy, and a cycle check — so a fan-out can't accidentally saturate providers, melt a mission budget, or wedge the runtime on a delegation loop.
@@ -432,16 +457,6 @@ Viral-loop release. Adds public share links for missions, skills, and sessions,
432
457
  - **Share-link-based skill install.** `POST /api/skills/import` already accepts an http(s) URL; pointing it at `https://<your-host>/api/s/<token>/raw` now installs a shared skill from another SwarmClaw instance without auth handshakes. Pairs naturally with existing `swarmclaw skills import` CLI.
433
458
  - **Share-link repository tests.** `share-link-repository.test.ts` covers mint / list / revoke / lookup-by-token round-trip plus expiry handling against a temporary data dir.
434
459
 
435
- ### v1.5.58 Highlights
436
-
437
- This release broadens the built-in evaluation harness so SwarmClaw runs can be benchmarked against named suites, adds two targeted starter kits, exposes live per-session cost data, tightens auto-skill drafting, and ships a zero-setup demo mission template.
438
-
439
- - **Benchmark-style eval suites.** New `SWEBENCH_LITE_SCENARIOS` and `GAIA_L1_SCENARIOS` in `src/lib/server/eval/scenarios-swebench.ts` and `scenarios-gaia.ts` — curated parallels (not the upstream datasets) sized for a single-agent harness run. The shared `EvalScenario` type now carries an optional `suite: 'core' | 'swe-bench-lite' | 'gaia-l1' | 'tool-use' | 'code-action'` tag. `POST /api/eval/suite` accepts `{ suite: "swe-bench-lite" }` to scope a run. New `GET /api/eval/suites` lists every suite with scenario count, max score, and categories. CLI commands: `swarmclaw eval suites`, and `swarmclaw eval suite` still takes a JSON body now including `suite`. Useful for advertising verifiable numbers against a named benchmark instead of a bespoke scoring rubric.
440
- - **Two additional starter kits.** `inbox_triage` (single Triager agent over email + memory + documents) and `data_analyst` (single Analyst agent over shell + files + web + documents) join the existing seven kits in `src/lib/setup-defaults.ts`. Both are surfaced on the intent-driven setup path alongside Personal Assistant, Research Copilot, Builder Studio, and Delegate Team.
441
- - **Live per-session usage API.** New `GET /api/usage/live?sessionId=...` returns a lightweight snapshot — records, tokens in/out, estimated cost, firstAt/lastAt, wallclockMs, turns — so frontends can surface a live cost meter without pulling the full aggregated `/api/usage` payload. Without a `sessionId` the route returns the ten most recently active sessions. Registered in the CLI as `swarmclaw usage live`.
442
- - **Auto-skill drafting is stricter and rate-limited.** `shouldAutoDraftSkillSuggestion` in `chat-turn-finalization.ts` now requires at least 3 tool events in the completed turn (was 1), and a new per-agent daily cap limits automatic drafts to 3 per day per agent to prevent suggestion-inbox spam. Both thresholds are named constants (`AUTO_DRAFT_MIN_TOOL_EVENTS`, `AUTO_DRAFT_DAILY_LIMIT`). Agents with `autoDraftSkillSuggestions = false` are unaffected (auto-drafting remains opt-in per agent).
443
- - **Hello World demo mission template.** New `hello-world-demo` entry in `BUILT_IN_MISSION_TEMPLATES` — a bounded, zero-setup mission that reads three files in the working directory and writes a one-paragraph markdown summary to `hello-world-report.md`. Budgets (USD 0.25, 20k tokens, 30 turns, 15 min) are small enough to run on a local Ollama model without cost. Intended as the first thing a new user watches an agent complete end to end.
444
-
445
460
  Older releases: https://swarmclaw.ai/docs/release-notes
446
461
 
447
462
  - GitHub releases: https://github.com/swarmclawai/swarmclaw/releases
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.5.62",
3
+ "version": "1.5.64",
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",
@@ -80,7 +80,7 @@
80
80
  "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
81
81
  "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",
82
82
  "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/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/openclaw-exports.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
83
- "test:runtime": "tsx --test src/lib/server/knowledge-sources.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/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.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/chats/chat-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/tts/route.test.ts",
83
+ "test:runtime": "tsx --test 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/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.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/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-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/tts/route.test.ts",
84
84
  "test:builder": "tsx --test 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",
85
85
  "test:e2e": "tsx .workbench/browser-e2e/run.ts",
86
86
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
@@ -297,3 +297,114 @@ test('chatroom route forwards tool activity and records one reply per participat
297
297
  assert.deepEqual(output.assistantCounts, { alpha: 1, beta: 1 })
298
298
  assert.deepEqual([...new Set(output.agentOrder)].sort(), ['alpha', 'beta'])
299
299
  })
300
+
301
+ test('chatroom route uses direct provider runtime for CLI providers', () => {
302
+ const output = runWithTempDataDir<{
303
+ errors: string[]
304
+ streamedText: string
305
+ assistantTexts: string[]
306
+ }>(`
307
+ const storageMod = await import('./src/lib/server/storage')
308
+ const providersMod = await import('@/lib/providers')
309
+ const routeMod = await import('./src/app/api/chatrooms/[id]/chat/route')
310
+ const streamMod = await import('@/lib/server/chat-execution/stream-agent-chat')
311
+ const storage = storageMod.default || storageMod
312
+ const providers = providersMod.default || providersMod
313
+ const route = routeMod.default || routeMod
314
+ const stream = streamMod.default || streamMod
315
+
316
+ const originalHandler = providers.PROVIDERS['codex-cli'].handler
317
+
318
+ const now = Date.now()
319
+ storage.saveAgents({
320
+ alpha: {
321
+ id: 'alpha',
322
+ name: 'Alpha',
323
+ provider: 'codex-cli',
324
+ model: 'gpt-5.3-codex',
325
+ extensions: [],
326
+ createdAt: now,
327
+ updatedAt: now,
328
+ },
329
+ })
330
+ storage.saveChatrooms({
331
+ room_1: {
332
+ id: 'room_1',
333
+ name: 'CLI Room',
334
+ agentIds: ['alpha'],
335
+ messages: [],
336
+ createdAt: now,
337
+ updatedAt: now,
338
+ chatMode: 'sequential',
339
+ autoAddress: true,
340
+ },
341
+ })
342
+
343
+ async function readSse(response) {
344
+ const reader = response.body.getReader()
345
+ const decoder = new TextDecoder()
346
+ let buffer = ''
347
+ const events = []
348
+ while (true) {
349
+ const { done, value } = await reader.read()
350
+ if (done) break
351
+ buffer += decoder.decode(value, { stream: true })
352
+ let idx = buffer.indexOf('\\n\\n')
353
+ while (idx !== -1) {
354
+ const chunk = buffer.slice(0, idx)
355
+ buffer = buffer.slice(idx + 2)
356
+ const line = chunk
357
+ .split('\\n')
358
+ .map((entry) => entry.trim())
359
+ .find((entry) => entry.startsWith('data: '))
360
+ if (line) {
361
+ events.push(JSON.parse(line.slice(6)))
362
+ }
363
+ idx = buffer.indexOf('\\n\\n')
364
+ }
365
+ }
366
+ return events
367
+ }
368
+
369
+ stream.setStreamAgentChatForTest(async () => {
370
+ throw new Error('streamAgentChat should not be called for codex-cli chatroom turns')
371
+ })
372
+ providers.PROVIDERS['codex-cli'].handler = {
373
+ streamChat: async (opts) => {
374
+ const reply = 'Codex CLI answered from direct provider runtime.'
375
+ opts.write('data: ' + JSON.stringify({ t: 'd', text: reply }) + '\\n')
376
+ return reply
377
+ },
378
+ }
379
+
380
+ try {
381
+ const response = await route.POST(
382
+ new Request('http://local/api/chatrooms/room_1/chat', {
383
+ method: 'POST',
384
+ headers: { 'content-type': 'application/json' },
385
+ body: JSON.stringify({ senderId: 'user', text: 'Say hello to the room.' }),
386
+ }),
387
+ { params: Promise.resolve({ id: 'room_1' }) },
388
+ )
389
+
390
+ const events = await readSse(response)
391
+ const chatroom = storage.loadChatrooms().room_1
392
+ const assistantTexts = chatroom.messages
393
+ .filter((entry) => entry.role === 'assistant')
394
+ .map((entry) => entry.text)
395
+
396
+ console.log(JSON.stringify({
397
+ errors: events.filter((entry) => entry.t === 'err').map((entry) => entry.text),
398
+ streamedText: events.filter((entry) => entry.t === 'd').map((entry) => entry.text).join(''),
399
+ assistantTexts,
400
+ }))
401
+ } finally {
402
+ providers.PROVIDERS['codex-cli'].handler = originalHandler
403
+ stream.setStreamAgentChatForTest(null)
404
+ }
405
+ `, { prefix: 'swarmclaw-chatroom-route-cli-provider-' })
406
+
407
+ assert.equal(output.errors.some((text) => /streamAgentChat should not be called/i.test(text)), false)
408
+ assert.equal(output.streamedText.includes('Codex CLI answered from direct provider runtime.'), true)
409
+ assert.equal(output.assistantTexts.some((text) => text.includes('Codex CLI answered from direct provider runtime.')), true)
410
+ })
@@ -6,6 +6,7 @@ import { notFound } from '@/lib/server/collection-helpers'
6
6
  import { safeParseBody } from '@/lib/server/safe-parse-body'
7
7
  import { streamAgentChat } from '@/lib/server/chat-execution/stream-agent-chat'
8
8
  import { getProvider } from '@/lib/providers'
9
+ import { NON_LANGGRAPH_PROVIDER_IDS } from '@/lib/provider-sets'
9
10
  import {
10
11
  resolveApiKey,
11
12
  parseMentions,
@@ -231,37 +232,55 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
231
232
 
232
233
  let fullText = ''
233
234
  let agentError = ''
234
- const result = await streamAgentChat({
235
- session: syntheticSession,
236
- message: messageForAgent,
237
- imagePath,
238
- attachedFiles,
239
- apiKey,
240
- systemPrompt: fullSystemPrompt,
241
- write: (raw: string) => {
242
- const lines = raw.split('\n').filter(Boolean)
243
- for (const line of lines) {
244
- if (!line.startsWith('data: ')) continue
245
- try {
246
- const parsed = JSON.parse(line.slice(6).trim())
247
- if (parsed.t === 'd' && parsed.text) {
248
- fullText += parsed.text
249
- writeEvent({ t: 'd', text: parsed.text, agentId: agent.id, agentName: agent.name })
250
- } else if (parsed.t === 'tool_call' || parsed.t === 'tool_result') {
251
- writeEvent({ ...parsed, agentId: agent.id, agentName: agent.name })
252
- } else if (parsed.t === 'err' && parsed.text) {
253
- agentError = parsed.text
254
- writeEvent({ t: 'err', text: parsed.text, agentId: agent.id, agentName: agent.name })
255
- }
256
- } catch {
257
- // skip malformed lines
235
+ const forwardProviderEvents = (raw: string) => {
236
+ const lines = raw.split('\n').filter(Boolean)
237
+ for (const line of lines) {
238
+ if (!line.startsWith('data: ')) continue
239
+ try {
240
+ const parsed = JSON.parse(line.slice(6).trim())
241
+ if (parsed.t === 'd' && parsed.text) {
242
+ fullText += parsed.text
243
+ writeEvent({ t: 'd', text: parsed.text, agentId: agent.id, agentName: agent.name })
244
+ } else if (parsed.t === 'tool_call' || parsed.t === 'tool_result') {
245
+ writeEvent({ ...parsed, agentId: agent.id, agentName: agent.name })
246
+ } else if (parsed.t === 'err' && parsed.text) {
247
+ agentError = parsed.text
248
+ writeEvent({ t: 'err', text: parsed.text, agentId: agent.id, agentName: agent.name })
258
249
  }
250
+ } catch {
251
+ // skip malformed lines
259
252
  }
260
- },
261
- history,
262
- })
253
+ }
254
+ }
263
255
 
264
- const rawResponseText = result.finalResponse || result.fullText || fullText
256
+ let rawResponseText = ''
257
+ if (NON_LANGGRAPH_PROVIDER_IDS.has(syntheticSession.provider)) {
258
+ const provider = getProvider(syntheticSession.provider)
259
+ if (!provider) throw new Error(`Unknown provider: ${syntheticSession.provider}`)
260
+ rawResponseText = await provider.handler.streamChat({
261
+ session: syntheticSession,
262
+ message: messageForAgent,
263
+ imagePath,
264
+ apiKey,
265
+ systemPrompt: fullSystemPrompt,
266
+ write: forwardProviderEvents,
267
+ active: new Map<string, unknown>(),
268
+ loadHistory: () => history,
269
+ })
270
+ if (!rawResponseText) rawResponseText = fullText
271
+ } else {
272
+ const result = await streamAgentChat({
273
+ session: syntheticSession,
274
+ message: messageForAgent,
275
+ imagePath,
276
+ attachedFiles,
277
+ apiKey,
278
+ systemPrompt: fullSystemPrompt,
279
+ write: forwardProviderEvents,
280
+ history,
281
+ })
282
+ rawResponseText = result.finalResponse || result.fullText || fullText
283
+ }
265
284
  const responseText = stripAgentReactionTokens(stripHiddenControlTokens(rawResponseText))
266
285
 
267
286
  // Don't persist empty or error-only messages — they pollute chat history
@@ -4,7 +4,6 @@ import { notify } from '@/lib/server/ws-hub'
4
4
  import { notFound } from '@/lib/server/collection-helpers'
5
5
  import { safeParseBody } from '@/lib/server/safe-parse-body'
6
6
  import { genId } from '@/lib/id'
7
- import { isWorkerOnlyAgent, buildWorkerOnlyAgentMessage } from '@/lib/server/agents/agent-availability'
8
7
 
9
8
  export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
10
9
  const { id } = await params
@@ -18,13 +17,6 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
18
17
  if (!agentId) return NextResponse.json({ error: 'agentId is required' }, { status: 400 })
19
18
 
20
19
  const agents = loadAgents()
21
- if (isWorkerOnlyAgent(agents[agentId])) {
22
- return NextResponse.json(
23
- { error: buildWorkerOnlyAgentMessage(agents[agentId], 'join chatrooms') },
24
- { status: 400 },
25
- )
26
- }
27
-
28
20
  if (!chatroom.agentIds.includes(agentId)) {
29
21
  chatroom.agentIds.push(agentId)
30
22
 
@@ -4,7 +4,6 @@ import { notify } from '@/lib/server/ws-hub'
4
4
  import { notFound } from '@/lib/server/collection-helpers'
5
5
  import { safeParseBody } from '@/lib/server/safe-parse-body'
6
6
  import { genId } from '@/lib/id'
7
- import { isWorkerOnlyAgent } from '@/lib/server/agents/agent-availability'
8
7
  import {
9
8
  ensureChatroomRoutingGuidance,
10
9
  synthesizeRoutingGuidanceFromRules,
@@ -78,16 +77,6 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
78
77
  { status: 400 },
79
78
  )
80
79
  }
81
- const cliAgentNames = agentIds
82
- .filter((agentId) => isWorkerOnlyAgent(agents[agentId]))
83
- .map((agentId) => agents[agentId]?.name || agentId)
84
- if (cliAgentNames.length > 0) {
85
- return NextResponse.json(
86
- { error: `CLI-based agents cannot join chatrooms: ${cliAgentNames.join(', ')}. They can only be used for direct chats and delegation.` },
87
- { status: 400 },
88
- )
89
- }
90
-
91
80
  const oldIds = new Set(chatroom.agentIds)
92
81
  const newIds = new Set(agentIds)
93
82
  const added = agentIds.filter((aid: string) => !oldIds.has(aid))
@@ -6,7 +6,6 @@ import { ChatroomCreateSchema, formatZodError } from '@/lib/validation/schemas'
6
6
  import { safeParseBody } from '@/lib/server/safe-parse-body'
7
7
  import { z } from 'zod'
8
8
  import type { Chatroom, ChatroomMessage } from '@/types'
9
- import { isWorkerOnlyAgent } from '@/lib/server/agents/agent-availability'
10
9
  import {
11
10
  ensureChatroomRoutingGuidance,
12
11
  synthesizeRoutingGuidanceFromRules,
@@ -61,15 +60,6 @@ export async function POST(req: Request) {
61
60
  { status: 400 },
62
61
  )
63
62
  }
64
- const cliAgentNames = requestedAgentIds
65
- .filter((agentId) => isWorkerOnlyAgent(knownAgents[agentId]))
66
- .map((agentId) => knownAgents[agentId]?.name || agentId)
67
- if (cliAgentNames.length > 0) {
68
- return NextResponse.json(
69
- { error: `CLI-based agents cannot join chatrooms: ${cliAgentNames.join(', ')}. They can only be used for direct chats and delegation.` },
70
- { status: 400 },
71
- )
72
- }
73
63
  const agentIds: string[] = requestedAgentIds
74
64
  const chatMode = body.chatMode === 'parallel' ? 'parallel' : 'sequential'
75
65
  const autoAddress = Boolean(body.autoAddress)
@@ -1,9 +1,13 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { clearChatMessages } from '@/lib/server/chats/chat-session-service'
2
+ import { clearChatMessagesWithUndo } from '@/lib/server/chats/chat-session-service'
3
3
  import { notFound } from '@/lib/server/collection-helpers'
4
4
 
5
5
  export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
6
6
  const { id } = await params
7
- if (!clearChatMessages(id)) return notFound()
8
- return new NextResponse('OK')
7
+ const result = clearChatMessagesWithUndo(id)
8
+ if (!result.ok) {
9
+ if (result.status === 404) return notFound()
10
+ return NextResponse.json(result.payload, { status: result.status })
11
+ }
12
+ return NextResponse.json(result.payload)
9
13
  }
@@ -0,0 +1,23 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { restoreChatFromUndoToken } from '@/lib/server/chats/chat-session-service'
4
+ import { badRequest, notFound } from '@/lib/server/collection-helpers'
5
+ import { safeParseBody } from '@/lib/server/safe-parse-body'
6
+
7
+ const BodySchema = z.object({
8
+ undoToken: z.string().min(1),
9
+ })
10
+
11
+ export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
12
+ const { id } = await params
13
+ const parsed = await safeParseBody(req, BodySchema)
14
+ if (parsed.error) return parsed.error
15
+ const token = parsed.data.undoToken.trim()
16
+ if (!token) return badRequest('undoToken is required')
17
+ const result = restoreChatFromUndoToken(id, token)
18
+ if (!result.ok) {
19
+ if (result.status === 404) return notFound(result.payload.error)
20
+ return NextResponse.json(result.payload, { status: result.status })
21
+ }
22
+ return NextResponse.json(result.payload)
23
+ }
@@ -0,0 +1,72 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { HumanMessage } from '@langchain/core/messages'
4
+ import { getSession } from '@/lib/server/sessions/session-repository'
5
+ import { getMessages, replaceAllMessages } from '@/lib/server/messages/message-repository'
6
+ import { summarizeAndCompact, type LLMSummarizer } from '@/lib/server/context-manager'
7
+ import { buildChatModel } from '@/lib/server/build-llm'
8
+ import { notFound } from '@/lib/server/collection-helpers'
9
+ import { safeParseBody } from '@/lib/server/safe-parse-body'
10
+ import { errorMessage } from '@/lib/shared-utils'
11
+
12
+ const BodySchema = z.object({
13
+ keepLastN: z.number().int().min(2).max(200).optional(),
14
+ }).partial()
15
+
16
+ const DEFAULT_KEEP_LAST_N = 10
17
+
18
+ export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
19
+ const { id } = await params
20
+ const session = getSession(id)
21
+ if (!session) return notFound()
22
+
23
+ const parsed = await safeParseBody(req, BodySchema)
24
+ if (parsed.error) return parsed.error
25
+ const keepLastN = Math.max(2, Math.min(parsed.data.keepLastN ?? DEFAULT_KEEP_LAST_N, 200))
26
+
27
+ const messages = getMessages(id)
28
+ if (messages.length <= keepLastN) {
29
+ return NextResponse.json({
30
+ status: 'no_action',
31
+ messageCount: messages.length,
32
+ keepLastN,
33
+ })
34
+ }
35
+
36
+ const generateSummary: LLMSummarizer = async (prompt) => {
37
+ const llm = buildChatModel({
38
+ provider: session.provider,
39
+ model: session.model,
40
+ apiKey: null,
41
+ credentialId: session.credentialId ?? null,
42
+ apiEndpoint: session.apiEndpoint ?? null,
43
+ })
44
+ const res = await llm.invoke([new HumanMessage(prompt)])
45
+ return typeof res.content === 'string' ? res.content : ''
46
+ }
47
+
48
+ try {
49
+ const result = await summarizeAndCompact({
50
+ messages,
51
+ keepLastN,
52
+ agentId: session.agentId ?? null,
53
+ sessionId: id,
54
+ provider: session.provider as string,
55
+ model: session.model as string,
56
+ generateSummary,
57
+ })
58
+ replaceAllMessages(id, result.messages)
59
+ return NextResponse.json({
60
+ status: 'compacted',
61
+ prunedCount: result.prunedCount,
62
+ memoriesStored: result.memoriesStored,
63
+ summaryAdded: result.summaryAdded,
64
+ messageCount: result.messages.length,
65
+ })
66
+ } catch (err: unknown) {
67
+ return NextResponse.json(
68
+ { error: `Compaction failed: ${errorMessage(err)}` },
69
+ { status: 500 },
70
+ )
71
+ }
72
+ }
@@ -0,0 +1,21 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { getSession } from '@/lib/server/sessions/session-repository'
3
+ import { getMessages } from '@/lib/server/messages/message-repository'
4
+ import { getContextStatus } from '@/lib/server/context-manager'
5
+ import { notFound } from '@/lib/server/collection-helpers'
6
+
7
+ const SYSTEM_PROMPT_TOKEN_ESTIMATE = 2000
8
+
9
+ export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
10
+ const { id } = await params
11
+ const session = getSession(id)
12
+ if (!session) return notFound()
13
+ const messages = getMessages(id)
14
+ const status = getContextStatus(
15
+ messages,
16
+ SYSTEM_PROMPT_TOKEN_ESTIMATE,
17
+ session.provider as string,
18
+ session.model as string,
19
+ )
20
+ return NextResponse.json(status)
21
+ }
@@ -0,0 +1,121 @@
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
+ test('chat clear + undo round-trip restores messages and CLI session IDs', () => {
7
+ const output = runWithTempDataDir<{
8
+ clearStatus: number
9
+ clearPayload: { cleared: number; undoToken: string }
10
+ postClearCount: number
11
+ postClearClaudeSessionId: string | null
12
+ postClearOpencodeWebSessionId: string | null
13
+ undoStatus: number
14
+ undoPayload: { restored: number }
15
+ postUndoCount: number
16
+ postUndoClaudeSessionId: string | null
17
+ postUndoOpencodeWebSessionId: string | null
18
+ undoTwiceStatus: number
19
+ undoTwicePayload: { error?: string }
20
+ missingSessionStatus: number
21
+ }>(`
22
+ const storageMod = await import('./src/lib/server/storage')
23
+ const repoMod = await import('@/lib/server/messages/message-repository')
24
+ const clearRouteMod = await import('./src/app/api/chats/[id]/clear/route')
25
+ const undoRouteMod = await import('./src/app/api/chats/[id]/clear/undo/route')
26
+ const storage = storageMod.default || storageMod
27
+ const repo = repoMod.default || repoMod
28
+ const clearRoute = clearRouteMod.default || clearRouteMod
29
+ const undoRoute = undoRouteMod.default || undoRouteMod
30
+
31
+ const now = Date.now()
32
+ storage.saveSessions({
33
+ sess_clear_1: {
34
+ id: 'sess_clear_1',
35
+ name: 'Clear test',
36
+ cwd: process.env.WORKSPACE_DIR,
37
+ user: 'tester',
38
+ provider: 'openai',
39
+ model: 'gpt-5',
40
+ claudeSessionId: 'cs_preserved_abc',
41
+ codexThreadId: null,
42
+ opencodeWebSessionId: 'owb_preserved_xyz',
43
+ messages: [],
44
+ createdAt: now,
45
+ lastActiveAt: now,
46
+ },
47
+ })
48
+
49
+ repo.appendMessage('sess_clear_1', { role: 'user', text: 'first', time: now })
50
+ repo.appendMessage('sess_clear_1', { role: 'assistant', text: 'reply', time: now + 1 })
51
+ repo.appendMessage('sess_clear_1', { role: 'user', text: 'second', time: now + 2 })
52
+
53
+ const clearResponse = await clearRoute.POST(
54
+ new Request('http://local/api/chats/sess_clear_1/clear', { method: 'POST' }),
55
+ { params: Promise.resolve({ id: 'sess_clear_1' }) },
56
+ )
57
+ const clearPayload = await clearResponse.json()
58
+
59
+ const postClearCount = repo.getMessages('sess_clear_1').length
60
+ const postClearSession = storage.loadSession('sess_clear_1')
61
+
62
+ const undoResponse = await undoRoute.POST(
63
+ new Request('http://local/api/chats/sess_clear_1/clear/undo', {
64
+ method: 'POST',
65
+ headers: { 'content-type': 'application/json' },
66
+ body: JSON.stringify({ undoToken: clearPayload.undoToken }),
67
+ }),
68
+ { params: Promise.resolve({ id: 'sess_clear_1' }) },
69
+ )
70
+ const undoPayload = await undoResponse.json()
71
+
72
+ const postUndoCount = repo.getMessages('sess_clear_1').length
73
+ const postUndoSession = storage.loadSession('sess_clear_1')
74
+
75
+ const undoTwiceResponse = await undoRoute.POST(
76
+ new Request('http://local/api/chats/sess_clear_1/clear/undo', {
77
+ method: 'POST',
78
+ headers: { 'content-type': 'application/json' },
79
+ body: JSON.stringify({ undoToken: clearPayload.undoToken }),
80
+ }),
81
+ { params: Promise.resolve({ id: 'sess_clear_1' }) },
82
+ )
83
+ const undoTwicePayload = await undoTwiceResponse.json()
84
+
85
+ const missingResponse = await clearRoute.POST(
86
+ new Request('http://local/api/chats/not_a_real_session/clear', { method: 'POST' }),
87
+ { params: Promise.resolve({ id: 'not_a_real_session' }) },
88
+ )
89
+
90
+ console.log(JSON.stringify({
91
+ clearStatus: clearResponse.status,
92
+ clearPayload: { cleared: clearPayload.cleared, undoToken: clearPayload.undoToken },
93
+ postClearCount,
94
+ postClearClaudeSessionId: postClearSession?.claudeSessionId ?? null,
95
+ postClearOpencodeWebSessionId: postClearSession?.opencodeWebSessionId ?? null,
96
+ undoStatus: undoResponse.status,
97
+ undoPayload,
98
+ postUndoCount,
99
+ postUndoClaudeSessionId: postUndoSession?.claudeSessionId ?? null,
100
+ postUndoOpencodeWebSessionId: postUndoSession?.opencodeWebSessionId ?? null,
101
+ undoTwiceStatus: undoTwiceResponse.status,
102
+ undoTwicePayload,
103
+ missingSessionStatus: missingResponse.status,
104
+ }))
105
+ `, { prefix: 'swarmclaw-clear-undo-route-' })
106
+
107
+ assert.equal(output.clearStatus, 200)
108
+ assert.equal(output.clearPayload.cleared, 3)
109
+ assert.match(output.clearPayload.undoToken, /^undo_/)
110
+ assert.equal(output.postClearCount, 0)
111
+ assert.equal(output.postClearClaudeSessionId, null, 'CLI session should be nulled after clear')
112
+ assert.equal(output.postClearOpencodeWebSessionId, null, 'opencode-web session should be nulled after clear')
113
+ assert.equal(output.undoStatus, 200)
114
+ assert.equal(output.undoPayload.restored, 3)
115
+ assert.equal(output.postUndoCount, 3)
116
+ assert.equal(output.postUndoClaudeSessionId, 'cs_preserved_abc', 'CLI session ID should be restored by undo')
117
+ assert.equal(output.postUndoOpencodeWebSessionId, 'owb_preserved_xyz', 'opencode-web session ID should be restored by undo')
118
+ assert.equal(output.undoTwiceStatus, 404, 'undo token should be single-use')
119
+ assert.ok(output.undoTwicePayload.error)
120
+ assert.equal(output.missingSessionStatus, 404)
121
+ })