Files
CodePilot/src/lib/onboarding-completion.ts
T
7418andClaude Opus 4.6 281e7f9502 fix: skills/MCP/completion system overhaul — separate skill kinds, fix MCP remote servers, deduplicate completion processing
Skills system:
- Replace `CommandBadge.isSkill: boolean` with `kind: SkillKind` (agent_skill | slash_command | sdk_command | codepilot_command)
- handleSubmit dispatches by kind: agent_skill retains skill name in prompt, slash/sdk commands pass through to SDK, codepilot commands expand via COMMAND_PROMPTS
- Popover groups commands into Commands / Slash Commands / Agent Skills
- API `/api/skills` returns `kind` field; `displayOverride` persisted through full chain (frontend → API → DB)
- Forward SDK `system:init` slash_commands/skills metadata to frontend via SSE

MCP management:
- Fix remote server (SSE/HTTP) creation: validate `url` instead of `command`
- Fix dual-config read/write asymmetry: PUT routes servers by `_source` to correct file (~/.claude.json vs ~/.claude/settings.json)
- DELETE checks both config files; POST checks both for name collision
- McpManager preserves `_source` tags on edit/rename; JSON editor only manages settings.json servers

Completion processing:
- Extract core logic into `onboarding-processor.ts` and `checkin-processor.ts` (shared by route handlers and server-side detection)
- Remove frontend `detectAssistantCompletion` from ChatView — server-side path in `finally` block is now the single source of truth
- Replace hard-coded `fetch("http://localhost:PORT/...")` with direct function imports
- Both processors are internally idempotent (check state before processing)

Other:
- `settingSources` always includes 'user' so SDK loads ~/.claude/skills/
- Add 24 unit tests for SkillKind dispatch logic
- Add onboarding-completion extraction tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:49:43 +08:00

137 lines
4.9 KiB
TypeScript

/**
* Shared module for extracting and processing onboarding/checkin completion
* fences from assistant messages. Used by both frontend (ChatView) and
* backend (collectStreamResponse) to ensure completion is never missed.
*/
// ─── Fence Extraction ───────────────────────────────────────────────
/** Regex for onboarding-complete fence — tolerates CRLF, extra whitespace, optional language tag */
const ONBOARDING_FENCE_RE =
/```+\s*onboarding-complete[^\n]*\r?\n([\s\S]*?)\r?\n\s*```+/;
/** Regex for checkin-complete fence */
const CHECKIN_FENCE_RE =
/```+\s*checkin-complete[^\n]*\r?\n([\s\S]*?)\r?\n\s*```+/;
export interface CompletionFence {
type: 'onboarding' | 'checkin';
rawPayload: string;
}
/**
* Extract a completion fence from assistant message content.
* Returns null if no fence found.
*/
export function extractCompletionFence(content: string): CompletionFence | null {
const onboardingMatch = content.match(ONBOARDING_FENCE_RE);
if (onboardingMatch) {
return { type: 'onboarding', rawPayload: onboardingMatch[1] };
}
const checkinMatch = content.match(CHECKIN_FENCE_RE);
if (checkinMatch) {
return { type: 'checkin', rawPayload: checkinMatch[1] };
}
return null;
}
// ─── Robust JSON Parsing ────────────────────────────────────────────
/**
* Attempt to parse a JSON payload from an AI-generated completion fence.
* Applies progressive repair strategies for common formatting issues:
* 1. Strip markdown bold/italic markers
* 2. Fix unescaped newlines inside string values
* 3. Fix single quotes → double quotes (only around keys/values)
* 4. Remove trailing commas before } or ]
* 5. As last resort, extract key-value pairs with regex
*/
export function parseCompletionPayload(raw: string): Record<string, string> | null {
const trimmed = raw.trim();
// Pre-clean: strip markdown formatting (**bold**, *italic*) from values
// This is safe because * is not valid unquoted JSON syntax
let cleaned = trimmed.replace(/\*{1,2}([^*]+)\*{1,2}/g, '$1');
// Strategy 1: direct parse
try {
const parsed = JSON.parse(cleaned);
if (isValidAnswerMap(parsed)) return normalizeAnswerMap(parsed);
} catch { /* continue */ }
// Strategy 3: fix unescaped newlines inside JSON strings
// Replace literal newlines between quotes with \\n
cleaned = cleaned.replace(/"([^"]*?)"/g, (_match, inner: string) => {
return '"' + inner.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t') + '"';
});
try {
const parsed = JSON.parse(cleaned);
if (isValidAnswerMap(parsed)) return normalizeAnswerMap(parsed);
} catch { /* continue */ }
// Strategy 4: single quotes → double quotes (careful: only structural quotes)
cleaned = cleaned.replace(/'/g, '"');
try {
const parsed = JSON.parse(cleaned);
if (isValidAnswerMap(parsed)) return normalizeAnswerMap(parsed);
} catch { /* continue */ }
// Strategy 5: remove trailing commas before } or ]
cleaned = cleaned.replace(/,\s*([}\]])/g, '$1');
try {
const parsed = JSON.parse(cleaned);
if (isValidAnswerMap(parsed)) return normalizeAnswerMap(parsed);
} catch { /* continue */ }
// Strategy 6: regex extraction as last resort
// Match patterns like "q1": "some value" or "q1":"some value"
const result: Record<string, string> = {};
const kvRegex = /"(q\d{1,2})"\s*:\s*"((?:[^"\\]|\\.)*)"/g;
let m: RegExpExecArray | null;
while ((m = kvRegex.exec(trimmed)) !== null) {
result[m[1]] = m[2].replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\r/g, '');
}
if (Object.keys(result).length >= 3) {
// Accept if we got at least 3 answers (partial is better than nothing)
return result;
}
return null;
}
function isValidAnswerMap(obj: unknown): obj is Record<string, unknown> {
return typeof obj === 'object' && obj !== null && !Array.isArray(obj);
}
function normalizeAnswerMap(obj: Record<string, unknown>): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = typeof value === 'string' ? value : String(value ?? '');
}
return result;
}
// ─── High-Level Helpers ─────────────────────────────────────────────
export interface ExtractedCompletion {
type: 'onboarding' | 'checkin';
answers: Record<string, string>;
}
/**
* Extract and parse a completion from assistant message content.
* Returns null if no valid completion found.
*/
export function extractCompletion(content: string): ExtractedCompletion | null {
const fence = extractCompletionFence(content);
if (!fence) return null;
const answers = parseCompletionPayload(fence.rawPayload);
if (!answers) return null;
return { type: fence.type, answers };
}