@swarmclawai/swarmclaw 1.5.63 → 1.5.65
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 +28 -0
- package/package.json +2 -2
- package/src/app/api/chats/[id]/clear/route.ts +7 -3
- package/src/app/api/chats/[id]/clear/undo/route.ts +23 -0
- package/src/app/api/chats/[id]/compact/route.ts +72 -0
- package/src/app/api/chats/[id]/context-status/route.ts +21 -0
- package/src/app/api/chats/clear-route.test.ts +121 -0
- package/src/app/api/chats/compact-route.test.ts +70 -0
- package/src/app/api/chats/context-status-route.test.ts +68 -0
- package/src/app/api/mcp-registry/[slug]/route.ts +31 -0
- package/src/app/api/mcp-registry/route.ts +36 -0
- package/src/app/api/mcp-servers/[id]/route.ts +5 -0
- package/src/app/api/mcp-servers/[id]/test/route.ts +12 -1
- package/src/app/api/mcp-servers/[id]/tools-info/route.ts +75 -0
- package/src/app/api/mcp-servers/route.test.ts +10 -0
- package/src/cli/index.js +13 -1
- package/src/cli/spec.js +4 -1
- package/src/components/chat/chat-area.tsx +62 -6
- package/src/components/chat/chat-header.tsx +13 -1
- package/src/components/chat/context-meter-badge.tsx +227 -0
- package/src/components/mcp-servers/mcp-server-list.tsx +57 -1
- package/src/components/mcp-servers/mcp-server-sheet.tsx +202 -1
- package/src/components/mcp-servers/registry-browser.tsx +219 -0
- package/src/lib/chat/chats.ts +37 -1
- package/src/lib/server/chats/chat-session-service.ts +75 -0
- package/src/lib/server/chats/clear-undo-snapshots.test.ts +107 -0
- package/src/lib/server/chats/clear-undo-snapshots.ts +92 -0
- package/src/lib/server/mcp-connection-pool.test.ts +127 -0
- package/src/lib/server/mcp-connection-pool.ts +150 -0
- package/src/lib/server/mcp-gateway-runtime.test.ts +177 -0
- package/src/lib/server/mcp-gateway-runtime.ts +138 -0
- package/src/lib/server/session-tools/index.ts +114 -15
- package/src/lib/server/storage-normalization.ts +11 -0
- package/src/types/agent.ts +1 -0
- package/src/types/misc.ts +7 -0
package/README.md
CHANGED
|
@@ -399,6 +399,34 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
399
399
|
|
|
400
400
|
## Releases
|
|
401
401
|
|
|
402
|
+
### v1.5.65 Highlights
|
|
403
|
+
|
|
404
|
+
Follow-up hardening on the v1.5.64 work after live-testing the chat-header flows, the MCP connection pool, and the MCP Registry browser. Six concrete bugs fixed in the clear/undo, MCP pool eviction, and registry-browser code paths.
|
|
405
|
+
|
|
406
|
+
- **`clearChatMessages` now resets `opencodeWebSessionId` too.** The snapshot/undo pair already captured and restored it, but `clear` itself left the stale identifier in place — so a fresh opencode-web turn would resume the conversation the user intended to drop. Paired with a matching default in `storage-normalization.ts` so older session records load with `opencodeWebSessionId: null` instead of `undefined`. Regression covered by `clear-route.test.ts`.
|
|
407
|
+
- **Undo toast no longer writes to the wrong chat.** If the user navigated away after clicking Clear, clicking Undo in the toast would inject restored messages into whatever chat was currently open. `chat-area.tsx` now gates the `setMessages` calls on `selectActiveSessionId === targetSessionId`; same guard added to the compact-complete path.
|
|
408
|
+
- **Background MCP status probes no longer evict the connection pool.** Visiting `/mcp-servers` auto-called `POST /api/mcp-servers/:id/test` for every server, which force-disconnected pooled clients that running agents were using mid-turn. Eviction is now gated behind `?reset=1`, which only the explicit **Re-test** button sends. Regression added to `src/app/api/mcp-servers/route.test.ts`.
|
|
409
|
+
- **SwarmDock MCP Registry browser actually works now.** The upstream `swarmdock-api.onrender.com` endpoint emits no CORS headers, so the in-browser `RegistryBrowser` component always failed with `Failed to fetch`. Added `GET /api/mcp-registry` and `GET /api/mcp-registry/:slug` as server-side proxies and rewired the component to call them. Verified in Chrome: 20 servers load, selecting one prefills the New MCP Server sheet with its recommended install command.
|
|
410
|
+
- **`mcp-registry` CLI group.** New commands `swarmclaw mcp-registry search` and `swarmclaw mcp-registry get <slug>` so CLI workflows can pull from the same proxy.
|
|
411
|
+
- **Prior release's MCP tool-evict-on-transport-failure fix** (cherry-picked from user's local branch): connection-class errors from downstream MCP tools now evict the pool entry for the originating server, so the next turn reconnects fresh instead of retrying through a half-broken transport.
|
|
412
|
+
|
|
413
|
+
### v1.5.64 Highlights
|
|
414
|
+
|
|
415
|
+
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.
|
|
416
|
+
|
|
417
|
+
- **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.
|
|
418
|
+
- **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.
|
|
419
|
+
- **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.
|
|
420
|
+
- **`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.
|
|
421
|
+
- **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.
|
|
422
|
+
- **`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.
|
|
423
|
+
- **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.
|
|
424
|
+
- **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.
|
|
425
|
+
- **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.
|
|
426
|
+
- **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.
|
|
427
|
+
- **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.
|
|
428
|
+
- **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.
|
|
429
|
+
|
|
402
430
|
### v1.5.63 Highlights
|
|
403
431
|
|
|
404
432
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.65",
|
|
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",
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
-
import {
|
|
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
|
-
|
|
8
|
-
|
|
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
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
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('POST /api/chats/[id]/compact returns no_action when transcript is smaller than keepLastN', () => {
|
|
7
|
+
const output = runWithTempDataDir<{
|
|
8
|
+
status: number
|
|
9
|
+
payload: { status?: string; messageCount?: number; keepLastN?: number }
|
|
10
|
+
missingStatus: number
|
|
11
|
+
}>(`
|
|
12
|
+
const storageMod = await import('./src/lib/server/storage')
|
|
13
|
+
const repoMod = await import('@/lib/server/messages/message-repository')
|
|
14
|
+
const routeMod = await import('./src/app/api/chats/[id]/compact/route')
|
|
15
|
+
const storage = storageMod.default || storageMod
|
|
16
|
+
const repo = repoMod.default || repoMod
|
|
17
|
+
const route = routeMod.default || routeMod
|
|
18
|
+
|
|
19
|
+
const now = Date.now()
|
|
20
|
+
storage.saveSessions({
|
|
21
|
+
sess_compact_1: {
|
|
22
|
+
id: 'sess_compact_1',
|
|
23
|
+
name: 'Compact test',
|
|
24
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
25
|
+
user: 'tester',
|
|
26
|
+
provider: 'openai',
|
|
27
|
+
model: 'gpt-4o-mini',
|
|
28
|
+
claudeSessionId: null,
|
|
29
|
+
messages: [],
|
|
30
|
+
createdAt: now,
|
|
31
|
+
lastActiveAt: now,
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Only 2 messages — smaller than default keepLastN (10), so compact should no-op.
|
|
36
|
+
repo.appendMessage('sess_compact_1', { role: 'user', text: 'hi', time: now })
|
|
37
|
+
repo.appendMessage('sess_compact_1', { role: 'assistant', text: 'hello', time: now + 1 })
|
|
38
|
+
|
|
39
|
+
const response = await route.POST(
|
|
40
|
+
new Request('http://local/api/chats/sess_compact_1/compact', {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'content-type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({}),
|
|
44
|
+
}),
|
|
45
|
+
{ params: Promise.resolve({ id: 'sess_compact_1' }) },
|
|
46
|
+
)
|
|
47
|
+
const payload = await response.json()
|
|
48
|
+
|
|
49
|
+
const missingResponse = await route.POST(
|
|
50
|
+
new Request('http://local/api/chats/missing/compact', {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'content-type': 'application/json' },
|
|
53
|
+
body: JSON.stringify({}),
|
|
54
|
+
}),
|
|
55
|
+
{ params: Promise.resolve({ id: 'missing' }) },
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
console.log(JSON.stringify({
|
|
59
|
+
status: response.status,
|
|
60
|
+
payload,
|
|
61
|
+
missingStatus: missingResponse.status,
|
|
62
|
+
}))
|
|
63
|
+
`, { prefix: 'swarmclaw-compact-route-' })
|
|
64
|
+
|
|
65
|
+
assert.equal(output.status, 200)
|
|
66
|
+
assert.equal(output.payload.status, 'no_action')
|
|
67
|
+
assert.equal(output.payload.messageCount, 2)
|
|
68
|
+
assert.equal(output.payload.keepLastN, 10)
|
|
69
|
+
assert.equal(output.missingStatus, 404)
|
|
70
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
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('GET /api/chats/[id]/context-status returns token usage summary', () => {
|
|
7
|
+
const output = runWithTempDataDir<{
|
|
8
|
+
status: number
|
|
9
|
+
hasContextWindow: boolean
|
|
10
|
+
percentUsed: number
|
|
11
|
+
messageCount: number
|
|
12
|
+
strategy: string
|
|
13
|
+
missingStatus: number
|
|
14
|
+
}>(`
|
|
15
|
+
const storageMod = await import('./src/lib/server/storage')
|
|
16
|
+
const repoMod = await import('@/lib/server/messages/message-repository')
|
|
17
|
+
const routeMod = await import('./src/app/api/chats/[id]/context-status/route')
|
|
18
|
+
const storage = storageMod.default || storageMod
|
|
19
|
+
const repo = repoMod.default || repoMod
|
|
20
|
+
const route = routeMod.default || routeMod
|
|
21
|
+
|
|
22
|
+
const now = Date.now()
|
|
23
|
+
storage.saveSessions({
|
|
24
|
+
sess_ctx_1: {
|
|
25
|
+
id: 'sess_ctx_1',
|
|
26
|
+
name: 'Context status test',
|
|
27
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
28
|
+
user: 'tester',
|
|
29
|
+
provider: 'openai',
|
|
30
|
+
model: 'gpt-4o-mini',
|
|
31
|
+
claudeSessionId: null,
|
|
32
|
+
messages: [],
|
|
33
|
+
createdAt: now,
|
|
34
|
+
lastActiveAt: now,
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
repo.appendMessage('sess_ctx_1', { role: 'user', text: 'hello world', time: now })
|
|
39
|
+
repo.appendMessage('sess_ctx_1', { role: 'assistant', text: 'hi there, how can I help?', time: now + 1 })
|
|
40
|
+
|
|
41
|
+
const response = await route.GET(
|
|
42
|
+
new Request('http://local/api/chats/sess_ctx_1/context-status'),
|
|
43
|
+
{ params: Promise.resolve({ id: 'sess_ctx_1' }) },
|
|
44
|
+
)
|
|
45
|
+
const payload = await response.json()
|
|
46
|
+
|
|
47
|
+
const missingResponse = await route.GET(
|
|
48
|
+
new Request('http://local/api/chats/missing/context-status'),
|
|
49
|
+
{ params: Promise.resolve({ id: 'missing' }) },
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
console.log(JSON.stringify({
|
|
53
|
+
status: response.status,
|
|
54
|
+
hasContextWindow: typeof payload.contextWindow === 'number' && payload.contextWindow > 0,
|
|
55
|
+
percentUsed: payload.percentUsed,
|
|
56
|
+
messageCount: payload.messageCount,
|
|
57
|
+
strategy: payload.strategy,
|
|
58
|
+
missingStatus: missingResponse.status,
|
|
59
|
+
}))
|
|
60
|
+
`, { prefix: 'swarmclaw-context-status-route-' })
|
|
61
|
+
|
|
62
|
+
assert.equal(output.status, 200)
|
|
63
|
+
assert.equal(output.hasContextWindow, true)
|
|
64
|
+
assert.ok(output.percentUsed >= 0 && output.percentUsed <= 100)
|
|
65
|
+
assert.equal(output.messageCount, 2)
|
|
66
|
+
assert.ok(['ok', 'warning', 'critical'].includes(output.strategy))
|
|
67
|
+
assert.equal(output.missingStatus, 404)
|
|
68
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
|
|
3
|
+
const REGISTRY_API = 'https://swarmdock-api.onrender.com/api/v1/mcp/servers'
|
|
4
|
+
|
|
5
|
+
export async function GET(_req: Request, { params }: { params: Promise<{ slug: string }> }) {
|
|
6
|
+
const { slug } = await params
|
|
7
|
+
if (!slug.trim()) {
|
|
8
|
+
return NextResponse.json({ error: 'slug is required' }, { status: 400 })
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
const upstream = await fetch(`${REGISTRY_API}/${encodeURIComponent(slug)}`, {
|
|
12
|
+
headers: { accept: 'application/json' },
|
|
13
|
+
})
|
|
14
|
+
if (upstream.status === 404) {
|
|
15
|
+
return NextResponse.json({ error: 'Registry server not found' }, { status: 404 })
|
|
16
|
+
}
|
|
17
|
+
if (!upstream.ok) {
|
|
18
|
+
return NextResponse.json(
|
|
19
|
+
{ error: `Server detail returned ${upstream.status}` },
|
|
20
|
+
{ status: 502 },
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
const data = await upstream.json()
|
|
24
|
+
return NextResponse.json(data)
|
|
25
|
+
} catch (err: unknown) {
|
|
26
|
+
return NextResponse.json(
|
|
27
|
+
{ error: err instanceof Error ? err.message : 'Registry unreachable' },
|
|
28
|
+
{ status: 502 },
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
|
|
3
|
+
// Server-side proxy for the public SwarmDock MCP Registry. The upstream API
|
|
4
|
+
// does not emit CORS headers, so the RegistryBrowser component in the browser
|
|
5
|
+
// cannot fetch it directly. This route forwards the search request and its
|
|
6
|
+
// JSON response untouched.
|
|
7
|
+
|
|
8
|
+
const REGISTRY_API = 'https://swarmdock-api.onrender.com/api/v1/mcp/servers'
|
|
9
|
+
|
|
10
|
+
export async function GET(req: Request) {
|
|
11
|
+
const url = new URL(req.url)
|
|
12
|
+
const q = url.searchParams.get('q') ?? ''
|
|
13
|
+
const limitRaw = url.searchParams.get('limit') ?? '20'
|
|
14
|
+
const limit = Math.max(1, Math.min(Number.parseInt(limitRaw, 10) || 20, 50))
|
|
15
|
+
const qs = new URLSearchParams({ limit: String(limit) })
|
|
16
|
+
if (q.trim()) qs.set('q', q.trim())
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const upstream = await fetch(`${REGISTRY_API}?${qs.toString()}`, {
|
|
20
|
+
headers: { accept: 'application/json' },
|
|
21
|
+
})
|
|
22
|
+
if (!upstream.ok) {
|
|
23
|
+
return NextResponse.json(
|
|
24
|
+
{ error: `Registry returned ${upstream.status}` },
|
|
25
|
+
{ status: 502 },
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
const data = await upstream.json()
|
|
29
|
+
return NextResponse.json(data)
|
|
30
|
+
} catch (err: unknown) {
|
|
31
|
+
return NextResponse.json(
|
|
32
|
+
{ error: err instanceof Error ? err.message : 'Registry unreachable' },
|
|
33
|
+
{ status: 502 },
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
3
3
|
import { loadMcpServers, saveMcpServers, deleteMcpServer } from '@/lib/server/storage'
|
|
4
4
|
import { mutateItem, deleteItem, notFound, type CollectionOps } from '@/lib/server/collection-helpers'
|
|
5
|
+
import { evictMcpClient } from '@/lib/server/mcp-connection-pool'
|
|
5
6
|
|
|
6
7
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
8
|
const ops: CollectionOps<any> = { load: loadMcpServers, save: saveMcpServers, deleteFn: deleteMcpServer }
|
|
@@ -21,11 +22,15 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
|
|
|
21
22
|
...server, ...body, id, updatedAt: Date.now(),
|
|
22
23
|
}))
|
|
23
24
|
if (!result) return notFound()
|
|
25
|
+
// Connection pool caches by config fingerprint; evicting here is defense in
|
|
26
|
+
// depth — getOrConnectMcpClient also detects fingerprint mismatches.
|
|
27
|
+
await evictMcpClient(id)
|
|
24
28
|
return NextResponse.json(result)
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
28
32
|
const { id } = await params
|
|
29
33
|
if (!deleteItem(ops, id)) return notFound()
|
|
34
|
+
await evictMcpClient(id)
|
|
30
35
|
return NextResponse.json({ deleted: id })
|
|
31
36
|
}
|
|
@@ -2,14 +2,25 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { loadMcpServers } from '@/lib/server/storage'
|
|
3
3
|
import { notFound } from '@/lib/server/collection-helpers'
|
|
4
4
|
import { connectMcpServer, mcpToolsToLangChain, disconnectMcpServer } from '@/lib/server/mcp-client'
|
|
5
|
+
import { evictMcpClient } from '@/lib/server/mcp-connection-pool'
|
|
5
6
|
import { errorMessage } from '@/lib/shared-utils'
|
|
6
7
|
|
|
7
|
-
export async function POST(
|
|
8
|
+
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
8
9
|
const { id } = await params
|
|
9
10
|
const servers = loadMcpServers()
|
|
10
11
|
const server = servers[id]
|
|
11
12
|
if (!server) return notFound()
|
|
12
13
|
|
|
14
|
+
// Only evict the pool when the caller explicitly asks for a reset (e.g. the
|
|
15
|
+
// "Re-test" button). Background probes from the server list view skip this
|
|
16
|
+
// so they don't disconnect pooled clients that running agents are using
|
|
17
|
+
// mid-turn. Pool eviction on config change is handled by the PUT route.
|
|
18
|
+
const url = new URL(req.url)
|
|
19
|
+
const reset = url.searchParams.get('reset') === '1' || url.searchParams.get('reset') === 'true'
|
|
20
|
+
if (reset) {
|
|
21
|
+
await evictMcpClient(id)
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
try {
|
|
14
25
|
const { client, transport } = await connectMcpServer(server)
|
|
15
26
|
const tools = await mcpToolsToLangChain(client, server.name)
|