fix: dynamic coordinator routing + boxed doctor parsing (#279)
* fix: route coordinator sends to live sessions and parse boxed doctor output * feat: make coordinator routing user-configurable and deployment-agnostic * feat: add coordinator target dropdown in settings * feat(settings): preview live coordinator routing resolution
This commit is contained in:
parent
f5551a2c93
commit
84e197b3dc
|
|
@ -7,6 +7,7 @@ import { requireRole } from '@/lib/auth'
|
||||||
import { logger } from '@/lib/logger'
|
import { logger } from '@/lib/logger'
|
||||||
import { scanForInjection, sanitizeForPrompt } from '@/lib/injection-guard'
|
import { scanForInjection, sanitizeForPrompt } from '@/lib/injection-guard'
|
||||||
import { callOpenClawGateway } from '@/lib/openclaw-gateway'
|
import { callOpenClawGateway } from '@/lib/openclaw-gateway'
|
||||||
|
import { resolveCoordinatorDeliveryTarget } from '@/lib/coordinator-routing'
|
||||||
|
|
||||||
type ForwardInfo = {
|
type ForwardInfo = {
|
||||||
attempted: boolean
|
attempted: boolean
|
||||||
|
|
@ -415,35 +416,54 @@ export async function POST(request: NextRequest) {
|
||||||
.prepare('SELECT * FROM agents WHERE lower(name) = lower(?) AND workspace_id = ?')
|
.prepare('SELECT * FROM agents WHERE lower(name) = lower(?) AND workspace_id = ?')
|
||||||
.get(to, workspaceId) as any
|
.get(to, workspaceId) as any
|
||||||
|
|
||||||
// Use explicit session key from caller if provided, then DB, then on-disk lookup
|
const explicitSessionKey = typeof body.sessionKey === 'string' && body.sessionKey
|
||||||
let sessionKey: string | null = typeof body.sessionKey === 'string' && body.sessionKey
|
|
||||||
? body.sessionKey
|
? body.sessionKey
|
||||||
: agent?.session_key || null
|
: null
|
||||||
|
const sessions = getAllGatewaySessions()
|
||||||
|
const isCoordinatorSend = String(to).toLowerCase() === COORDINATOR_AGENT.toLowerCase()
|
||||||
|
const allAgents = isCoordinatorSend
|
||||||
|
? (db
|
||||||
|
.prepare('SELECT name, session_key, config FROM agents WHERE workspace_id = ?')
|
||||||
|
.all(workspaceId) as Array<{ name: string; session_key?: string | null; config?: string | null }>)
|
||||||
|
: []
|
||||||
|
const configuredCoordinatorTarget = isCoordinatorSend
|
||||||
|
? (db
|
||||||
|
.prepare("SELECT value FROM settings WHERE key = 'chat.coordinator_target_agent'")
|
||||||
|
.get() as { value?: string } | undefined)?.value || null
|
||||||
|
: null
|
||||||
|
|
||||||
|
const coordinatorResolution = resolveCoordinatorDeliveryTarget({
|
||||||
|
to: String(to),
|
||||||
|
coordinatorAgent: COORDINATOR_AGENT,
|
||||||
|
directAgent: agent
|
||||||
|
? {
|
||||||
|
name: String(agent.name || to),
|
||||||
|
session_key: typeof agent.session_key === 'string' ? agent.session_key : null,
|
||||||
|
config: typeof agent.config === 'string' ? agent.config : null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
allAgents,
|
||||||
|
sessions,
|
||||||
|
explicitSessionKey,
|
||||||
|
configuredCoordinatorTarget,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use explicit session key from caller if provided, then DB, then on-disk lookup
|
||||||
|
let sessionKey: string | null = coordinatorResolution.sessionKey
|
||||||
|
|
||||||
// Fallback: derive session from on-disk gateway session stores
|
// Fallback: derive session from on-disk gateway session stores
|
||||||
if (!sessionKey) {
|
if (!sessionKey) {
|
||||||
const sessions = getAllGatewaySessions()
|
|
||||||
const match = sessions.find(
|
const match = sessions.find(
|
||||||
(s) => s.agent.toLowerCase() === String(to).toLowerCase()
|
(s) =>
|
||||||
|
s.agent.toLowerCase() === String(to).toLowerCase() ||
|
||||||
|
s.agent.toLowerCase() === coordinatorResolution.deliveryName.toLowerCase() ||
|
||||||
|
s.agent.toLowerCase() === String(coordinatorResolution.openclawAgentId || '').toLowerCase()
|
||||||
)
|
)
|
||||||
sessionKey = match?.key || match?.sessionId || null
|
sessionKey = match?.key || match?.sessionId || null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer configured openclawId when present, fallback to normalized name
|
// Prefer configured openclawId when present, fallback to normalized name
|
||||||
let openclawAgentId: string | null = null
|
let openclawAgentId: string | null = coordinatorResolution.openclawAgentId
|
||||||
if (agent?.config) {
|
|
||||||
try {
|
|
||||||
const cfg = JSON.parse(agent.config)
|
|
||||||
if (cfg?.openclawId && typeof cfg.openclawId === 'string') {
|
|
||||||
openclawAgentId = cfg.openclawId
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore parse issues
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!openclawAgentId && typeof to === 'string') {
|
|
||||||
openclawAgentId = to.toLowerCase().replace(/\s+/g, '-')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sessionKey && !openclawAgentId) {
|
if (!sessionKey && !openclawAgentId) {
|
||||||
forwardInfo.reason = 'no_active_session'
|
forwardInfo.reason = 'no_active_session'
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,13 @@ const settingDefinitions: Record<string, { category: string; description: string
|
||||||
'gateway.host': { category: 'gateway', description: 'Gateway hostname', default: config.gatewayHost },
|
'gateway.host': { category: 'gateway', description: 'Gateway hostname', default: config.gatewayHost },
|
||||||
'gateway.port': { category: 'gateway', description: 'Gateway port number', default: String(config.gatewayPort) },
|
'gateway.port': { category: 'gateway', description: 'Gateway port number', default: String(config.gatewayPort) },
|
||||||
|
|
||||||
|
// Chat
|
||||||
|
'chat.coordinator_target_agent': {
|
||||||
|
category: 'chat',
|
||||||
|
description: 'Optional coordinator routing target (agent name or openclawId). When set, coordinator inbox messages are forwarded to this agent before default/main-session fallback.',
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
|
||||||
// General
|
// General
|
||||||
'general.site_name': { category: 'general', description: 'Mission Control display name', default: 'Mission Control' },
|
'general.site_name': { category: 'general', description: 'Mission Control display name', default: 'Mission Control' },
|
||||||
'general.auto_cleanup': { category: 'general', description: 'Enable automatic data cleanup', default: 'false' },
|
'general.auto_cleanup': { category: 'general', description: 'Enable automatic data cleanup', default: 'false' },
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import { useNavigateToPanel } from '@/lib/navigation'
|
||||||
import { SecurityScanCard } from '@/components/onboarding/security-scan-card'
|
import { SecurityScanCard } from '@/components/onboarding/security-scan-card'
|
||||||
import { Loader } from '@/components/ui/loader'
|
import { Loader } from '@/components/ui/loader'
|
||||||
import { clearOnboardingDismissedThisSession, clearOnboardingReplayFromStart } from '@/lib/onboarding-session'
|
import { clearOnboardingDismissedThisSession, clearOnboardingReplayFromStart } from '@/lib/onboarding-session'
|
||||||
|
import { resolveCoordinatorDeliveryTarget, type CoordinatorAgentRecord } from '@/lib/coordinator-routing'
|
||||||
|
import type { GatewaySession } from '@/lib/sessions'
|
||||||
|
|
||||||
interface Setting {
|
interface Setting {
|
||||||
key: string
|
key: string
|
||||||
|
|
@ -25,16 +27,60 @@ interface ApiKeyInfo {
|
||||||
last_rotated_by: string | null
|
last_rotated_by: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CoordinatorTargetAgent {
|
||||||
|
name: string
|
||||||
|
openclawId: string
|
||||||
|
isDefault: boolean
|
||||||
|
sessionKey: string | null
|
||||||
|
configRaw: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoordinatorSession = GatewaySession & { source?: string }
|
||||||
|
|
||||||
|
const COORDINATOR_AGENT = (process.env.NEXT_PUBLIC_COORDINATOR_AGENT || 'coordinator').toLowerCase()
|
||||||
|
|
||||||
|
function parseCoordinatorTargetAgents(rawAgents: any[]): CoordinatorTargetAgent[] {
|
||||||
|
const out: CoordinatorTargetAgent[] = []
|
||||||
|
for (const raw of rawAgents || []) {
|
||||||
|
const name = typeof raw?.name === 'string' ? raw.name.trim() : ''
|
||||||
|
if (!name) continue
|
||||||
|
const config = raw?.config && typeof raw.config === 'object' ? raw.config : {}
|
||||||
|
const openclawIdRaw = typeof config.openclawId === 'string' && config.openclawId.trim()
|
||||||
|
? config.openclawId.trim()
|
||||||
|
: name
|
||||||
|
const openclawId = openclawIdRaw.toLowerCase().replace(/\s+/g, '-')
|
||||||
|
out.push({
|
||||||
|
name,
|
||||||
|
openclawId,
|
||||||
|
isDefault: config.isDefault === true,
|
||||||
|
sessionKey: typeof raw?.session_key === 'string' && raw.session_key.trim() ? raw.session_key.trim() : null,
|
||||||
|
configRaw: JSON.stringify(config),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const unique = new Map<string, CoordinatorTargetAgent>()
|
||||||
|
for (const agent of out) {
|
||||||
|
const key = agent.openclawId || agent.name.toLowerCase()
|
||||||
|
if (!unique.has(key)) unique.set(key, agent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(unique.values()).sort((a, b) => {
|
||||||
|
if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const categoryLabels: Record<string, { label: string; icon: string; description: string }> = {
|
const categoryLabels: Record<string, { label: string; icon: string; description: string }> = {
|
||||||
general: { label: 'General', icon: '⚙', description: 'Core Mission Control settings' },
|
general: { label: 'General', icon: '⚙', description: 'Core Mission Control settings' },
|
||||||
security: { label: 'Security', icon: '🔑', description: 'API key management and security settings' },
|
security: { label: 'Security', icon: '🔑', description: 'API key management and security settings' },
|
||||||
retention: { label: 'Data Retention', icon: '🗄', description: 'How long data is kept before cleanup' },
|
retention: { label: 'Data Retention', icon: '🗄', description: 'How long data is kept before cleanup' },
|
||||||
|
chat: { label: 'Chat', icon: '💬', description: 'Coordinator routing and chat behavior settings' },
|
||||||
gateway: { label: 'Gateway', icon: '🔌', description: 'OpenClaw gateway connection settings' },
|
gateway: { label: 'Gateway', icon: '🔌', description: 'OpenClaw gateway connection settings' },
|
||||||
profiles: { label: 'Security Profiles', icon: 'shield', description: 'Hook profile controls security scanning strictness' },
|
profiles: { label: 'Security Profiles', icon: 'shield', description: 'Hook profile controls security scanning strictness' },
|
||||||
custom: { label: 'Custom', icon: '🔧', description: 'User-defined settings' },
|
custom: { label: 'Custom', icon: '🔧', description: 'User-defined settings' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryOrder = ['general', 'security', 'profiles', 'retention', 'gateway', 'custom']
|
const categoryOrder = ['general', 'security', 'profiles', 'retention', 'chat', 'gateway', 'custom']
|
||||||
|
|
||||||
// Dropdown options for subscription plan settings
|
// Dropdown options for subscription plan settings
|
||||||
const subscriptionDropdowns: Record<string, { label: string; value: string }[]> = {
|
const subscriptionDropdowns: Record<string, { label: string; value: string }[]> = {
|
||||||
|
|
@ -79,6 +125,8 @@ export function SettingsPanel() {
|
||||||
const [showSecurityScan, setShowSecurityScan] = useState(false)
|
const [showSecurityScan, setShowSecurityScan] = useState(false)
|
||||||
const [hookProfile, setHookProfile] = useState<string>('standard')
|
const [hookProfile, setHookProfile] = useState<string>('standard')
|
||||||
const [hookProfileSaving, setHookProfileSaving] = useState(false)
|
const [hookProfileSaving, setHookProfileSaving] = useState(false)
|
||||||
|
const [coordinatorTargetAgents, setCoordinatorTargetAgents] = useState<CoordinatorTargetAgent[]>([])
|
||||||
|
const [coordinatorSessions, setCoordinatorSessions] = useState<CoordinatorSession[]>([])
|
||||||
|
|
||||||
// Replay onboarding state
|
// Replay onboarding state
|
||||||
const [replayingOnboarding, setReplayingOnboarding] = useState(false)
|
const [replayingOnboarding, setReplayingOnboarding] = useState(false)
|
||||||
|
|
@ -104,6 +152,36 @@ export function SettingsPanel() {
|
||||||
setTimeout(() => setFeedback(null), 3000)
|
setTimeout(() => setFeedback(null), 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getCoordinatorResolutionPreview = useCallback((configuredTarget: string) => {
|
||||||
|
const allAgents: CoordinatorAgentRecord[] = coordinatorTargetAgents.map(agent => ({
|
||||||
|
name: agent.name,
|
||||||
|
session_key: agent.sessionKey,
|
||||||
|
config: agent.configRaw,
|
||||||
|
}))
|
||||||
|
const directAgent = allAgents.find(agent => agent.name.toLowerCase() === COORDINATOR_AGENT) || null
|
||||||
|
const gatewaySessions = coordinatorSessions.filter(session => (session.source || 'gateway') === 'gateway')
|
||||||
|
|
||||||
|
const resolved = resolveCoordinatorDeliveryTarget({
|
||||||
|
to: COORDINATOR_AGENT,
|
||||||
|
coordinatorAgent: COORDINATOR_AGENT,
|
||||||
|
directAgent,
|
||||||
|
allAgents,
|
||||||
|
sessions: gatewaySessions,
|
||||||
|
configuredCoordinatorTarget: configuredTarget || null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const viaLabel: Record<string, string> = {
|
||||||
|
configured: 'configured target',
|
||||||
|
default: 'default agent',
|
||||||
|
main_session: 'live :main session',
|
||||||
|
direct: 'coordinator record',
|
||||||
|
fallback: 'fallback',
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetLabel = `${resolved.deliveryName}${resolved.openclawAgentId ? ` (${resolved.openclawAgentId})` : ''}`
|
||||||
|
return `Resolves now to ${targetLabel} via ${viaLabel[resolved.resolvedBy] || resolved.resolvedBy}.`
|
||||||
|
}, [coordinatorTargetAgents, coordinatorSessions])
|
||||||
|
|
||||||
const fetchSettings = useCallback(async () => {
|
const fetchSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/settings')
|
const res = await fetch('/api/settings')
|
||||||
|
|
@ -126,6 +204,45 @@ export function SettingsPanel() {
|
||||||
// Load hook profile from settings
|
// Load hook profile from settings
|
||||||
const hpSetting = (data.settings || []).find((s: Setting) => s.key === 'hook_profile')
|
const hpSetting = (data.settings || []).find((s: Setting) => s.key === 'hook_profile')
|
||||||
if (hpSetting) setHookProfile(hpSetting.value)
|
if (hpSetting) setHookProfile(hpSetting.value)
|
||||||
|
|
||||||
|
// Load agent options for coordinator routing dropdown
|
||||||
|
try {
|
||||||
|
const agentsRes = await fetch('/api/agents?limit=200')
|
||||||
|
if (agentsRes.ok) {
|
||||||
|
const agentsData = await agentsRes.json()
|
||||||
|
setCoordinatorTargetAgents(parseCoordinatorTargetAgents(agentsData.agents || []))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// non-critical
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load live sessions to preview coordinator routing resolution
|
||||||
|
try {
|
||||||
|
const sessionsRes = await fetch('/api/sessions')
|
||||||
|
if (sessionsRes.ok) {
|
||||||
|
const sessionsData = await sessionsRes.json()
|
||||||
|
const mapped: CoordinatorSession[] = Array.isArray(sessionsData.sessions)
|
||||||
|
? sessionsData.sessions.map((session: any) => ({
|
||||||
|
key: String(session?.key || ''),
|
||||||
|
agent: String(session?.agent || ''),
|
||||||
|
source: typeof session?.source === 'string' ? session.source : undefined,
|
||||||
|
sessionId: String(session?.id || session?.key || ''),
|
||||||
|
updatedAt: Number(session?.lastActivity || session?.startTime || 0),
|
||||||
|
chatType: String(session?.kind || 'unknown'),
|
||||||
|
channel: String(session?.channel || ''),
|
||||||
|
model: String(session?.model || ''),
|
||||||
|
totalTokens: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
contextTokens: 0,
|
||||||
|
active: Boolean(session?.active),
|
||||||
|
})).filter((session: CoordinatorSession) => session.key && session.agent)
|
||||||
|
: []
|
||||||
|
setCoordinatorSessions(mapped)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// non-critical
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load settings')
|
setError('Failed to load settings')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -735,7 +852,19 @@ export function SettingsPanel() {
|
||||||
const isChanged = edits[setting.key] !== undefined && edits[setting.key] !== setting.value
|
const isChanged = edits[setting.key] !== undefined && edits[setting.key] !== setting.value
|
||||||
const isBooleanish = setting.value === 'true' || setting.value === 'false'
|
const isBooleanish = setting.value === 'true' || setting.value === 'false'
|
||||||
const isNumeric = /^\d+$/.test(setting.value)
|
const isNumeric = /^\d+$/.test(setting.value)
|
||||||
const dropdownOptions = subscriptionDropdowns[setting.key]
|
const coordinatorTargetOptions = setting.key === 'chat.coordinator_target_agent'
|
||||||
|
? [
|
||||||
|
{ label: 'Auto (default/main-session fallback)', value: '' },
|
||||||
|
...coordinatorTargetAgents.map(agent => ({
|
||||||
|
label: `${agent.name}${agent.isDefault ? ' (default)' : ''} — ${agent.openclawId}`,
|
||||||
|
value: agent.openclawId,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
: null
|
||||||
|
const dropdownOptions = coordinatorTargetOptions || subscriptionDropdowns[setting.key]
|
||||||
|
const coordinatorPreview = setting.key === 'chat.coordinator_target_agent'
|
||||||
|
? getCoordinatorResolutionPreview(currentValue)
|
||||||
|
: null
|
||||||
const shortKey = setting.key.split('.').pop() || setting.key
|
const shortKey = setting.key.split('.').pop() || setting.key
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -760,18 +889,22 @@ export function SettingsPanel() {
|
||||||
<p className="text-2xs text-muted-foreground/60 mt-1 font-mono">{setting.key}</p>
|
<p className="text-2xs text-muted-foreground/60 mt-1 font-mono">{setting.key}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||||
{dropdownOptions ? (
|
<div className="flex items-center gap-2">
|
||||||
<select
|
{dropdownOptions ? (
|
||||||
value={currentValue}
|
<select
|
||||||
onChange={e => handleEdit(setting.key, e.target.value)}
|
value={currentValue}
|
||||||
className="w-48 px-2 py-1 text-sm bg-background border border-border rounded-md focus:border-primary focus:outline-none"
|
onChange={e => handleEdit(setting.key, e.target.value)}
|
||||||
>
|
className="w-64 px-2 py-1 text-sm bg-background border border-border rounded-md focus:border-primary focus:outline-none"
|
||||||
{dropdownOptions.map(opt => (
|
>
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
{dropdownOptions.map(opt => (
|
||||||
))}
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
</select>
|
))}
|
||||||
) : isBooleanish ? (
|
{currentValue && !dropdownOptions.some(opt => opt.value === currentValue) && (
|
||||||
|
<option value={currentValue}>Custom: {currentValue}</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
) : isBooleanish ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(setting.key, currentValue === 'true' ? 'false' : 'true')}
|
onClick={() => handleEdit(setting.key, currentValue === 'true' ? 'false' : 'true')}
|
||||||
className={`w-10 h-5 rounded-full relative transition-colors select-none ${
|
className={`w-10 h-5 rounded-full relative transition-colors select-none ${
|
||||||
|
|
@ -798,19 +931,23 @@ export function SettingsPanel() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!setting.is_default && (
|
{!setting.is_default && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleReset(setting.key)}
|
onClick={() => handleReset(setting.key)}
|
||||||
title="Reset to default"
|
title="Reset to default"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
className="w-6 h-6"
|
className="w-6 h-6"
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
<path d="M2 8a6 6 0 1111.3-2.8" strokeLinecap="round" />
|
<path d="M2 8a6 6 0 1111.3-2.8" strokeLinecap="round" />
|
||||||
<path d="M14 2v3.5h-3.5" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="M14 2v3.5h-3.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{coordinatorPreview && (
|
||||||
|
<p className="text-2xs text-muted-foreground max-w-72 text-right">{coordinatorPreview}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { resolveCoordinatorDeliveryTarget, type CoordinatorAgentRecord } from '@/lib/coordinator-routing'
|
||||||
|
import type { GatewaySession } from '@/lib/sessions'
|
||||||
|
|
||||||
|
function mkSession(agent: string, key: string): GatewaySession {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
agent,
|
||||||
|
sessionId: `${agent}-session`,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
chatType: 'direct',
|
||||||
|
channel: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
totalTokens: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
contextTokens: 0,
|
||||||
|
active: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolveCoordinatorDeliveryTarget', () => {
|
||||||
|
it('returns direct resolution when target agent exists', () => {
|
||||||
|
const directAgent: CoordinatorAgentRecord = {
|
||||||
|
name: 'dev',
|
||||||
|
session_key: 'agent:dev:main',
|
||||||
|
config: JSON.stringify({ openclawId: 'dev' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = resolveCoordinatorDeliveryTarget({
|
||||||
|
to: 'dev',
|
||||||
|
coordinatorAgent: 'Coordinator',
|
||||||
|
directAgent,
|
||||||
|
allAgents: [],
|
||||||
|
sessions: [mkSession('dev', 'agent:dev:main')],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resolved).toEqual({
|
||||||
|
deliveryName: 'dev',
|
||||||
|
sessionKey: 'agent:dev:main',
|
||||||
|
openclawAgentId: 'dev',
|
||||||
|
resolvedBy: 'direct',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves coordinator to explicitly configured target when present', () => {
|
||||||
|
const allAgents: CoordinatorAgentRecord[] = [
|
||||||
|
{ name: 'jarv', config: JSON.stringify({ openclawId: 'jarv' }) },
|
||||||
|
{ name: 'dev', config: JSON.stringify({ isDefault: true, openclawId: 'dev' }) },
|
||||||
|
]
|
||||||
|
|
||||||
|
const resolved = resolveCoordinatorDeliveryTarget({
|
||||||
|
to: 'Coordinator',
|
||||||
|
coordinatorAgent: 'Coordinator',
|
||||||
|
directAgent: null,
|
||||||
|
allAgents,
|
||||||
|
sessions: [mkSession('jarv', 'agent:jarv:main')],
|
||||||
|
configuredCoordinatorTarget: 'jarv',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resolved).toEqual({
|
||||||
|
deliveryName: 'jarv',
|
||||||
|
sessionKey: 'agent:jarv:main',
|
||||||
|
openclawAgentId: 'jarv',
|
||||||
|
resolvedBy: 'configured',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves coordinator to default agent when no explicit target is configured', () => {
|
||||||
|
const allAgents: CoordinatorAgentRecord[] = [
|
||||||
|
{ name: 'jarv', config: JSON.stringify({ openclawId: 'jarv' }) },
|
||||||
|
{ name: 'dev', config: JSON.stringify({ isDefault: true, openclawId: 'dev' }) },
|
||||||
|
]
|
||||||
|
|
||||||
|
const resolved = resolveCoordinatorDeliveryTarget({
|
||||||
|
to: 'Coordinator',
|
||||||
|
coordinatorAgent: 'Coordinator',
|
||||||
|
directAgent: null,
|
||||||
|
allAgents,
|
||||||
|
sessions: [mkSession('dev', 'agent:dev:main')],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resolved).toEqual({
|
||||||
|
deliveryName: 'dev',
|
||||||
|
sessionKey: 'agent:dev:main',
|
||||||
|
openclawAgentId: 'dev',
|
||||||
|
resolvedBy: 'default',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves coordinator to first live main session when no default agent exists', () => {
|
||||||
|
const resolved = resolveCoordinatorDeliveryTarget({
|
||||||
|
to: 'Coordinator',
|
||||||
|
coordinatorAgent: 'Coordinator',
|
||||||
|
directAgent: null,
|
||||||
|
allAgents: [{ name: 'admin', config: JSON.stringify({ openclawId: 'admin' }) }],
|
||||||
|
sessions: [mkSession('jarv', 'agent:jarv:main')],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resolved).toEqual({
|
||||||
|
deliveryName: 'jarv',
|
||||||
|
sessionKey: 'agent:jarv:main',
|
||||||
|
openclawAgentId: 'jarv',
|
||||||
|
resolvedBy: 'main_session',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to normalized destination when nothing else matches', () => {
|
||||||
|
const resolved = resolveCoordinatorDeliveryTarget({
|
||||||
|
to: 'Coordinator Team',
|
||||||
|
coordinatorAgent: 'Coordinator',
|
||||||
|
directAgent: null,
|
||||||
|
allAgents: [],
|
||||||
|
sessions: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resolved).toEqual({
|
||||||
|
deliveryName: 'Coordinator Team',
|
||||||
|
sessionKey: null,
|
||||||
|
openclawAgentId: 'coordinator-team',
|
||||||
|
resolvedBy: 'fallback',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -89,6 +89,28 @@ Run "openclaw doctor --fix" to apply changes.
|
||||||
expect(result.raw).not.toContain('Multiple state directories detected')
|
expect(result.raw).not.toContain('Multiple state directories detected')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('parses state integrity blocks when lines are prefixed by box-drawing gutters', () => {
|
||||||
|
const result = parseOpenClawDoctorOutput(`
|
||||||
|
┌ OpenClaw doctor
|
||||||
|
│
|
||||||
|
◇ State integrity
|
||||||
|
│ - Multiple state directories detected. This can split session history.
|
||||||
|
│ - $OPENCLAW_HOME/.openclaw
|
||||||
|
│ - /home/nefes/.openclaw
|
||||||
|
│ Active state dir: $OPENCLAW_HOME
|
||||||
|
│ - Found 11 orphan transcript file(s) in $OPENCLAW_HOME/agents/jarv/sessions.
|
||||||
|
Run "openclaw doctor --fix" to apply changes.
|
||||||
|
`, 0, { stateDir: '/home/openclaw/.openclaw' })
|
||||||
|
|
||||||
|
expect(result.level).toBe('warning')
|
||||||
|
expect(result.category).toBe('state')
|
||||||
|
expect(result.issues).toEqual([
|
||||||
|
'Found 11 orphan transcript file(s) in $OPENCLAW_HOME/agents/jarv/sessions.',
|
||||||
|
])
|
||||||
|
expect(result.raw).not.toContain('/home/nefes/.openclaw')
|
||||||
|
expect(result.raw).not.toContain('Multiple state directories detected')
|
||||||
|
})
|
||||||
|
|
||||||
it('marks clean output as healthy', () => {
|
it('marks clean output as healthy', () => {
|
||||||
const result = parseOpenClawDoctorOutput('OK: configuration valid', 0)
|
const result = parseOpenClawDoctorOutput('OK: configuration valid', 0)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
import type { GatewaySession } from './sessions'
|
||||||
|
|
||||||
|
export interface CoordinatorAgentRecord {
|
||||||
|
name: string
|
||||||
|
session_key?: string | null
|
||||||
|
config?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedCoordinatorTarget {
|
||||||
|
deliveryName: string
|
||||||
|
sessionKey: string | null
|
||||||
|
openclawAgentId: string | null
|
||||||
|
resolvedBy: 'direct' | 'configured' | 'default' | 'main_session' | 'fallback'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeName(value: string | null | undefined): string {
|
||||||
|
return String(value || '').trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOpenClawId(value: string | null | undefined): string {
|
||||||
|
return normalizeName(value).replace(/\s+/g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfig(raw: string | null | undefined): Record<string, unknown> {
|
||||||
|
if (!raw) return {}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfigOpenClawId(agent: CoordinatorAgentRecord): string | null {
|
||||||
|
const parsed = parseConfig(agent.config)
|
||||||
|
return typeof parsed.openclawId === 'string' && parsed.openclawId.trim()
|
||||||
|
? parsed.openclawId.trim()
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfigIsDefault(agent: CoordinatorAgentRecord): boolean {
|
||||||
|
const parsed = parseConfig(agent.config)
|
||||||
|
return parsed.isDefault === true
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSessionForAgent(
|
||||||
|
agent: CoordinatorAgentRecord,
|
||||||
|
sessions: GatewaySession[],
|
||||||
|
): GatewaySession | undefined {
|
||||||
|
const name = normalizeName(agent.name)
|
||||||
|
const openclawId = normalizeOpenClawId(getConfigOpenClawId(agent) || agent.name)
|
||||||
|
return sessions.find((session) => {
|
||||||
|
const sessionAgent = normalizeName(session.agent)
|
||||||
|
return sessionAgent === name || sessionAgent === openclawId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfiguredCoordinatorTarget(
|
||||||
|
preferredTarget: string,
|
||||||
|
allAgents: CoordinatorAgentRecord[],
|
||||||
|
sessions: GatewaySession[],
|
||||||
|
): CoordinatorAgentRecord | null {
|
||||||
|
const wanted = normalizeName(preferredTarget)
|
||||||
|
if (!wanted) return null
|
||||||
|
|
||||||
|
return allAgents.find((agent) => {
|
||||||
|
const byName = normalizeName(agent.name) === wanted
|
||||||
|
const byOpenClawId = normalizeOpenClawId(getConfigOpenClawId(agent) || agent.name) === wanted
|
||||||
|
const session = findSessionForAgent(agent, sessions)
|
||||||
|
const bySessionAgent = session ? normalizeName(session.agent) === wanted : false
|
||||||
|
return byName || byOpenClawId || bySessionAgent
|
||||||
|
}) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCoordinatorDeliveryTarget(params: {
|
||||||
|
to: string
|
||||||
|
coordinatorAgent: string
|
||||||
|
directAgent: CoordinatorAgentRecord | null
|
||||||
|
allAgents: CoordinatorAgentRecord[]
|
||||||
|
sessions: GatewaySession[]
|
||||||
|
explicitSessionKey?: string | null
|
||||||
|
configuredCoordinatorTarget?: string | null
|
||||||
|
}): ResolvedCoordinatorTarget {
|
||||||
|
const normalizedTo = normalizeName(params.to)
|
||||||
|
const normalizedCoordinatorAgent = normalizeName(params.coordinatorAgent)
|
||||||
|
const explicitSessionKey = params.explicitSessionKey?.trim() || null
|
||||||
|
|
||||||
|
const buildResult = (
|
||||||
|
agent: CoordinatorAgentRecord,
|
||||||
|
resolvedBy: ResolvedCoordinatorTarget['resolvedBy'],
|
||||||
|
): ResolvedCoordinatorTarget => {
|
||||||
|
const openclawAgentId = getConfigOpenClawId(agent) || normalizeOpenClawId(agent.name)
|
||||||
|
const sessionKey =
|
||||||
|
explicitSessionKey ||
|
||||||
|
agent.session_key?.trim() ||
|
||||||
|
findSessionForAgent(agent, params.sessions)?.key ||
|
||||||
|
null
|
||||||
|
|
||||||
|
return {
|
||||||
|
deliveryName: agent.name,
|
||||||
|
sessionKey,
|
||||||
|
openclawAgentId,
|
||||||
|
resolvedBy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedTo === normalizedCoordinatorAgent) {
|
||||||
|
const configuredTarget = (params.configuredCoordinatorTarget || '').trim()
|
||||||
|
if (configuredTarget) {
|
||||||
|
const configuredAgent = resolveConfiguredCoordinatorTarget(configuredTarget, params.allAgents, params.sessions)
|
||||||
|
if (configuredAgent) {
|
||||||
|
return buildResult(configuredAgent, 'configured')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultAgent = params.allAgents.find(getConfigIsDefault)
|
||||||
|
if (defaultAgent) {
|
||||||
|
return buildResult(defaultAgent, 'default')
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainSession = params.sessions.find((session) => /:main$/i.test(session.key))
|
||||||
|
if (mainSession) {
|
||||||
|
const matchingAgent = params.allAgents.find((agent) => {
|
||||||
|
const openclawId = normalizeOpenClawId(getConfigOpenClawId(agent) || agent.name)
|
||||||
|
const agentName = normalizeName(agent.name)
|
||||||
|
const sessionAgent = normalizeName(mainSession.agent)
|
||||||
|
return sessionAgent === agentName || sessionAgent === openclawId
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
deliveryName: matchingAgent?.name || mainSession.agent,
|
||||||
|
sessionKey: explicitSessionKey || mainSession.key || null,
|
||||||
|
openclawAgentId:
|
||||||
|
getConfigOpenClawId(matchingAgent || { name: mainSession.agent }) ||
|
||||||
|
normalizeOpenClawId(mainSession.agent),
|
||||||
|
resolvedBy: 'main_session',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.directAgent) {
|
||||||
|
return buildResult(params.directAgent, 'direct')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.directAgent) {
|
||||||
|
return buildResult(params.directAgent, 'direct')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
deliveryName: params.to,
|
||||||
|
sessionKey: explicitSessionKey,
|
||||||
|
openclawAgentId: normalizeOpenClawId(params.to),
|
||||||
|
resolvedBy: 'fallback',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,10 @@ export interface OpenClawDoctorStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLine(line: string): string {
|
function normalizeLine(line: string): string {
|
||||||
return line.replace(/\u001b\[[0-9;]*m/g, '').trim()
|
return line
|
||||||
|
.replace(/\u001b\[[0-9;]*m/g, '')
|
||||||
|
.replace(/^[\s│┃║┆┊╎╏]+/, '')
|
||||||
|
.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSessionAgingLine(line: string): boolean {
|
function isSessionAgingLine(line: string): boolean {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue