mirror of
https://github.com/proxysoul/Empryo.git
synced 2026-11-07 06:25:57 -10:00
feat: v2 compaction, git branch/stash ops, SSRF protection, agent bus hardening, and comprehensive test suite
V2 context compaction: incremental structured extraction via WorkingStateManager — builds working state from tool calls and messages as-you-go, optional cheap LLM gap-fill on compact. Configurable via compaction.strategy, live toggle via /compaction command. Git tooling: git_branch tool (list/create/switch/delete), expanded git_stash (list/show/drop), merge conflict detection in git status, diff summary in commit output. Extracted parseStatusLine, unquoteGitPath, parseGitLogLine as testable functions. Security: SSRF protection on fetch-page (private IP/hostname blocking, protocol validation), isForbidden checks in rename_symbol and move_symbol, expanded shell subshell keyword detection with inner path extraction, shellQuote on project tool flags. Agent bus: LRU file cache with 50MB cap and byte tracking, tool result waiters for dedup, DependencyFailedError, circuit breaker for provider failures, proper dispose/drain of all waiters and locks on abort. Other: auto-recall memories from user messages, restricted tool mode (activeTools list), template literal interpolation in replaceInCode, refactor tool cleanup (removed redundant rename action), execSync→spawnSync in analyze, statusbar compaction indicators, improved memory menu descriptions. Tests: 17 new test files (~6k lines) covering cross-language edge cases, git status parsing, forbidden paths, shell security, hang safety, memory DB, move-symbol imports, regex backend, replace-in-code, fetch-page validation, and more. Docs: added docs/compaction.md, updated README with compaction deep dive link.
This commit is contained in:
67 files changed
+10319
-577
No files matched your search
@@ -20,7 +20,7 @@ SoulForge combines a multi-agent system, deep code intelligence, and an embedded
|
||||
|
||||
**Web Search** — Brave API → DuckDuckGo fallback for search, Jina Reader → Readability → regex for page fetching. Agent-loop mode for multi-step research. OS keychain secret storage.
|
||||
|
||||
**Context Compaction** — LLM-powered summarization of older conversation when context fills up. Shows before/after context percentages, preserves recent messages, supports deferred compaction during streaming. Auto-triggers at 70% context or manual via `/compact`.
|
||||
**Context Compaction** — Two strategies: V1 (LLM batch summarization) and V2 (incremental structured extraction). V2 builds working state as-you-go from tool calls and messages — compaction is near-instant with an optional cheap gap-fill pass. Configurable thresholds, dedicated model via task router, live toggle via `/compaction`. [Deep dive →](docs/compaction.md)
|
||||
|
||||
**Task Router** — Assign different models per task type (planning, coding, exploration, web search, compact, semantic). Opus for architecture, Haiku for grep.
|
||||
|
||||
@@ -92,6 +92,7 @@ See [GETTING_STARTED.md](GETTING_STARTED.md) for the full setup guide.
|
||||
| [Repo Map](docs/repo-map.md) | PageRank ranking, co-change, semantic summaries, dynamic budgets |
|
||||
| [Compound Tools](docs/compound-tools.md) | Design principles, benchmarks, tool reference |
|
||||
| [Agent Bus](docs/agent-bus.md) | Parallel coordination, shared cache, edit mutex, findings |
|
||||
| [Compaction](docs/compaction.md) | V1/V2 strategies, configuration, task router, visual indicators |
|
||||
|
||||
## Dev Commands
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Context Compaction
|
||||
|
||||
SoulForge supports two compaction strategies for managing long conversations. When context usage exceeds a threshold, older messages are compacted to free space while preserving critical information.
|
||||
|
||||
## Strategies
|
||||
|
||||
### V1 — LLM Batch Summarization (default)
|
||||
|
||||
The original approach. When compaction triggers:
|
||||
|
||||
1. Splits messages: last N kept verbatim, everything older goes to the summarizer
|
||||
2. Formats older messages (6k chars/msg, 8k for tool results)
|
||||
3. Sends to an LLM with a structured prompt requesting: Environment, Files Touched, Tool Results, Key Decisions, Work Completed, Errors, Current State
|
||||
4. Replaces older messages with the summary
|
||||
|
||||
**Cost**: One LLM call processing potentially 100k+ chars, outputting up to 8192 tokens.
|
||||
|
||||
### V2 — Incremental Structured Extraction
|
||||
|
||||
Maintains a `WorkingStateManager` that extracts structured state **as the conversation happens**, not in a batch at compaction time.
|
||||
|
||||
**What gets extracted (deterministic, zero LLM cost):**
|
||||
- **Files** — tracked from read/edit/write tool calls with action details
|
||||
- **Failures** — extracted from error results
|
||||
- **Tool results** — rolling window of shell/grep/project outputs
|
||||
- **Task** — set from first user message
|
||||
|
||||
**What gets extracted (regex-based, zero LLM cost):**
|
||||
- **Decisions** — patterns like "I'll use...", "decided to...", "because..."
|
||||
- **Discoveries** — patterns like "found that...", "the issue was..."
|
||||
|
||||
**On compaction:**
|
||||
1. Serializes the pre-built structured state into markdown
|
||||
2. Optionally runs a cheap LLM **gap-fill** pass (2048 tokens max) that sees the structured state + a 4k char sample of older messages and only outputs what's missing
|
||||
3. Same message replacement as v1
|
||||
|
||||
**Cost**: Rule-based extraction during conversation (free). Gap-fill pass ~2k tokens vs v1's 8k. If `llmExtraction: false`, compaction is instant with zero API calls.
|
||||
|
||||
## Configuration
|
||||
|
||||
```jsonc
|
||||
// ~/.soulforge/config.json (global) or .soulforge/config.json (project)
|
||||
{
|
||||
"compaction": {
|
||||
"strategy": "v2", // "v1" (default) | "v2"
|
||||
"triggerThreshold": 0.7, // auto-compact at 70% context usage
|
||||
"resetThreshold": 0.4, // hysteresis reset to prevent oscillation
|
||||
"keepRecent": 4, // verbatim recent messages to preserve
|
||||
"maxToolResults": 30, // rolling window for tool result slots (v2)
|
||||
"llmExtraction": true // cheap LLM gap-fill on compact (v2)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All fields are optional. Omitting `compaction` or `strategy` defaults to v1 with no behavior change.
|
||||
|
||||
### Live toggle
|
||||
|
||||
Use `/compaction` to switch strategies with project/global scope support. The change takes effect immediately — switching to v2 starts extraction on the next message, switching to v1 drops the working state entirely.
|
||||
|
||||
### Dedicated model via task router
|
||||
|
||||
Both strategies use the task router's `compact` slot:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"taskRouter": {
|
||||
"compact": "google/gemini-2.0-flash"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Falls back to `taskRouter.default`, then the active model. For v2, only the gap-fill pass uses this model. For v1, the full summarization uses it.
|
||||
|
||||
## Visual Indicators
|
||||
|
||||
- **ContextBar**: Shows `v2:N` (slot count) when v2 is active and extracting
|
||||
- **ContextBar**: Shows `◐ compacting` spinner during active compaction (both strategies)
|
||||
- **InputBox**: Shows "Compacting context..." status during compaction
|
||||
- **System message**: Reports strategy used and before/after context percentages
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/core/compaction/
|
||||
├── types.ts — WorkingState, CompactionConfig, slot types
|
||||
├── working-state.ts — WorkingStateManager class (semantic slots + serialization)
|
||||
├── extractor.ts — Rule-based extractors for tool calls and messages
|
||||
├── summarize.ts — buildV2Summary() with optional LLM gap-fill
|
||||
└── index.ts — barrel exports
|
||||
```
|
||||
|
||||
### Data flow (v2)
|
||||
|
||||
```
|
||||
User message ──────────────────────► extractFromUserMessage() ──► WSM.task
|
||||
Tool call (read/edit/shell/etc.) ──► extractFromToolCall() ──► WSM.files, WSM.toolResults
|
||||
Tool result (success/error) ───────► extractFromToolResult() ──► WSM.toolResults, WSM.failures
|
||||
Assistant text ────────────────────► extractFromAssistantMessage() ► WSM.decisions, WSM.discoveries
|
||||
│
|
||||
Context > threshold ───► buildV2Summary() ──► serialize WSM │
|
||||
│ + optional gap-fill ◄──┘
|
||||
▼
|
||||
[summary msg] + [ack msg] + [N recent msgs]
|
||||
```
|
||||
|
||||
### Guard behavior
|
||||
|
||||
When strategy is not `"v2"`, the `WorkingStateManager` is `null`. Every extraction call site checks `if (workingStateRef.current)` — no WSM instance means zero v2 code executes. No background tasks, no timers, no allocations.
|
||||
@@ -86,7 +86,7 @@ function truncate(str: string, max: number): string {
|
||||
return str.length > max ? `${str.slice(0, max - 1)}…` : str;
|
||||
}
|
||||
|
||||
const ABORT_ON_LOADING = new Set(["/clear", "/compact", "/summarize", "/plan"]);
|
||||
const ABORT_ON_LOADING = new Set(["/clear", "/compact", "/plan"]);
|
||||
|
||||
const SHUTDOWN_STEPS = [
|
||||
"Quenching active flames…",
|
||||
@@ -850,6 +850,7 @@ export function App({ config, projectConfig, resumeSessionId, bootProviders, boo
|
||||
vimHints: effectiveConfig.vimHints !== false,
|
||||
verbose: effectiveConfig.verbose === true,
|
||||
diffStyle: effectiveConfig.diffStyle ?? "default",
|
||||
compactionStrategy: effectiveConfig.compaction?.strategy ?? "v1",
|
||||
showReasoning: uiState.showReasoning,
|
||||
setShowReasoning: uiState.setShowReasoning,
|
||||
openSetup: () => uiState.openModal("setup"),
|
||||
@@ -886,6 +887,7 @@ export function App({ config, projectConfig, resumeSessionId, bootProviders, boo
|
||||
effectiveConfig.vimHints,
|
||||
effectiveConfig.verbose,
|
||||
effectiveConfig.diffStyle,
|
||||
effectiveConfig.compaction?.strategy,
|
||||
saveToScope,
|
||||
detectScope,
|
||||
],
|
||||
@@ -1196,6 +1198,7 @@ export function App({ config, projectConfig, resumeSessionId, bootProviders, boo
|
||||
<StreamSegmentList
|
||||
segments={chat.streamSegments}
|
||||
toolCalls={chat.liveToolCalls}
|
||||
streaming={chat.isLoading}
|
||||
verbose={effectiveConfig.verbose === true}
|
||||
diffStyle={effectiveConfig.diffStyle}
|
||||
showReasoning={showReasoning}
|
||||
|
||||
@@ -49,12 +49,22 @@ interface BarTarget {
|
||||
flash: boolean;
|
||||
}
|
||||
|
||||
const COMPACT_FRAMES = ["◐", "◓", "◑", "◒"];
|
||||
|
||||
interface CompactState {
|
||||
active: boolean;
|
||||
frame: number;
|
||||
strategy: "v1" | "v2";
|
||||
v2Slots: number;
|
||||
}
|
||||
|
||||
function buildContent(
|
||||
pct: number,
|
||||
tokensK: string,
|
||||
windowLabel: string,
|
||||
live: boolean,
|
||||
flash: boolean,
|
||||
compact?: CompactState,
|
||||
): StyledText {
|
||||
const filled = Math.round((pct / 100) * BAR_WIDTH);
|
||||
const empty = BAR_WIDTH - filled;
|
||||
@@ -72,6 +82,12 @@ function buildContent(
|
||||
fgStyle(pctColor)(live ? `${String(pct)}%` : `~${String(pct)}%`),
|
||||
fgStyle("#444")(` ${tokensK}k/${windowLabel}`),
|
||||
];
|
||||
if (compact?.active) {
|
||||
const spinner = COMPACT_FRAMES[compact.frame % COMPACT_FRAMES.length] ?? "◐";
|
||||
chunks.push(fgStyle("#5af")(` ${spinner} compacting`));
|
||||
} else if (compact?.strategy === "v2" && compact.v2Slots > 0) {
|
||||
chunks.push(fgStyle("#336")(` v2:${String(compact.v2Slots)}`));
|
||||
}
|
||||
return new StyledText(chunks);
|
||||
}
|
||||
|
||||
@@ -128,12 +144,25 @@ export function ContextBar({ contextManager, modelId }: Props) {
|
||||
|
||||
const currentPctRef = useRef(0);
|
||||
const currentTokensRef = useRef(0);
|
||||
const compactFrameRef = useRef(0);
|
||||
const prevV2SlotsRef = useRef(0);
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
const target = targetRef.current;
|
||||
const store = useStatusBarStore.getState();
|
||||
const isCompacting = store.compacting;
|
||||
if (isCompacting) compactFrameRef.current++;
|
||||
const pct = approach(currentPctRef.current, target.pct);
|
||||
const tok = approach(currentTokensRef.current, target.tokensX10);
|
||||
if (pct === currentPctRef.current && tok === currentTokensRef.current && !target.flash)
|
||||
const slotsChanged = store.v2Slots !== prevV2SlotsRef.current;
|
||||
prevV2SlotsRef.current = store.v2Slots;
|
||||
if (
|
||||
pct === currentPctRef.current &&
|
||||
tok === currentTokensRef.current &&
|
||||
!target.flash &&
|
||||
!isCompacting &&
|
||||
!slotsChanged
|
||||
)
|
||||
return;
|
||||
currentPctRef.current = pct;
|
||||
currentTokensRef.current = tok;
|
||||
@@ -145,6 +174,12 @@ export function ContextBar({ contextManager, modelId }: Props) {
|
||||
windowLabel,
|
||||
target.live,
|
||||
target.flash,
|
||||
{
|
||||
active: isCompacting,
|
||||
frame: compactFrameRef.current,
|
||||
strategy: store.compactionStrategy,
|
||||
v2Slots: store.v2Slots,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
@@ -41,6 +41,7 @@ const LINES: HelpLine[] = [
|
||||
{ type: "entry", label: "/stash pop", desc: "pop latest stash" },
|
||||
{ type: "entry", label: "/log", desc: "show recent commits" },
|
||||
{ type: "entry", label: "/compact", desc: "compact conversation to save context" },
|
||||
{ type: "entry", label: "/compaction", desc: "switch compaction strategy (v1/v2)" },
|
||||
{ type: "entry", label: "/context", desc: "show context budget breakdown" },
|
||||
{ type: "entry", label: "/context clear", desc: "reset context (git|skills|memory|all)" },
|
||||
{
|
||||
|
||||
@@ -45,6 +45,7 @@ const CMD_DEFS: Array<{ cmd: string; ic: string; desc: string }> = [
|
||||
{ cmd: "/co-author-commits", ic: "git", desc: "Toggle co-author trailer" },
|
||||
{ cmd: "/commit", ic: "git", desc: "AI-assisted git commit" },
|
||||
{ cmd: "/compact", ic: "compress", desc: "Compact conversation context" },
|
||||
{ cmd: "/compaction", ic: "compress", desc: "Switch compaction strategy (v1/v2)" },
|
||||
{ cmd: "/context", ic: "context", desc: "Show/clear context budget" },
|
||||
{ cmd: "/continue", ic: "play", desc: "Continue interrupted generation" },
|
||||
{ cmd: "/diff", ic: "git", desc: "Open diff in editor" },
|
||||
@@ -85,7 +86,6 @@ const CMD_DEFS: Array<{ cmd: string; ic: string; desc: string }> = [
|
||||
{ cmd: "/stash pop", ic: "git", desc: "Pop latest stash" },
|
||||
{ cmd: "/status", ic: "git", desc: "Git status" },
|
||||
{ cmd: "/storage", ic: "system", desc: "View & manage storage usage" },
|
||||
{ cmd: "/summarize", ic: "compress", desc: "Compact conversation context" },
|
||||
{ cmd: "/tabs", ic: "tabs", desc: "List open tabs" },
|
||||
{ cmd: "/verbose", ic: "cog", desc: "Toggle verbose tool output" },
|
||||
{ cmd: "/vim-hints", ic: "pencil", desc: "Toggle vim keybinding hints" },
|
||||
@@ -215,23 +215,47 @@ export function InputBox({
|
||||
const [ghostTick, setGhostTick] = useState(0);
|
||||
const forgeStatusRef = useRef("");
|
||||
const wasLoadingRef = useRef(false);
|
||||
const loadingStartRef = useRef(0);
|
||||
const [elapsedSec, setElapsedSec] = useState(0);
|
||||
if (isLoading && !wasLoadingRef.current) {
|
||||
forgeStatusRef.current = FORGE_STATUSES[
|
||||
Math.floor(Math.random() * FORGE_STATUSES.length)
|
||||
] as string;
|
||||
loadingStartRef.current = Date.now();
|
||||
}
|
||||
wasLoadingRef.current = isLoading;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showBusy) return;
|
||||
const timer = setInterval(() => setGhostTick((t) => t + 1), GHOST_SPEED);
|
||||
if (!showBusy) {
|
||||
setElapsedSec(0);
|
||||
return;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
setGhostTick((t) => t + 1);
|
||||
if (isLoading) {
|
||||
setElapsedSec(Math.floor((Date.now() - loadingStartRef.current) / 1000));
|
||||
}
|
||||
}, GHOST_SPEED);
|
||||
return () => clearInterval(timer);
|
||||
}, [showBusy]);
|
||||
}, [showBusy, isLoading]);
|
||||
|
||||
const ghostFrameFn = GHOST_FRAMES[ghostTick % GHOST_FRAMES.length];
|
||||
const currentGhost = ghostFrameFn ? ghostFrameFn() : " ";
|
||||
const busyStatus = isCompacting ? "Compacting context…" : forgeStatusRef.current;
|
||||
|
||||
let elapsedLabel = "";
|
||||
if (isLoading && elapsedSec > 0) {
|
||||
const h = Math.floor(elapsedSec / 3600);
|
||||
const m = Math.floor((elapsedSec % 3600) / 60);
|
||||
const s = elapsedSec % 60;
|
||||
elapsedLabel =
|
||||
h > 0
|
||||
? `${String(h)}h ${String(m).padStart(2, "0")}m ${String(s).padStart(2, "0")}s`
|
||||
: m > 0
|
||||
? `${String(m)}m ${String(s).padStart(2, "0")}s`
|
||||
: `${String(s)}s`;
|
||||
}
|
||||
|
||||
const historyDBRef = useRef<HistoryDB | null>(null);
|
||||
const historyCacheRef = useRef<string[]>([]);
|
||||
const historyIdx = useRef(-1);
|
||||
@@ -922,6 +946,7 @@ export function InputBox({
|
||||
<box paddingX={1} height={1} gap={1} flexDirection="row">
|
||||
<text fg={isCompacting ? "#5af" : "#8B5CF6"}>{currentGhost}</text>
|
||||
<text fg={isCompacting ? "#3388cc" : "#6A0DAD"}>{busyStatus}</text>
|
||||
{elapsedLabel !== "" && <text fg="#555">{elapsedLabel}</text>}
|
||||
{queueCount != null && queueCount > 0 && (
|
||||
<text fg="#555">({String(queueCount)} queued)</text>
|
||||
)}
|
||||
|
||||
@@ -73,8 +73,8 @@ const ITEMS: SettingItem[] = [
|
||||
},
|
||||
{
|
||||
key: "compact",
|
||||
label: "Compact",
|
||||
desc: "server-side compaction",
|
||||
label: "Prompt Cache",
|
||||
desc: "API-level prompt caching (200K+ models)",
|
||||
type: "toggle",
|
||||
section: "CONTEXT MANAGEMENT",
|
||||
},
|
||||
@@ -384,7 +384,7 @@ export function ProviderSettings({
|
||||
item.type === "toggle" ? (raw ? "x" : " ") : String(raw).padStart(valW - 2);
|
||||
|
||||
const srcScope = detectValueScope(item.key, projectConfig);
|
||||
const srcTag = srcScope === "project" ? "p" : "g";
|
||||
const srcTag = srcScope === "project" ? "[project]" : "[global]";
|
||||
const srcColor = srcScope === "project" ? "#00BFFF" : "#666";
|
||||
|
||||
rows.push(
|
||||
|
||||
@@ -14,9 +14,12 @@ function trimToCompleteLines(text: string): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
const STREAMING_OPACITY = 0.7;
|
||||
|
||||
export const StreamSegmentList = memo(function StreamSegmentList({
|
||||
segments,
|
||||
toolCalls,
|
||||
streaming = false,
|
||||
verbose = false,
|
||||
diffStyle = "default",
|
||||
showReasoning = true,
|
||||
@@ -24,6 +27,7 @@ export const StreamSegmentList = memo(function StreamSegmentList({
|
||||
}: {
|
||||
segments: StreamSegment[];
|
||||
toolCalls: LiveToolCall[];
|
||||
streaming?: boolean;
|
||||
verbose?: boolean;
|
||||
diffStyle?: "default" | "sidebyside" | "compact";
|
||||
showReasoning?: boolean;
|
||||
@@ -31,6 +35,16 @@ export const StreamSegmentList = memo(function StreamSegmentList({
|
||||
}) {
|
||||
const toolCallMap = useMemo(() => new Map(toolCalls.map((tc) => [tc.id, tc])), [toolCalls]);
|
||||
|
||||
let lastTextIndex = -1;
|
||||
if (streaming) {
|
||||
for (let j = segments.length - 1; j >= 0; j--) {
|
||||
if (segments[j]!.type === "text") {
|
||||
lastTextIndex = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lastVisibleType: string | null = null;
|
||||
return (
|
||||
<>
|
||||
@@ -40,11 +54,17 @@ export const StreamSegmentList = memo(function StreamSegmentList({
|
||||
const needsGap = lastVisibleType !== null && lastVisibleType !== seg.type ? 1 : 0;
|
||||
lastVisibleType = seg.type;
|
||||
if (seg.type === "text") {
|
||||
const isActiveSegment = i === lastTextIndex;
|
||||
const display = trimToCompleteLines(seg.content);
|
||||
if (display.length === 0) return null;
|
||||
return (
|
||||
<box key={`text-${String(i)}`} flexDirection="column" marginTop={needsGap}>
|
||||
<Markdown text={display} streaming />
|
||||
<box
|
||||
key={`text-${String(i)}`}
|
||||
flexDirection="column"
|
||||
marginTop={needsGap}
|
||||
opacity={isActiveSegment ? STREAMING_OPACITY : undefined}
|
||||
>
|
||||
<Markdown text={isActiveSegment ? `${display}▊` : display} streaming />
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
+81
-19
@@ -52,6 +52,7 @@ export interface CommandContext {
|
||||
vimHints: boolean;
|
||||
verbose: boolean;
|
||||
diffStyle: "default" | "sidebyside" | "compact";
|
||||
compactionStrategy: "v1" | "v2";
|
||||
showReasoning: boolean;
|
||||
setShowReasoning: (v: boolean) => void;
|
||||
openSetup: () => void;
|
||||
@@ -500,16 +501,20 @@ function openMemoryMenu(ctx: CommandContext): void {
|
||||
{
|
||||
value: "write-scope",
|
||||
label: "Write Scope",
|
||||
description: `current: ${config.writeScope}`,
|
||||
description: `where Forge saves new memories (current: ${config.writeScope})`,
|
||||
},
|
||||
{
|
||||
value: "read-scope",
|
||||
label: "Read Scope",
|
||||
description: `which memories Forge can access (current: ${config.readScope})`,
|
||||
},
|
||||
{ value: "read-scope", label: "Read Scope", description: `current: ${config.readScope}` },
|
||||
{
|
||||
value: "settings-storage",
|
||||
label: "Persist Settings",
|
||||
description: `current: ${memMgr.settingsScope}`,
|
||||
label: "Save Settings To",
|
||||
description: `where these scope preferences are stored (current: ${memMgr.settingsScope})`,
|
||||
},
|
||||
{ value: "view", label: "View Memories", description: "list all memories by scope" },
|
||||
{ value: "clear", label: "Clear Memories", description: "delete memories by scope" },
|
||||
{ value: "view", label: "View Memories", description: "browse all stored memories" },
|
||||
{ value: "clear", label: "Clear Memories", description: "permanently delete memories" },
|
||||
],
|
||||
onSelect: (value) => {
|
||||
if (value === "write-scope") {
|
||||
@@ -518,9 +523,17 @@ function openMemoryMenu(ctx: CommandContext): void {
|
||||
icon: "",
|
||||
currentValue: memMgr.scopeConfig.writeScope,
|
||||
options: [
|
||||
{ value: "global", label: "Global", description: "write to global memory" },
|
||||
{ value: "project", label: "Project", description: "write to project memory" },
|
||||
{ value: "none", label: "None", description: "disable memory writes" },
|
||||
{
|
||||
value: "global",
|
||||
label: "Global",
|
||||
description: "shared across all projects (~/.soulforge/)",
|
||||
},
|
||||
{
|
||||
value: "project",
|
||||
label: "Project",
|
||||
description: "scoped to this project (.soulforge/)",
|
||||
},
|
||||
{ value: "none", label: "None", description: "Forge won't save new memories" },
|
||||
],
|
||||
onSelect: (ws) => {
|
||||
memMgr.scopeConfig = {
|
||||
@@ -548,11 +561,19 @@ function openMemoryMenu(ctx: CommandContext): void {
|
||||
{
|
||||
value: "all",
|
||||
label: "All",
|
||||
description: "read from all scopes (project > global)",
|
||||
description: "search both project and global memories",
|
||||
},
|
||||
{ value: "global", label: "Global", description: "only access global memories" },
|
||||
{
|
||||
value: "project",
|
||||
label: "Project",
|
||||
description: "only access this project's memories",
|
||||
},
|
||||
{
|
||||
value: "none",
|
||||
label: "None",
|
||||
description: "Forge won't read or auto-recall memories",
|
||||
},
|
||||
{ value: "global", label: "Global", description: "read global only" },
|
||||
{ value: "project", label: "Project", description: "read project only" },
|
||||
{ value: "none", label: "None", description: "disable memory reads" },
|
||||
],
|
||||
onSelect: (rs) => {
|
||||
memMgr.scopeConfig = {
|
||||
@@ -580,12 +601,12 @@ function openMemoryMenu(ctx: CommandContext): void {
|
||||
{
|
||||
value: "project",
|
||||
label: "Project",
|
||||
description: "save to .soulforge/ in this project",
|
||||
description: "scope preferences saved in .soulforge/ (this project only)",
|
||||
},
|
||||
{
|
||||
value: "global",
|
||||
label: "Global",
|
||||
description: "save to ~/.soulforge/ for all projects",
|
||||
description: "scope preferences saved in ~/.soulforge/ (apply everywhere)",
|
||||
},
|
||||
],
|
||||
onSelect: (ss) => {
|
||||
@@ -628,9 +649,13 @@ function openMemoryMenu(ctx: CommandContext): void {
|
||||
title: "Clear Memories",
|
||||
icon: "",
|
||||
options: [
|
||||
{ value: "project", label: "Project", description: "clear project memories" },
|
||||
{ value: "global", label: "Global", description: "clear global memories" },
|
||||
{ value: "all", label: "All", description: "clear all memories" },
|
||||
{
|
||||
value: "project",
|
||||
label: "Project",
|
||||
description: "delete all project-scoped memories",
|
||||
},
|
||||
{ value: "global", label: "Global", description: "delete all global memories" },
|
||||
{ value: "all", label: "All", description: "delete everything from both scopes" },
|
||||
],
|
||||
onSelect: (scope) => {
|
||||
const cleared = memMgr.clearScope(scope as "project" | "global" | "all");
|
||||
@@ -1125,7 +1150,6 @@ async function handleCommandInner(input: string, ctx: CommandContext): Promise<v
|
||||
case "/session":
|
||||
ctx.openSessions();
|
||||
break;
|
||||
case "/summarize":
|
||||
case "/compact":
|
||||
ctx.chat.summarizeConversation();
|
||||
break;
|
||||
@@ -1832,6 +1856,44 @@ async function handleCommandInner(input: string, ctx: CommandContext): Promise<v
|
||||
});
|
||||
break;
|
||||
}
|
||||
if (cmd === "/compaction") {
|
||||
const patch = (v: string) => ({
|
||||
compaction: { strategy: v as "v1" | "v2" },
|
||||
});
|
||||
ctx.openCommandPicker({
|
||||
title: "Compaction Strategy",
|
||||
icon: "",
|
||||
currentValue: ctx.compactionStrategy,
|
||||
scopeEnabled: true,
|
||||
initialScope: ctx.detectScope("compaction"),
|
||||
options: [
|
||||
{
|
||||
value: "v1",
|
||||
label: "V1 — LLM Summarization",
|
||||
description: "batch summarize with LLM when context is full (default)",
|
||||
},
|
||||
{
|
||||
value: "v2",
|
||||
label: "V2 — Incremental Extraction",
|
||||
description: "extract structured state as-you-go, cheap gap-fill on compact",
|
||||
},
|
||||
],
|
||||
onSelect: (value, scope) => {
|
||||
ctx.saveToScope(patch(value), scope ?? "project");
|
||||
ctx.chat.setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "system",
|
||||
content: `Compaction strategy: ${value} (${scope ?? "project"})`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
},
|
||||
onScopeMove: (value, from, to) => ctx.saveToScope(patch(value), to, from),
|
||||
});
|
||||
break;
|
||||
}
|
||||
if (cmd === "/diff-style") {
|
||||
const patch = (v: string) => ({
|
||||
diffStyle: v as "default" | "sidebyside" | "compact",
|
||||
|
||||
@@ -87,6 +87,9 @@ export function mergeConfigs(global: AppConfig, project: Partial<AppConfig> | nu
|
||||
const cm = layer.contextManagement
|
||||
? { ...merged.contextManagement, ...layer.contextManagement }
|
||||
: merged.contextManagement;
|
||||
const comp = layer.compaction
|
||||
? { ...merged.compaction, ...layer.compaction }
|
||||
: merged.compaction;
|
||||
merged = {
|
||||
...merged,
|
||||
...layer,
|
||||
@@ -97,6 +100,7 @@ export function mergeConfigs(global: AppConfig, project: Partial<AppConfig> | nu
|
||||
thinking: th,
|
||||
performance: perf,
|
||||
contextManagement: cm,
|
||||
compaction: comp,
|
||||
};
|
||||
}
|
||||
return merged;
|
||||
|
||||
+322
-80
@@ -6,6 +6,8 @@
|
||||
* The bus is immutable-append-only (no deletions) to avoid race conditions.
|
||||
*/
|
||||
|
||||
import { logBackgroundError } from "../../stores/errors.js";
|
||||
|
||||
export function normalizePath(p: string): string {
|
||||
let n = p;
|
||||
while (n.startsWith("./")) n = n.slice(2);
|
||||
@@ -14,31 +16,22 @@ export function normalizePath(p: string): string {
|
||||
|
||||
export interface SharedCache {
|
||||
files: Map<string, string | null>;
|
||||
toolResults: Map<string, string>;
|
||||
toolResults: Map<string, { result: string; ts: number; agentId: string }>;
|
||||
findings: BusFinding[];
|
||||
}
|
||||
|
||||
export interface BusFinding {
|
||||
/** Which agent posted this */
|
||||
agentId: string;
|
||||
/** Short label for the finding */
|
||||
label: string;
|
||||
/** Full content — code snippets, analysis, file paths, etc. */
|
||||
content: string;
|
||||
/** Timestamp */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface AgentTask {
|
||||
/** Unique ID for this agent within the dispatch group */
|
||||
agentId: string;
|
||||
/** "explore" or "code" */
|
||||
role: "explore" | "code";
|
||||
/** The task description sent to the subagent */
|
||||
task: string;
|
||||
/** Optional dependencies — agent IDs that must complete first */
|
||||
dependsOn?: string[];
|
||||
/** Optional timeout in ms for dependency waits (default: 300_000) */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
@@ -51,13 +44,23 @@ export interface AgentResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class DependencyFailedError extends Error {
|
||||
constructor(
|
||||
public readonly depAgentId: string,
|
||||
public readonly depResult: AgentResult,
|
||||
) {
|
||||
super(`Dependency "${depAgentId}" failed: ${depResult.error ?? depResult.result}`);
|
||||
this.name = "DependencyFailedError";
|
||||
}
|
||||
}
|
||||
|
||||
interface FileCacheEntry {
|
||||
agentId: string;
|
||||
state: "reading" | "done" | "failed";
|
||||
content: string | null;
|
||||
waiters: Array<(content: string | null) => void>;
|
||||
/** Incremented on every edit/update — reads check this to avoid overwriting fresher content */
|
||||
gen: number;
|
||||
lastAccess: number;
|
||||
}
|
||||
|
||||
export type AcquireResult =
|
||||
@@ -86,8 +89,10 @@ export interface CacheMetrics {
|
||||
fileWaits: number;
|
||||
toolHits: number;
|
||||
toolMisses: number;
|
||||
toolWaits: number;
|
||||
toolEvictions: number;
|
||||
toolInvalidations: number;
|
||||
providerFailures: number;
|
||||
}
|
||||
|
||||
export interface FileReadRecord {
|
||||
@@ -101,9 +106,13 @@ export interface FileReadRecord {
|
||||
cached: boolean;
|
||||
}
|
||||
|
||||
const EDIT_LOCK_TIMEOUT_MS = 180_000;
|
||||
const CIRCUIT_BREAKER_THRESHOLD = 2;
|
||||
const CIRCUIT_BREAKER_WINDOW_MS = 10_000;
|
||||
|
||||
export class AgentBus {
|
||||
private findings: BusFinding[] = [];
|
||||
private findingKeys = new Set<string>();
|
||||
private findingKeys = new Map<string, number>();
|
||||
private results = new Map<string, AgentResult>();
|
||||
private completionCallbacks = new Map<string, Array<() => void>>();
|
||||
|
||||
@@ -114,10 +123,15 @@ export class AgentBus {
|
||||
private _filesRead = new Map<string, Set<string>>();
|
||||
private _fileReadRecords: FileReadRecord[] = [];
|
||||
private _filesEdited = new Map<string, Set<string>>();
|
||||
private toolResultCache = new Map<string, string>();
|
||||
private fileCacheBytes = 0;
|
||||
private readonly fileCacheMaxBytes = 50 * 1024 * 1024; // 50MB
|
||||
private toolResultCache = new Map<string, { result: string; ts: number; agentId: string }>();
|
||||
private toolResultWaiters = new Map<string, Array<(result: string | null) => void>>();
|
||||
private readonly toolResultCacheMaxSize = 200;
|
||||
private readonly toolResultTTL = 120_000;
|
||||
private _lastSeenFindingIdx = new Map<string, number>();
|
||||
private _editLocks = new Map<string, Promise<void>>();
|
||||
private _editLockQueues = new Map<string, Array<() => void>>();
|
||||
private _editLockHeld = new Set<string>();
|
||||
private _fileOwners = new Map<string, string>();
|
||||
private _metrics: CacheMetrics = {
|
||||
fileHits: 0,
|
||||
@@ -125,14 +139,19 @@ export class AgentBus {
|
||||
fileWaits: 0,
|
||||
toolHits: 0,
|
||||
toolMisses: 0,
|
||||
toolWaits: 0,
|
||||
toolEvictions: 0,
|
||||
toolInvalidations: 0,
|
||||
providerFailures: 0,
|
||||
};
|
||||
|
||||
private _abortController = new AbortController();
|
||||
|
||||
private _providerFailures: number[] = [];
|
||||
|
||||
constructor(shared?: SharedCache) {
|
||||
if (shared) {
|
||||
const now = Date.now();
|
||||
for (const [path, content] of shared.files) {
|
||||
this.fileCache.set(path, {
|
||||
agentId: "_shared",
|
||||
@@ -140,7 +159,9 @@ export class AgentBus {
|
||||
content,
|
||||
waiters: [],
|
||||
gen: 0,
|
||||
lastAccess: now,
|
||||
});
|
||||
this.fileCacheBytes += content?.length ?? 0;
|
||||
}
|
||||
for (const [key, result] of shared.toolResults) {
|
||||
this.toolResultCache.set(key, result);
|
||||
@@ -157,6 +178,34 @@ export class AgentBus {
|
||||
|
||||
abort(reason?: string): void {
|
||||
this._abortController.abort(reason ?? "dispatch cancelled by peer agent");
|
||||
this.drainAllWaiters();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.drainAllWaiters();
|
||||
}
|
||||
|
||||
private drainAllWaiters(): void {
|
||||
for (const [, entry] of this.fileCache) {
|
||||
if (entry.state === "reading") {
|
||||
for (const w of entry.waiters) w(null);
|
||||
entry.waiters = [];
|
||||
entry.state = "failed";
|
||||
}
|
||||
}
|
||||
for (const [key, waiters] of this.toolResultWaiters) {
|
||||
for (const w of waiters) w(null);
|
||||
this.toolResultWaiters.delete(key);
|
||||
}
|
||||
for (const [path, queue] of this._editLockQueues) {
|
||||
this._editLockQueues.delete(path);
|
||||
this._editLockHeld.delete(path);
|
||||
for (const grant of queue) grant();
|
||||
}
|
||||
for (const [_agentId, cbs] of this.completionCallbacks) {
|
||||
this.completionCallbacks.delete(_agentId);
|
||||
for (const cb of cbs) cb();
|
||||
}
|
||||
}
|
||||
|
||||
registerTasks(tasks: AgentTask[]): void {
|
||||
@@ -174,6 +223,7 @@ export class AgentBus {
|
||||
const entry = this.fileCache.get(key);
|
||||
if (entry) {
|
||||
if (entry.state === "done") {
|
||||
entry.lastAccess = Date.now();
|
||||
this._metrics.fileHits++;
|
||||
this.onCacheEvent?.(agentId, "hit", key, entry.agentId);
|
||||
return { cached: true, content: entry.content };
|
||||
@@ -188,8 +238,16 @@ export class AgentBus {
|
||||
}
|
||||
}
|
||||
this._metrics.fileMisses++;
|
||||
const now = Date.now();
|
||||
const gen = entry?.gen ?? 0;
|
||||
this.fileCache.set(key, { agentId, state: "reading", content: null, waiters: [], gen });
|
||||
this.fileCache.set(key, {
|
||||
agentId,
|
||||
state: "reading",
|
||||
content: null,
|
||||
waiters: [],
|
||||
gen,
|
||||
lastAccess: now,
|
||||
});
|
||||
return { cached: false, gen };
|
||||
}
|
||||
|
||||
@@ -199,9 +257,13 @@ export class AgentBus {
|
||||
if (!entry) return;
|
||||
if (entry.gen !== readGen) return;
|
||||
entry.state = "done";
|
||||
entry.lastAccess = Date.now();
|
||||
const oldSize = entry.content?.length ?? 0;
|
||||
entry.content = content;
|
||||
this.fileCacheBytes += (content?.length ?? 0) - oldSize;
|
||||
for (const waiter of entry.waiters) waiter(content);
|
||||
entry.waiters = [];
|
||||
this.evictFileCacheIfNeeded(key);
|
||||
}
|
||||
|
||||
failFileRead(path: string, readGen: number): void {
|
||||
@@ -209,6 +271,7 @@ export class AgentBus {
|
||||
const entry = this.fileCache.get(key);
|
||||
if (!entry) return;
|
||||
if (entry.gen !== readGen) return;
|
||||
this.fileCacheBytes -= entry.content?.length ?? 0;
|
||||
const { waiters } = entry;
|
||||
this.fileCache.delete(key);
|
||||
for (const waiter of waiters) waiter(null);
|
||||
@@ -217,12 +280,16 @@ export class AgentBus {
|
||||
invalidateFile(path: string, agentId = "_edit"): void {
|
||||
const key = normalizePath(path);
|
||||
const entry = this.fileCache.get(key);
|
||||
if (entry && entry.waiters.length > 0) {
|
||||
if (entry) {
|
||||
this.fileCacheBytes -= entry.content?.length ?? 0;
|
||||
entry.gen++;
|
||||
entry.state = "failed";
|
||||
entry.content = null;
|
||||
for (const waiter of entry.waiters) waiter(null);
|
||||
entry.waiters = [];
|
||||
}
|
||||
this.fileCache.delete(key);
|
||||
const invalidated = this.invalidateToolResultsForFile(key);
|
||||
const invalidated = this.invalidateToolResultsForFile(key, agentId);
|
||||
if (invalidated > 0) {
|
||||
this.onCacheEvent?.(agentId, "invalidate", key, agentId);
|
||||
}
|
||||
@@ -230,44 +297,103 @@ export class AgentBus {
|
||||
|
||||
updateFile(path: string, content: string, agentId = "_edit"): void {
|
||||
const key = normalizePath(path);
|
||||
const now = Date.now();
|
||||
const entry = this.fileCache.get(key);
|
||||
if (entry) {
|
||||
const oldSize = entry.content?.length ?? 0;
|
||||
entry.gen++;
|
||||
entry.content = content;
|
||||
entry.state = "done";
|
||||
entry.agentId = agentId;
|
||||
entry.lastAccess = now;
|
||||
this.fileCacheBytes += content.length - oldSize;
|
||||
for (const waiter of entry.waiters) waiter(content);
|
||||
entry.waiters = [];
|
||||
} else {
|
||||
this.fileCache.set(key, { agentId, state: "done", content, waiters: [], gen: 1 });
|
||||
this.fileCache.set(key, {
|
||||
agentId,
|
||||
state: "done",
|
||||
content,
|
||||
waiters: [],
|
||||
gen: 1,
|
||||
lastAccess: now,
|
||||
});
|
||||
this.fileCacheBytes += content.length;
|
||||
}
|
||||
const invalidated = this.invalidateToolResultsForFile(key);
|
||||
this.evictFileCacheIfNeeded(key);
|
||||
const invalidated = this.invalidateToolResultsForFile(key, agentId);
|
||||
if (invalidated > 0) {
|
||||
this.onCacheEvent?.(agentId, "invalidate", key, agentId);
|
||||
}
|
||||
}
|
||||
|
||||
private invalidateToolResultsForFile(filePath: string): number {
|
||||
private evictFileCacheIfNeeded(protectKey: string): void {
|
||||
if (this.fileCacheBytes <= this.fileCacheMaxBytes) return;
|
||||
|
||||
const candidates: [string, FileCacheEntry][] = [];
|
||||
for (const [key, entry] of this.fileCache) {
|
||||
if (key === protectKey) continue;
|
||||
if (entry.state !== "done") continue;
|
||||
candidates.push([key, entry]);
|
||||
}
|
||||
candidates.sort((a, b) => a[1].lastAccess - b[1].lastAccess);
|
||||
|
||||
for (const [key, entry] of candidates) {
|
||||
this.fileCacheBytes -= entry.content?.length ?? 0;
|
||||
this.fileCache.delete(key);
|
||||
if (this.fileCacheBytes <= this.fileCacheMaxBytes * 0.8) break;
|
||||
}
|
||||
}
|
||||
|
||||
private invalidateToolResult(key: string): void {
|
||||
this.toolResultCache.delete(key);
|
||||
const waiters = this.toolResultWaiters.get(key);
|
||||
if (waiters) {
|
||||
this.toolResultWaiters.delete(key);
|
||||
for (const w of waiters) w(null);
|
||||
}
|
||||
this._metrics.toolInvalidations++;
|
||||
}
|
||||
|
||||
private keyMatchesFile(parts: string[], filePath: string): boolean {
|
||||
const tool = parts[0];
|
||||
if (tool === "read_code" || tool === "analyze") {
|
||||
return parts[1] === filePath;
|
||||
}
|
||||
if (tool === "navigate") {
|
||||
return parts[2] === filePath || parts[2] === "";
|
||||
}
|
||||
if (tool === "grep" || tool === "glob") {
|
||||
const scopePath = parts[2] ?? "";
|
||||
return (
|
||||
scopePath === "" ||
|
||||
scopePath === "." ||
|
||||
filePath.startsWith(`${scopePath}/`) ||
|
||||
filePath === scopePath
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private invalidateToolResultsForFile(filePath: string, editingAgentId: string): number {
|
||||
let count = 0;
|
||||
for (const k of this.toolResultCache.keys()) {
|
||||
if (k.includes(filePath)) {
|
||||
this.toolResultCache.delete(k);
|
||||
this._metrics.toolInvalidations++;
|
||||
count++;
|
||||
continue;
|
||||
}
|
||||
for (const [k, entry] of this.toolResultCache) {
|
||||
try {
|
||||
const parts = JSON.parse(k) as string[];
|
||||
const tool = parts[0];
|
||||
if ((tool === "grep" || tool === "glob") && parts.some((p) => p === "." || p === "")) {
|
||||
this.toolResultCache.delete(k);
|
||||
this._metrics.toolInvalidations++;
|
||||
count++;
|
||||
if (!this.keyMatchesFile(parts, filePath)) continue;
|
||||
if (
|
||||
parts[0] === "read_code" &&
|
||||
entry.agentId === editingAgentId &&
|
||||
parts[1] === filePath &&
|
||||
parts[3]
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
this.invalidateToolResult(k);
|
||||
count++;
|
||||
} catch {
|
||||
if ((k.startsWith("grep:") || k.startsWith("glob:")) && k.includes(":.")) {
|
||||
this.toolResultCache.delete(k);
|
||||
this._metrics.toolInvalidations++;
|
||||
if (k.includes(`"${filePath}"`) || k.includes(`:${filePath}:`)) {
|
||||
this.invalidateToolResult(k);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
@@ -346,24 +472,64 @@ export class AgentBus {
|
||||
agentId: string,
|
||||
path: string,
|
||||
): Promise<{ release: () => void; owner: string | null }> {
|
||||
const prev = this._editLocks.get(path) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const done = new Promise<void>((r) => {
|
||||
release = r;
|
||||
});
|
||||
this._editLocks.set(
|
||||
path,
|
||||
prev.then(() => done),
|
||||
);
|
||||
const existingOwner = this._fileOwners.get(path) ?? null;
|
||||
return prev.then(() => {
|
||||
|
||||
const grant = (): { release: () => void; owner: string | null } => {
|
||||
this._editLockHeld.add(path);
|
||||
if (!this._fileOwners.has(path)) {
|
||||
this._fileOwners.set(path, agentId);
|
||||
}
|
||||
return { release, owner: existingOwner };
|
||||
let released = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (!released) {
|
||||
released = true;
|
||||
logBackgroundError(
|
||||
"edit-lock-timeout",
|
||||
`Edit lock for "${path}" held by "${agentId}" force-released after ${String(EDIT_LOCK_TIMEOUT_MS / 1000)}s`,
|
||||
);
|
||||
this.releaseEditLock(path);
|
||||
}
|
||||
}, EDIT_LOCK_TIMEOUT_MS);
|
||||
return {
|
||||
release: () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
clearTimeout(timer);
|
||||
this.releaseEditLock(path);
|
||||
},
|
||||
owner: existingOwner,
|
||||
};
|
||||
};
|
||||
|
||||
if (!this._editLockHeld.has(path)) {
|
||||
return Promise.resolve(grant());
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let queue = this._editLockQueues.get(path);
|
||||
if (!queue) {
|
||||
queue = [];
|
||||
this._editLockQueues.set(path, queue);
|
||||
}
|
||||
queue.push(() => resolve(grant()));
|
||||
});
|
||||
}
|
||||
|
||||
private releaseEditLock(path: string): void {
|
||||
this._editLockHeld.delete(path);
|
||||
const queue = this._editLockQueues.get(path);
|
||||
if (queue && queue.length > 0) {
|
||||
const next = queue.shift();
|
||||
if (queue.length === 0) this._editLockQueues.delete(path);
|
||||
try {
|
||||
next?.();
|
||||
} catch {
|
||||
this._editLockQueues.delete(path);
|
||||
this._editLockHeld.delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getFileOwner(path: string): string | null {
|
||||
return this._fileOwners.get(path) ?? null;
|
||||
}
|
||||
@@ -388,52 +554,93 @@ export class AgentBus {
|
||||
return result;
|
||||
}
|
||||
|
||||
acquireToolResult(agentId: string, key: string): string | null {
|
||||
const result = this.toolResultCache.get(key);
|
||||
if (result === undefined) {
|
||||
this._metrics.toolMisses++;
|
||||
return null;
|
||||
acquireToolResult(
|
||||
agentId: string,
|
||||
key: string,
|
||||
):
|
||||
| { hit: true; result: string }
|
||||
| { hit: false }
|
||||
| { hit: "waiting"; result: Promise<string | null> } {
|
||||
const entry = this.toolResultCache.get(key);
|
||||
if (entry !== undefined) {
|
||||
if (Date.now() - entry.ts > this.toolResultTTL) {
|
||||
this.toolResultCache.delete(key);
|
||||
} else {
|
||||
this._metrics.toolHits++;
|
||||
this.toolResultCache.delete(key);
|
||||
this.toolResultCache.set(key, entry);
|
||||
this.onToolCacheEvent?.(agentId, this.toolNameFromKey(key), key, "hit");
|
||||
return { hit: true, result: entry.result };
|
||||
}
|
||||
}
|
||||
this._metrics.toolHits++;
|
||||
this.toolResultCache.delete(key);
|
||||
this.toolResultCache.set(key, result);
|
||||
let toolName = key;
|
||||
try {
|
||||
const parts = JSON.parse(key) as string[];
|
||||
toolName = parts[0] ?? key;
|
||||
} catch {
|
||||
const colonIdx = key.indexOf(":");
|
||||
toolName = colonIdx >= 0 ? key.slice(0, colonIdx) : key;
|
||||
const waiters = this.toolResultWaiters.get(key);
|
||||
if (waiters) {
|
||||
this._metrics.toolWaits++;
|
||||
const promise = new Promise<string | null>((resolve) => {
|
||||
waiters.push((r) => resolve(r));
|
||||
});
|
||||
return { hit: "waiting", result: promise };
|
||||
}
|
||||
this.onToolCacheEvent?.(agentId, toolName, key, "hit");
|
||||
return result;
|
||||
this.toolResultWaiters.set(key, []);
|
||||
this._metrics.toolMisses++;
|
||||
return { hit: false };
|
||||
}
|
||||
|
||||
cacheToolResult(agentId: string, key: string, result: string): void {
|
||||
const waiters = this.toolResultWaiters.get(key);
|
||||
this.toolResultWaiters.delete(key);
|
||||
if (waiters) {
|
||||
for (const w of waiters) w(result);
|
||||
}
|
||||
this.toolResultCache.delete(key);
|
||||
if (this.toolResultCache.size >= this.toolResultCacheMaxSize) {
|
||||
this._metrics.toolEvictions++;
|
||||
const firstKey = this.toolResultCache.keys().next().value;
|
||||
if (firstKey) this.toolResultCache.delete(firstKey);
|
||||
}
|
||||
this.toolResultCache.set(key, result);
|
||||
let toolName = key;
|
||||
try {
|
||||
const parts = JSON.parse(key) as string[];
|
||||
toolName = parts[0] ?? key;
|
||||
} catch {
|
||||
const colonIdx = key.indexOf(":");
|
||||
toolName = colonIdx >= 0 ? key.slice(0, colonIdx) : key;
|
||||
}
|
||||
this.onToolCacheEvent?.(agentId, toolName, key, "store");
|
||||
this.toolResultCache.set(key, { result, ts: Date.now(), agentId });
|
||||
this.onToolCacheEvent?.(agentId, this.toolNameFromKey(key), key, "store");
|
||||
}
|
||||
|
||||
private toolNameFromKey(key: string): string {
|
||||
try {
|
||||
const parts = JSON.parse(key) as string[];
|
||||
return parts[0] ?? key;
|
||||
} catch {
|
||||
const colonIdx = key.indexOf(":");
|
||||
return colonIdx >= 0 ? key.slice(0, colonIdx) : key;
|
||||
}
|
||||
}
|
||||
|
||||
private findingBytes = 0;
|
||||
private readonly findingMaxContentBytes = 2048;
|
||||
private readonly findingMaxTotalBytes = 128 * 1024; // 128KB
|
||||
|
||||
postFinding(finding: BusFinding): void {
|
||||
if (this.findings.length >= 30) return;
|
||||
if (this.findings.length >= 30 && !this.findingKeys.has(`${finding.agentId}:${finding.label}`))
|
||||
return;
|
||||
if (this.findingBytes >= this.findingMaxTotalBytes) return;
|
||||
const key = `${finding.agentId}:${finding.label}`;
|
||||
if (this.findingKeys.has(key)) return;
|
||||
this.findingKeys.add(key);
|
||||
this.findings.push(finding);
|
||||
const content =
|
||||
finding.content.length > this.findingMaxContentBytes
|
||||
? `${finding.content.slice(0, this.findingMaxContentBytes)}… [truncated]`
|
||||
: finding.content;
|
||||
const capped = { ...finding, content };
|
||||
|
||||
const existingIdx = this.findingKeys.get(key);
|
||||
if (existingIdx !== undefined) {
|
||||
const old = this.findings[existingIdx];
|
||||
if (old) {
|
||||
this.findingBytes -= old.content.length;
|
||||
this.findings[existingIdx] = capped;
|
||||
this.findingBytes += content.length;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.findingKeys.set(key, this.findings.length);
|
||||
this.findingBytes += content.length;
|
||||
this.findings.push(capped);
|
||||
}
|
||||
|
||||
getFindings(excludeAgentId?: string): BusFinding[] {
|
||||
@@ -464,7 +671,12 @@ export class AgentBus {
|
||||
|
||||
waitForAgent(agentId: string, timeoutMs = 300_000): Promise<AgentResult> {
|
||||
const existing = this.results.get(agentId);
|
||||
if (existing) return Promise.resolve(existing);
|
||||
if (existing) {
|
||||
if (!existing.success) {
|
||||
return Promise.reject(new DependencyFailedError(agentId, existing));
|
||||
}
|
||||
return Promise.resolve(existing);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
@@ -480,12 +692,35 @@ export class AgentBus {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
const result = this.results.get(agentId);
|
||||
if (result) resolve(result);
|
||||
if (result) {
|
||||
if (!result.success) {
|
||||
reject(new DependencyFailedError(agentId, result));
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Agent bus disposed while waiting for "${agentId}"`));
|
||||
}
|
||||
});
|
||||
this.completionCallbacks.set(agentId, cbs);
|
||||
});
|
||||
}
|
||||
|
||||
recordProviderFailure(): boolean {
|
||||
const now = Date.now();
|
||||
this._providerFailures.push(now);
|
||||
this._metrics.providerFailures++;
|
||||
|
||||
const cutoff = now - CIRCUIT_BREAKER_WINDOW_MS;
|
||||
this._providerFailures = this._providerFailures.filter((t) => t > cutoff);
|
||||
|
||||
if (this._providerFailures.length >= CIRCUIT_BREAKER_THRESHOLD) {
|
||||
this.abort("circuit breaker tripped — multiple provider failures within window");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
summarizeFindings(excludeAgentId?: string): string {
|
||||
const findings = this.getFindings(excludeAgentId);
|
||||
if (findings.length === 0) return "No findings from peer agents yet.";
|
||||
@@ -562,9 +797,16 @@ export class AgentBus {
|
||||
for (const [path, entry] of this.fileCache) {
|
||||
if (entry.state === "done") files.set(path, entry.content);
|
||||
}
|
||||
const now = Date.now();
|
||||
const toolResults = new Map<string, { result: string; ts: number; agentId: string }>();
|
||||
for (const [key, entry] of this.toolResultCache) {
|
||||
if (now - entry.ts <= this.toolResultTTL) {
|
||||
toolResults.set(key, entry);
|
||||
}
|
||||
}
|
||||
return {
|
||||
files,
|
||||
toolResults: new Map(this.toolResultCache),
|
||||
toolResults,
|
||||
findings: [...this.findings],
|
||||
};
|
||||
}
|
||||
|
||||
+43
-15
@@ -1,9 +1,10 @@
|
||||
import type { ProviderOptions } from "@ai-sdk/provider-utils";
|
||||
import type { LanguageModel } from "ai";
|
||||
import { stepCountIs, ToolLoopAgent } from "ai";
|
||||
import { z } from "zod";
|
||||
import type { EditorIntegration, ForgeMode, InteractiveCallbacks } from "../../types/index.js";
|
||||
import type { ContextManager } from "../context/manager.js";
|
||||
import { buildInteractiveTools, buildRestrictedModeTools, buildTools } from "../tools/index.js";
|
||||
import { buildInteractiveTools, buildTools, RESTRICTED_TOOL_NAMES } from "../tools/index.js";
|
||||
import { repairToolCall, sanitizeToolInputsStep, smoothStreamOptions } from "./stream-options.js";
|
||||
import { buildSubagentTools, type SharedCacheRef } from "./subagent-tools.js";
|
||||
|
||||
@@ -31,8 +32,11 @@ interface ForgeAgentOptions {
|
||||
* Factory function (not singleton) — model can change between turns (Ctrl+L).
|
||||
* Combines direct tools + subagent tools + optional interactive tools.
|
||||
*
|
||||
* For restricted modes (architect, socratic, challenge), only read-only tools
|
||||
* are registered — the LLM physically cannot call edit/shell/git.
|
||||
* For restricted modes (architect, socratic, challenge), activeTools limits
|
||||
* to read-only tools — the LLM physically cannot call edit/shell/git.
|
||||
*
|
||||
* Uses prepareCall for auto-recall: user message passed via callOptionsSchema
|
||||
* at .stream() time, memory search injected into instructions dynamically.
|
||||
*/
|
||||
export function createForgeAgent({
|
||||
model,
|
||||
@@ -53,13 +57,11 @@ export function createForgeAgent({
|
||||
const isRestricted = RESTRICTED_MODES.has(forgeMode);
|
||||
const repoMap = contextManager.isRepoMapReady() ? contextManager.getRepoMap() : undefined;
|
||||
|
||||
const directTools = isRestricted
|
||||
? buildRestrictedModeTools(editorIntegration, onApproveWebSearch, { webSearchModel, repoMap })
|
||||
: buildTools(undefined, editorIntegration, onApproveWebSearch, {
|
||||
codeExecution,
|
||||
webSearchModel,
|
||||
repoMap,
|
||||
});
|
||||
const directTools = buildTools(undefined, editorIntegration, onApproveWebSearch, {
|
||||
codeExecution,
|
||||
webSearchModel,
|
||||
repoMap,
|
||||
});
|
||||
|
||||
const repoMapContext = contextManager.isRepoMapReady()
|
||||
? contextManager.renderRepoMap() || undefined
|
||||
@@ -93,20 +95,46 @@ export function createForgeAgent({
|
||||
sharedCacheRef,
|
||||
});
|
||||
|
||||
const allTools = {
|
||||
...directTools,
|
||||
...subagentTools,
|
||||
...(interactive ? buildInteractiveTools(interactive, { cwd, sessionId }) : {}),
|
||||
};
|
||||
|
||||
const allToolNames = Object.keys(allTools) as (keyof typeof allTools)[];
|
||||
const restrictedSet = new Set(RESTRICTED_TOOL_NAMES);
|
||||
const restrictedActiveTools = isRestricted
|
||||
? allToolNames.filter((name) => restrictedSet.has(name))
|
||||
: undefined;
|
||||
|
||||
return new ToolLoopAgent({
|
||||
id: "forge",
|
||||
model,
|
||||
...smoothStreamOptions,
|
||||
tools: {
|
||||
...directTools,
|
||||
...subagentTools,
|
||||
...(interactive ? buildInteractiveTools(interactive, { cwd, sessionId }) : {}),
|
||||
},
|
||||
tools: allTools,
|
||||
callOptionsSchema: z.object({
|
||||
userMessage: z.string().optional(),
|
||||
}),
|
||||
instructions: {
|
||||
role: "system" as const,
|
||||
content: contextManager.buildSystemPrompt(),
|
||||
providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
|
||||
},
|
||||
prepareCall: ({ options, ...settings }) => {
|
||||
const recalled = options?.userMessage
|
||||
? contextManager.getMemoryManager().autoRecall(options.userMessage)
|
||||
: null;
|
||||
|
||||
return {
|
||||
...settings,
|
||||
...(recalled
|
||||
? {
|
||||
instructions: `${settings.instructions}\n\n### Auto-Recalled Memories (matching this message)\n${recalled}`,
|
||||
}
|
||||
: {}),
|
||||
...(restrictedActiveTools ? { activeTools: restrictedActiveTools } : {}),
|
||||
};
|
||||
},
|
||||
stopWhen: stepCountIs(500),
|
||||
prepareStep: sanitizeToolInputsStep,
|
||||
experimental_repairToolCall: repairToolCall,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type { AgentResult, AgentTask, BusFinding } from "./agent-bus.js";
|
||||
export { AgentBus } from "./agent-bus.js";
|
||||
export { AgentBus, DependencyFailedError } from "./agent-bus.js";
|
||||
export { buildBusTools } from "./bus-tools.js";
|
||||
export { createCodeAgent } from "./code.js";
|
||||
export { createExploreAgent } from "./explore.js";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { smoothStream } from "ai";
|
||||
|
||||
/** Shared smoothStream transform applied to all agents. */
|
||||
export const smoothStreamOptions = {
|
||||
experimental_transform: smoothStream({ delayInMs: 500, chunking: "word" }),
|
||||
experimental_transform: smoothStream({ delayInMs: 12, chunking: "word" }),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -19,7 +19,7 @@ export const smoothStreamOptions = {
|
||||
*
|
||||
* This prepareStep hook ensures all tool-call inputs are plain objects.
|
||||
*/
|
||||
function sanitizeMessages(messages: ModelMessage[]): ModelMessage[] {
|
||||
export function sanitizeMessages(messages: ModelMessage[]): ModelMessage[] {
|
||||
let dirty = false;
|
||||
const cleaned = messages.map((msg) => {
|
||||
if (msg.role !== "assistant" || typeof msg.content === "string") return msg;
|
||||
|
||||
+281
-239
@@ -5,7 +5,14 @@ import { z } from "zod";
|
||||
import { logBackgroundError } from "../../stores/errors.js";
|
||||
import type { RepoMap } from "../intelligence/repo-map.js";
|
||||
import { projectTool } from "../tools/project.js";
|
||||
import { AgentBus, type AgentTask, type FileReadRecord, type SharedCache } from "./agent-bus.js";
|
||||
import {
|
||||
AgentBus,
|
||||
type AgentTask,
|
||||
type AgentResult as BusAgentResult,
|
||||
DependencyFailedError,
|
||||
type FileReadRecord,
|
||||
type SharedCache,
|
||||
} from "./agent-bus.js";
|
||||
import { createCodeAgent } from "./code.js";
|
||||
import { createExploreAgent } from "./explore.js";
|
||||
import { emitAgentStats, emitMultiAgentEvent, emitSubagentStep } from "./subagent-events.js";
|
||||
@@ -52,7 +59,6 @@ function formatToolArgs(toolCall: { toolName: string; input?: unknown }): string
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Build step-reporting callbacks for a subagent */
|
||||
function buildStepCallbacks(parentToolCallId: string, agentId?: string) {
|
||||
const acc = { toolUses: 0, input: 0, output: 0, cacheRead: 0 };
|
||||
|
||||
@@ -312,8 +318,10 @@ async function runEvaluator(
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const BASE_DELAY_MS = 2000;
|
||||
const MAX_CONCURRENT_AGENTS = 3;
|
||||
|
||||
function isRetryable(error: unknown): boolean {
|
||||
if (error instanceof DependencyFailedError) return false;
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
const lower = msg.toLowerCase();
|
||||
return (
|
||||
@@ -345,9 +353,23 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single agent task with retry + exponential backoff for transient errors.
|
||||
*/
|
||||
function createAgent(task: AgentTask, models: SubagentModels, bus: AgentBus) {
|
||||
const useExplore = task.role === "explore" || models.readOnly === true;
|
||||
const opts = {
|
||||
bus,
|
||||
agentId: task.agentId,
|
||||
providerOptions: models.providerOptions,
|
||||
headers: models.headers,
|
||||
webSearchModel: models.webSearchModel,
|
||||
onApproveWebSearch: models.onApproveWebSearch,
|
||||
repoMapContext: models.repoMapContext,
|
||||
repoMap: models.repoMap,
|
||||
};
|
||||
return useExplore
|
||||
? createExploreAgent(models.explorationModel ?? models.defaultModel, opts)
|
||||
: createCodeAgent(models.codingModel ?? models.defaultModel, opts);
|
||||
}
|
||||
|
||||
async function runAgentTask(
|
||||
task: AgentTask,
|
||||
models: SubagentModels,
|
||||
@@ -355,11 +377,47 @@ async function runAgentTask(
|
||||
parentToolCallId: string,
|
||||
totalAgents: number,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
): Promise<{
|
||||
doneResult: DoneToolResult | null;
|
||||
resultText: string;
|
||||
callbacks: ReturnType<typeof buildStepCallbacks>;
|
||||
result: BusAgentResult;
|
||||
}> {
|
||||
if (task.dependsOn && task.dependsOn.length > 0) {
|
||||
await Promise.all(
|
||||
task.dependsOn.map((dep) => bus.waitForAgent(dep, task.timeoutMs ?? 300_000)),
|
||||
);
|
||||
try {
|
||||
await Promise.all(
|
||||
task.dependsOn.map((dep) => bus.waitForAgent(dep, task.timeoutMs ?? 300_000)),
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof DependencyFailedError) {
|
||||
const errMsg = `Skipped: dependency "${err.depAgentId}" failed`;
|
||||
const agentResult = {
|
||||
agentId: task.agentId,
|
||||
role: task.role,
|
||||
task: task.task,
|
||||
result: errMsg,
|
||||
success: false,
|
||||
error: errMsg,
|
||||
} satisfies BusAgentResult;
|
||||
bus.setResult(agentResult);
|
||||
emitMultiAgentEvent({
|
||||
parentToolCallId,
|
||||
type: "agent-error",
|
||||
agentId: task.agentId,
|
||||
role: task.role,
|
||||
task: task.task,
|
||||
totalAgents,
|
||||
error: errMsg,
|
||||
});
|
||||
return {
|
||||
doneResult: null,
|
||||
resultText: errMsg,
|
||||
callbacks: buildStepCallbacks(parentToolCallId, task.agentId),
|
||||
result: agentResult,
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
emitMultiAgentEvent({
|
||||
@@ -413,29 +471,7 @@ async function runAgentTask(
|
||||
}
|
||||
|
||||
try {
|
||||
const useExplore = task.role === "explore" || models.readOnly === true;
|
||||
const agent = useExplore
|
||||
? createExploreAgent(models.explorationModel ?? models.defaultModel, {
|
||||
bus,
|
||||
agentId: task.agentId,
|
||||
providerOptions: models.providerOptions,
|
||||
headers: models.headers,
|
||||
webSearchModel: models.webSearchModel,
|
||||
onApproveWebSearch: models.onApproveWebSearch,
|
||||
repoMapContext: models.repoMapContext,
|
||||
repoMap: models.repoMap,
|
||||
})
|
||||
: createCodeAgent(models.codingModel ?? models.defaultModel, {
|
||||
bus,
|
||||
agentId: task.agentId,
|
||||
providerOptions: models.providerOptions,
|
||||
headers: models.headers,
|
||||
webSearchModel: models.webSearchModel,
|
||||
onApproveWebSearch: models.onApproveWebSearch,
|
||||
repoMapContext: models.repoMapContext,
|
||||
repoMap: models.repoMap,
|
||||
});
|
||||
|
||||
const agent = createAgent(task, models, bus);
|
||||
const callbacks = buildStepCallbacks(parentToolCallId, task.agentId);
|
||||
|
||||
const result = await agent.generate({
|
||||
@@ -455,13 +491,14 @@ async function runAgentTask(
|
||||
const doneResult = extractDoneResult(result);
|
||||
const resultText = doneResult ? formatDoneResult(doneResult) : buildFallbackResult(result);
|
||||
|
||||
bus.setResult({
|
||||
const agentResult: BusAgentResult = {
|
||||
agentId: task.agentId,
|
||||
role: task.role,
|
||||
task: task.task,
|
||||
result: resultText,
|
||||
success: true,
|
||||
});
|
||||
};
|
||||
bus.setResult(agentResult);
|
||||
|
||||
autoPostCompletionSummary(bus, task);
|
||||
|
||||
@@ -479,10 +516,15 @@ async function runAgentTask(
|
||||
cacheHits: cacheRead > 0 ? cacheRead : undefined,
|
||||
resultChars: resultText.length,
|
||||
});
|
||||
return;
|
||||
return { doneResult, resultText, callbacks, result: agentResult };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isRetryable(error) || attempt === MAX_RETRIES) break;
|
||||
if (isRetryable(error)) {
|
||||
const tripped = bus.recordProviderFailure();
|
||||
if (tripped || attempt === MAX_RETRIES) break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,14 +532,15 @@ async function runAgentTask(
|
||||
`Failed after ${String(MAX_RETRIES)} attempts. ` +
|
||||
`Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`;
|
||||
|
||||
bus.setResult({
|
||||
const agentResult: BusAgentResult = {
|
||||
agentId: task.agentId,
|
||||
role: task.role,
|
||||
task: task.task,
|
||||
result: errMsg,
|
||||
success: false,
|
||||
error: errMsg,
|
||||
});
|
||||
};
|
||||
bus.setResult(agentResult);
|
||||
|
||||
emitMultiAgentEvent({
|
||||
parentToolCallId,
|
||||
@@ -508,12 +551,15 @@ async function runAgentTask(
|
||||
totalAgents,
|
||||
error: errMsg,
|
||||
});
|
||||
|
||||
return {
|
||||
doneResult: null,
|
||||
resultText: errMsg,
|
||||
callbacks: buildStepCallbacks(parentToolCallId, task.agentId),
|
||||
result: agentResult,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified `dispatch` tool — replaces explore, code, and multi_agent.
|
||||
* Handles 1–10 agents with a minimal schema.
|
||||
*/
|
||||
export function buildSubagentTools(models: SubagentModels) {
|
||||
const cacheRef: SharedCacheRef = models.sharedCacheRef ?? {
|
||||
current: undefined,
|
||||
@@ -565,233 +611,223 @@ export function buildSubagentTools(models: SubagentModels) {
|
||||
}),
|
||||
execute: async (args, { abortSignal, toolCallId }) => {
|
||||
const bus = new AgentBus(cacheRef.current);
|
||||
try {
|
||||
const tasks: AgentTask[] = args.tasks.map((t, i) => ({
|
||||
agentId: t.id ?? `agent-${String(i + 1)}`,
|
||||
role: t.role,
|
||||
task: t.task,
|
||||
dependsOn: t.dependsOn,
|
||||
}));
|
||||
|
||||
const tasks: AgentTask[] = args.tasks.map((t, i) => ({
|
||||
agentId: t.id ?? `agent-${String(i + 1)}`,
|
||||
role: t.role,
|
||||
task: t.task,
|
||||
dependsOn: t.dependsOn,
|
||||
}));
|
||||
bus.registerTasks(tasks);
|
||||
|
||||
bus.registerTasks(tasks);
|
||||
bus.onCacheEvent = (agentId, type, path, sourceAgentId) => {
|
||||
emitSubagentStep({
|
||||
parentToolCallId: toolCallId,
|
||||
toolName: type === "invalidate" ? "edit_file" : "read_file",
|
||||
args: path,
|
||||
state: type === "wait" ? "running" : "done",
|
||||
agentId,
|
||||
cacheState: type,
|
||||
sourceAgentId,
|
||||
});
|
||||
};
|
||||
|
||||
bus.onCacheEvent = (agentId, type, path, sourceAgentId) => {
|
||||
emitSubagentStep({
|
||||
parentToolCallId: toolCallId,
|
||||
toolName: type === "invalidate" ? "edit_file" : "read_file",
|
||||
args: path,
|
||||
state: type === "wait" ? "running" : "done",
|
||||
agentId,
|
||||
cacheState: type,
|
||||
sourceAgentId,
|
||||
});
|
||||
};
|
||||
bus.onToolCacheEvent = (agentId, toolName, key, type) => {
|
||||
let displayArgs = "";
|
||||
try {
|
||||
const parts = JSON.parse(key) as string[];
|
||||
displayArgs = parts.slice(1).join(" ");
|
||||
} catch {
|
||||
const colonIdx = key.indexOf(":");
|
||||
displayArgs = colonIdx >= 0 ? key.slice(colonIdx + 1) : "";
|
||||
}
|
||||
emitSubagentStep({
|
||||
parentToolCallId: toolCallId,
|
||||
toolName,
|
||||
args: displayArgs,
|
||||
state: "done",
|
||||
agentId,
|
||||
cacheState: type,
|
||||
});
|
||||
};
|
||||
|
||||
bus.onToolCacheEvent = (agentId, toolName, key, type) => {
|
||||
let displayArgs = "";
|
||||
try {
|
||||
const parts = JSON.parse(key) as string[];
|
||||
displayArgs = parts.slice(1).join(" ");
|
||||
} catch {
|
||||
const colonIdx = key.indexOf(":");
|
||||
displayArgs = colonIdx >= 0 ? key.slice(colonIdx + 1) : "";
|
||||
const isSingle = tasks.length === 1;
|
||||
|
||||
if (isSingle) {
|
||||
const task = tasks[0] as AgentTask;
|
||||
const { doneResult, resultText } = await runAgentTask(
|
||||
task,
|
||||
models,
|
||||
bus,
|
||||
toolCallId,
|
||||
1,
|
||||
abortSignal,
|
||||
);
|
||||
if (!doneResult && !bus.getResult(task.agentId)?.success) {
|
||||
throw new Error(resultText);
|
||||
}
|
||||
const editedMap = bus.getEditedFiles(task.agentId);
|
||||
return {
|
||||
reads: bus.getFileReadRecords(task.agentId),
|
||||
filesEdited: [...editedMap.keys()],
|
||||
output: resultText,
|
||||
} satisfies DispatchOutput;
|
||||
}
|
||||
emitSubagentStep({
|
||||
parentToolCallId: toolCallId,
|
||||
toolName,
|
||||
args: displayArgs,
|
||||
state: "done",
|
||||
agentId,
|
||||
cacheState: type,
|
||||
});
|
||||
};
|
||||
|
||||
const isSingle = tasks.length === 1;
|
||||
|
||||
if (!isSingle) {
|
||||
emitMultiAgentEvent({
|
||||
parentToolCallId: toolCallId,
|
||||
type: "dispatch-start",
|
||||
totalAgents: tasks.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (isSingle) {
|
||||
const task = tasks[0] as AgentTask;
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
if (abortSignal?.aborted) break;
|
||||
if (attempt > 0) {
|
||||
const jitter = Math.random() * 1000;
|
||||
await sleep(BASE_DELAY_MS * 2 ** (attempt - 1) + jitter, abortSignal);
|
||||
if (abortSignal?.aborted) break;
|
||||
}
|
||||
try {
|
||||
const useExploreAgent = task.role === "explore" || models.readOnly === true;
|
||||
const agent = useExploreAgent
|
||||
? createExploreAgent(models.explorationModel ?? models.defaultModel, {
|
||||
bus,
|
||||
agentId: task.agentId,
|
||||
providerOptions: models.providerOptions,
|
||||
headers: models.headers,
|
||||
webSearchModel: models.webSearchModel,
|
||||
onApproveWebSearch: models.onApproveWebSearch,
|
||||
repoMapContext: models.repoMapContext,
|
||||
repoMap: models.repoMap,
|
||||
})
|
||||
: createCodeAgent(models.codingModel ?? models.defaultModel, {
|
||||
bus,
|
||||
agentId: task.agentId,
|
||||
providerOptions: models.providerOptions,
|
||||
headers: models.headers,
|
||||
webSearchModel: models.webSearchModel,
|
||||
onApproveWebSearch: models.onApproveWebSearch,
|
||||
repoMapContext: models.repoMapContext,
|
||||
repoMap: models.repoMap,
|
||||
});
|
||||
|
||||
const result = await agent.generate({
|
||||
prompt: task.task,
|
||||
abortSignal,
|
||||
...buildStepCallbacks(toolCallId, task.agentId),
|
||||
});
|
||||
const doneResult = extractDoneResult(result);
|
||||
autoPostCompletionSummary(bus, task);
|
||||
cacheRef.current = bus.exportCaches();
|
||||
const output = doneResult
|
||||
? formatDoneResult(doneResult)
|
||||
: buildFallbackResult(result);
|
||||
const editedMap = bus.getEditedFiles(task.agentId);
|
||||
return {
|
||||
reads: bus.getFileReadRecords(task.agentId),
|
||||
filesEdited: [...editedMap.keys()],
|
||||
output,
|
||||
} satisfies DispatchOutput;
|
||||
} catch (error) {
|
||||
lastErr = error;
|
||||
if (!isRetryable(error) || attempt === MAX_RETRIES) throw error;
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
const taskIds = new Set(tasks.map((t) => t.agentId));
|
||||
for (const task of tasks) {
|
||||
if (task.dependsOn) {
|
||||
for (const dep of task.dependsOn) {
|
||||
if (!taskIds.has(dep)) {
|
||||
return `Error: task "${task.agentId}" depends on unknown task "${dep}"`;
|
||||
const taskIds = new Set(tasks.map((t) => t.agentId));
|
||||
for (const task of tasks) {
|
||||
if (task.dependsOn) {
|
||||
for (const dep of task.dependsOn) {
|
||||
if (!taskIds.has(dep)) {
|
||||
return `Error: task "${task.agentId}" depends on unknown task "${dep}"`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasCycle = (() => {
|
||||
const visited = new Set<string>();
|
||||
const stack = new Set<string>();
|
||||
const depMap = new Map(tasks.map((t) => [t.agentId, t.dependsOn ?? []]));
|
||||
const dfs = (id: string): boolean => {
|
||||
if (stack.has(id)) return true;
|
||||
if (visited.has(id)) return false;
|
||||
visited.add(id);
|
||||
stack.add(id);
|
||||
for (const dep of depMap.get(id) ?? []) {
|
||||
if (dfs(dep)) return true;
|
||||
const hasCycle = (() => {
|
||||
const visited = new Set<string>();
|
||||
const stack = new Set<string>();
|
||||
const depMap = new Map(tasks.map((t) => [t.agentId, t.dependsOn ?? []]));
|
||||
const dfs = (id: string): boolean => {
|
||||
if (stack.has(id)) return true;
|
||||
if (visited.has(id)) return false;
|
||||
visited.add(id);
|
||||
stack.add(id);
|
||||
for (const dep of depMap.get(id) ?? []) {
|
||||
if (dfs(dep)) return true;
|
||||
}
|
||||
stack.delete(id);
|
||||
return false;
|
||||
};
|
||||
return tasks.some((t) => dfs(t.agentId));
|
||||
})();
|
||||
if (hasCycle) return "Error: dependency cycle detected among tasks";
|
||||
|
||||
const combinedAbort = AbortSignal.any(
|
||||
[abortSignal, bus.abortSignal].filter(Boolean) as AbortSignal[],
|
||||
);
|
||||
|
||||
const STAGGER_MS = 100;
|
||||
let inflightCount = 0;
|
||||
const inflightWaiters: Array<() => void> = [];
|
||||
|
||||
const acquireConcurrencySlot = async (): Promise<void> => {
|
||||
while (inflightCount >= MAX_CONCURRENT_AGENTS) {
|
||||
await new Promise<void>((resolve) => inflightWaiters.push(resolve));
|
||||
}
|
||||
stack.delete(id);
|
||||
return false;
|
||||
inflightCount++;
|
||||
};
|
||||
return tasks.some((t) => dfs(t.agentId));
|
||||
})();
|
||||
if (hasCycle) return "Error: dependency cycle detected among tasks";
|
||||
|
||||
// Combine parent abort (user cancel) with bus abort (peer cancel)
|
||||
const combinedAbort = AbortSignal.any(
|
||||
[abortSignal, bus.abortSignal].filter(Boolean) as AbortSignal[],
|
||||
);
|
||||
const releaseConcurrencySlot = (): void => {
|
||||
inflightCount--;
|
||||
const waiter = inflightWaiters.shift();
|
||||
if (waiter) waiter();
|
||||
};
|
||||
|
||||
const STAGGER_MS = 100;
|
||||
const promises = tasks.map((task, idx) => {
|
||||
const hasDeps = task.dependsOn && task.dependsOn.length > 0;
|
||||
const delay = hasDeps ? 0 : idx * STAGGER_MS;
|
||||
const run = () =>
|
||||
runAgentTask(task, models, bus, toolCallId, tasks.length, combinedAbort);
|
||||
return delay > 0 ? sleep(delay, combinedAbort).then(run) : run();
|
||||
});
|
||||
await Promise.all(promises);
|
||||
const promises = tasks.map((task, idx) => {
|
||||
const hasDeps = task.dependsOn && task.dependsOn.length > 0;
|
||||
const jitter = Math.random() * STAGGER_MS;
|
||||
const delay = hasDeps ? 0 : idx * STAGGER_MS + jitter;
|
||||
|
||||
emitMultiAgentEvent({
|
||||
parentToolCallId: toolCallId,
|
||||
type: "dispatch-done",
|
||||
totalAgents: tasks.length,
|
||||
completedAgents: bus.completedAgentIds.length,
|
||||
findingCount: bus.findingCount,
|
||||
});
|
||||
const run = async () => {
|
||||
await acquireConcurrencySlot();
|
||||
try {
|
||||
await runAgentTask(task, models, bus, toolCallId, tasks.length, combinedAbort);
|
||||
} finally {
|
||||
releaseConcurrencySlot();
|
||||
}
|
||||
};
|
||||
|
||||
const results = bus.getAllResults();
|
||||
const successful = results.filter((r) => r.success);
|
||||
const failed = results.filter((r) => !r.success);
|
||||
return delay > 0 ? sleep(delay, combinedAbort).then(run) : run();
|
||||
});
|
||||
await Promise.all(promises);
|
||||
|
||||
const sections: string[] = [];
|
||||
const heading = args.objective ?? "Dispatch";
|
||||
sections.push(`## ${heading}`);
|
||||
sections.push(
|
||||
`**${String(successful.length)}/${String(tasks.length)}** agents completed successfully.`,
|
||||
);
|
||||
emitMultiAgentEvent({
|
||||
parentToolCallId: toolCallId,
|
||||
type: "dispatch-done",
|
||||
totalAgents: tasks.length,
|
||||
completedAgents: bus.completedAgentIds.length,
|
||||
findingCount: bus.findingCount,
|
||||
});
|
||||
|
||||
if (bus.findingCount > 0) {
|
||||
const findings = bus.getFindings();
|
||||
const results = bus.getAllResults();
|
||||
const successful = results.filter((r) => r.success);
|
||||
const failed = results.filter((r) => !r.success);
|
||||
|
||||
const sections: string[] = [];
|
||||
const heading = args.objective ?? "Dispatch";
|
||||
sections.push(`## ${heading}`);
|
||||
sections.push(
|
||||
`### Coordination Findings (${String(findings.length)})`,
|
||||
...findings.map((f) => `**[${f.agentId}] ${f.label}:**\n${f.content}`),
|
||||
`**${String(successful.length)}/${String(tasks.length)}** agents completed successfully.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const r of results) {
|
||||
const status = r.success ? "✓" : "✗";
|
||||
sections.push(
|
||||
`\n### ${status} Agent: ${r.agentId} (${r.role})\n**Task:** ${r.task}\n\n${r.result}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
sections.push(
|
||||
`\n### Errors\n${failed.map((r) => `- ${r.agentId}: ${r.error}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const allEdited = bus.getEditedFiles();
|
||||
if (allEdited.size > 0) {
|
||||
const lines: string[] = [];
|
||||
const conflicts: string[] = [];
|
||||
for (const [path, agents] of allEdited) {
|
||||
lines.push(`- \`${path}\` — ${agents.join(", ")}`);
|
||||
if (agents.length > 1) conflicts.push(path);
|
||||
}
|
||||
sections.push(`\n### Files Edited\n${lines.join("\n")}`);
|
||||
if (conflicts.length > 0) {
|
||||
if (bus.findingCount > 0) {
|
||||
const findings = bus.getFindings();
|
||||
sections.push(
|
||||
`\n⚠ **Edit conflicts detected** — multiple agents edited: ${conflicts.map((p) => `\`${p}\``).join(", ")}. Review these files carefully.`,
|
||||
`### Coordination Findings (${String(findings.length)})`,
|
||||
...findings.map((f) => `**[${f.agentId}] ${f.label}:**\n${f.content}`),
|
||||
);
|
||||
}
|
||||
|
||||
for (const r of results) {
|
||||
const status = r.success ? "✓" : "✗";
|
||||
sections.push(
|
||||
`\n### ${status} Agent: ${r.agentId} (${r.role})\n**Task:** ${r.task}\n\n${r.result}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
sections.push(
|
||||
`\n### Errors\n${failed.map((r) => `- ${r.agentId}: ${r.error}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const allEdited = bus.getEditedFiles();
|
||||
if (allEdited.size > 0) {
|
||||
const lines: string[] = [];
|
||||
const conflicts: string[] = [];
|
||||
for (const [path, agents] of allEdited) {
|
||||
lines.push(`- \`${path}\` — ${agents.join(", ")}`);
|
||||
if (agents.length > 1) conflicts.push(path);
|
||||
}
|
||||
sections.push(`\n### Files Edited\n${lines.join("\n")}`);
|
||||
if (conflicts.length > 0) {
|
||||
sections.push(
|
||||
`\n⚠ **Edit conflicts detected** — multiple agents edited: ${conflicts.map((p) => `\`${p}\``).join(", ")}. Review these files carefully.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const evalResult = await runEvaluator(bus, tasks, toolCallId);
|
||||
if (evalResult) sections.push(evalResult);
|
||||
|
||||
const m = bus.metrics;
|
||||
const cacheStats = [m.fileHits, m.fileWaits, m.toolHits].some((v) => v > 0)
|
||||
? `\n### Cache\nFiles: ${String(m.fileHits)} hits, ${String(m.fileWaits)} waits, ${String(m.fileMisses)} misses | Tools: ${String(m.toolHits)} hits, ${String(m.toolWaits)} waits, ${String(m.toolMisses)} misses, ${String(m.toolEvictions)} evictions, ${String(m.toolInvalidations)} invalidations`
|
||||
: "";
|
||||
if (cacheStats) sections.push(cacheStats);
|
||||
|
||||
return {
|
||||
reads: bus.getFileReadRecords(),
|
||||
filesEdited: [...bus.getEditedFiles().keys()],
|
||||
output: sections.join("\n"),
|
||||
} satisfies DispatchOutput;
|
||||
} finally {
|
||||
try {
|
||||
cacheRef.current = bus.exportCaches();
|
||||
} catch (err) {
|
||||
logBackgroundError("cache-export", err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
bus.dispose();
|
||||
}
|
||||
|
||||
const evalResult = await runEvaluator(bus, tasks, toolCallId);
|
||||
if (evalResult) sections.push(evalResult);
|
||||
|
||||
const m = bus.metrics;
|
||||
const cacheStats = [m.fileHits, m.fileWaits, m.toolHits].some((v) => v > 0)
|
||||
? `\n### Cache\nFiles: ${String(m.fileHits)} hits, ${String(m.fileWaits)} waits, ${String(m.fileMisses)} misses | Tools: ${String(m.toolHits)} hits, ${String(m.toolMisses)} misses, ${String(m.toolEvictions)} evictions, ${String(m.toolInvalidations)} invalidations`
|
||||
: "";
|
||||
if (cacheStats) sections.push(cacheStats);
|
||||
|
||||
cacheRef.current = bus.exportCaches();
|
||||
|
||||
return {
|
||||
reads: bus.getFileReadRecords(),
|
||||
filesEdited: [...bus.getEditedFiles().keys()],
|
||||
output: sections.join("\n"),
|
||||
} satisfies DispatchOutput;
|
||||
},
|
||||
toModelOutput({ output }: { toolCallId: string; input: unknown; output: unknown }) {
|
||||
const dispatch = output as DispatchOutput | string;
|
||||
@@ -801,16 +837,22 @@ export function buildSubagentTools(models: SubagentModels) {
|
||||
const compact: string[] = [];
|
||||
let blankRun = 0;
|
||||
let inCodeBlock = false;
|
||||
let inStructuredSection = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("```")) inCodeBlock = !inCodeBlock;
|
||||
if (/^(?:Files edited:|Key findings:|###.*Agent:)/.test(line)) {
|
||||
inStructuredSection = true;
|
||||
} else if (line.startsWith("## ") || line.startsWith("### Cache")) {
|
||||
inStructuredSection = false;
|
||||
}
|
||||
if (line.trim() === "") {
|
||||
blankRun++;
|
||||
if (blankRun <= 1) compact.push("");
|
||||
continue;
|
||||
}
|
||||
blankRun = 0;
|
||||
const limit = inCodeBlock ? 500 : 250;
|
||||
const limit = inCodeBlock || inStructuredSection ? 800 : 250;
|
||||
compact.push(line.length > limit ? `${line.slice(0, limit)}...` : line);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { ModelMessage } from "ai";
|
||||
import type { WorkingStateManager } from "./working-state.js";
|
||||
|
||||
/**
|
||||
* Rule-based extractor that processes tool calls and messages to update
|
||||
* the working state incrementally. This covers ~90% of extraction with
|
||||
* zero LLM cost. The remaining ~10% (decisions from AI reasoning) can
|
||||
* optionally use a cheap LLM pass.
|
||||
*/
|
||||
|
||||
// ─── Tool Call Extraction (deterministic) ───
|
||||
|
||||
const READ_TOOLS = new Set(["read_file", "navigate", "grep", "glob", "analyze"]);
|
||||
const EDIT_TOOLS = new Set(["edit_file", "replace_file", "write_file"]);
|
||||
const SHELL_TOOL = "shell";
|
||||
const PROJECT_TOOL = "project";
|
||||
|
||||
export function extractFromToolCall(
|
||||
wsm: WorkingStateManager,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): void {
|
||||
const filePath = extractFilePath(args);
|
||||
|
||||
if (READ_TOOLS.has(toolName) && filePath) {
|
||||
wsm.trackFile(filePath, {
|
||||
type: "read",
|
||||
summary:
|
||||
toolName === "grep"
|
||||
? `grep for "${truncate(String(args.pattern ?? ""), 80)}"`
|
||||
: toolName === "glob"
|
||||
? `glob "${truncate(String(args.pattern ?? ""), 80)}"`
|
||||
: toolName === "analyze"
|
||||
? `analyzed${args.symbols ? ` symbols: ${truncate(String(args.symbols), 100)}` : ""}`
|
||||
: "read",
|
||||
});
|
||||
}
|
||||
|
||||
if (EDIT_TOOLS.has(toolName) && filePath) {
|
||||
const detail =
|
||||
toolName === "edit_file"
|
||||
? buildEditDetail(args)
|
||||
: toolName === "write_file"
|
||||
? "full write"
|
||||
: "replaced";
|
||||
wsm.trackFile(filePath, {
|
||||
type: filePath && toolName === "write_file" ? "create" : "edit",
|
||||
detail,
|
||||
});
|
||||
}
|
||||
|
||||
if (toolName === SHELL_TOOL) {
|
||||
const cmd = truncate(String(args.command ?? ""), 120);
|
||||
wsm.addToolResult("shell", `ran: ${cmd}`);
|
||||
}
|
||||
|
||||
if (toolName === PROJECT_TOOL) {
|
||||
const action = String(args.action ?? "");
|
||||
wsm.addToolResult(
|
||||
"project",
|
||||
`${action}${args.command ? `: ${truncate(String(args.command), 80)}` : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function extractFromToolResult(
|
||||
wsm: WorkingStateManager,
|
||||
toolName: string,
|
||||
result: unknown,
|
||||
_args?: Record<string, unknown>,
|
||||
): void {
|
||||
const resultStr = typeof result === "string" ? result : JSON.stringify(result);
|
||||
|
||||
if (isErrorResult(resultStr)) {
|
||||
const errorSummary = extractErrorSummary(resultStr);
|
||||
wsm.addFailure(`${toolName}: ${errorSummary}`);
|
||||
}
|
||||
|
||||
if (toolName === "shell" || toolName === "project") {
|
||||
const summary = truncate(resultStr, 300);
|
||||
const existing = wsm.getState().toolResults;
|
||||
const last = existing[existing.length - 1];
|
||||
if (last && last.tool === toolName) {
|
||||
last.summary += ` → ${summary}`;
|
||||
} else {
|
||||
wsm.addToolResult(toolName, summary);
|
||||
}
|
||||
}
|
||||
|
||||
if (toolName === "grep") {
|
||||
const matchCount = (resultStr.match(/\n/g) || []).length;
|
||||
wsm.addToolResult(
|
||||
"grep",
|
||||
`${matchCount} matches${matchCount > 0 ? `: ${truncate(resultStr, 200)}` : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Message-level Extraction (deterministic) ───
|
||||
|
||||
export function extractFromUserMessage(wsm: WorkingStateManager, message: ModelMessage): void {
|
||||
const text = messageText(message);
|
||||
if (!text) return;
|
||||
|
||||
if (!wsm.getState().task) {
|
||||
wsm.setTask(truncate(text, 300));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract decisions from assistant text. This is the "fuzzy 10%" —
|
||||
* can be done rule-based (pattern matching) or with a cheap LLM.
|
||||
* This function handles the rule-based version.
|
||||
*/
|
||||
export function extractFromAssistantMessage(wsm: WorkingStateManager, message: ModelMessage): void {
|
||||
const text = messageText(message);
|
||||
if (!text) return;
|
||||
|
||||
const decisionPatterns = [
|
||||
/(?:I'll|let's|we should|I'm going to|the approach is|decided to|choosing|using|switching to)\s+(.{10,120}?)(?:\.|$)/gi,
|
||||
/(?:because|since|the reason is)\s+(.{10,120}?)(?:\.|$)/gi,
|
||||
];
|
||||
|
||||
for (const pattern of decisionPatterns) {
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
if (match[1]) {
|
||||
const decision = truncate(match[1].trim(), 150);
|
||||
if (decision.length > 15) {
|
||||
wsm.addDecision(decision);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const discoveryPatterns = [
|
||||
/(?:found that|discovered|it turns out|interestingly|the issue (?:is|was))\s+(.{10,150}?)(?:\.|$)/gi,
|
||||
];
|
||||
|
||||
for (const pattern of discoveryPatterns) {
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
if (match[1]) {
|
||||
wsm.addDiscovery(truncate(match[1].trim(), 150));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
function extractFilePath(args: Record<string, unknown>): string | undefined {
|
||||
const keys = ["file", "path", "filePath", "file_path", "target_file", "source_file", "target"];
|
||||
for (const key of keys) {
|
||||
const val = args[key];
|
||||
if (typeof val === "string" && val.length > 0) return val;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildEditDetail(args: Record<string, unknown>): string {
|
||||
const old = truncate(String(args.old_string ?? args.search ?? ""), 60);
|
||||
const new_ = truncate(String(args.new_string ?? args.replace ?? ""), 60);
|
||||
if (old && new_) return `"${old}" → "${new_}"`;
|
||||
if (args.line != null) return `line ${args.line}`;
|
||||
return "edited";
|
||||
}
|
||||
|
||||
function isErrorResult(result: string): boolean {
|
||||
return /(?:error|Error|ERROR|failed|FAILED|exception|EXCEPTION|not found|ENOENT|EACCES|panic)/i.test(
|
||||
result.slice(0, 500),
|
||||
);
|
||||
}
|
||||
|
||||
function extractErrorSummary(result: string): string {
|
||||
const lines = result.split("\n").filter((l) => l.trim().length > 0);
|
||||
const errorLine = lines.find((l) => /(?:error|Error|failed|exception|not found)/i.test(l));
|
||||
return truncate(errorLine || lines[0] || "unknown error", 200);
|
||||
}
|
||||
|
||||
function messageText(msg: ModelMessage): string | undefined {
|
||||
if (typeof msg.content === "string") return msg.content;
|
||||
if (Array.isArray(msg.content)) {
|
||||
const texts: string[] = [];
|
||||
for (const part of msg.content) {
|
||||
if (typeof part === "object" && part !== null && "text" in part) {
|
||||
texts.push(String((part as { text: string }).text));
|
||||
}
|
||||
}
|
||||
return texts.join("\n") || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
if (s.length <= max) return s;
|
||||
return `${s.slice(0, max)}...`;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
extractFromAssistantMessage,
|
||||
extractFromToolCall,
|
||||
extractFromToolResult,
|
||||
extractFromUserMessage,
|
||||
} from "./extractor.js";
|
||||
export { buildV2Summary } from "./summarize.js";
|
||||
export type { CompactionConfig, WorkingState } from "./types.js";
|
||||
export { DEFAULT_COMPACTION_CONFIG } from "./types.js";
|
||||
export { WorkingStateManager } from "./working-state.js";
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { JSONObject } from "@ai-sdk/provider";
|
||||
import type { ModelMessage } from "ai";
|
||||
import { generateText } from "ai";
|
||||
import type { WorkingStateManager } from "./working-state.js";
|
||||
|
||||
/**
|
||||
* Build the compacted summary for v2.
|
||||
*
|
||||
* 1. Serialize the incrementally-built working state (free, already computed).
|
||||
* 2. Optionally run a cheap LLM pass to extract anything the rule-based
|
||||
* extractor might have missed from the older messages.
|
||||
* 3. Merge and return the final summary string.
|
||||
*
|
||||
* The LLM pass is a small "gap-fill" — NOT a full conversation summarization.
|
||||
* It receives the existing structured state + the raw older messages and only
|
||||
* outputs what's missing. This is ~10x cheaper than v1's full summarization.
|
||||
*/
|
||||
export async function buildV2Summary(opts: {
|
||||
wsm: WorkingStateManager;
|
||||
olderMessages: ModelMessage[];
|
||||
model?: Parameters<typeof generateText>[0]["model"];
|
||||
providerOptions?: Record<string, JSONObject>;
|
||||
headers?: Record<string, string>;
|
||||
skipLlm?: boolean;
|
||||
}): Promise<string> {
|
||||
const { wsm, olderMessages, model, providerOptions, headers, skipLlm } = opts;
|
||||
|
||||
const structuredState = wsm.serialize();
|
||||
|
||||
if (skipLlm || !model) {
|
||||
return structuredState;
|
||||
}
|
||||
|
||||
const convoSample = buildConvoSample(olderMessages, 4000);
|
||||
|
||||
const { text: gapFill } = await generateText({
|
||||
model,
|
||||
maxOutputTokens: 2048,
|
||||
...(providerOptions && Object.keys(providerOptions).length > 0 ? { providerOptions } : {}),
|
||||
...(headers ? { headers } : {}),
|
||||
prompt: [
|
||||
"You are reviewing a structured summary of a coding conversation to find MISSING information.",
|
||||
"",
|
||||
"EXISTING STRUCTURED STATE (already extracted):",
|
||||
structuredState,
|
||||
"",
|
||||
"RAW CONVERSATION SAMPLE (older messages):",
|
||||
convoSample,
|
||||
"",
|
||||
"Your job: output ONLY information that is MISSING from the structured state above.",
|
||||
"Format as bullet points under these headers (skip empty sections):",
|
||||
"",
|
||||
"## Missing Decisions",
|
||||
"## Missing Discoveries",
|
||||
"## Missing Context",
|
||||
"",
|
||||
"If the structured state already covers everything important, output exactly: COMPLETE",
|
||||
"Be very concise. Only add genuinely missing context — don't repeat what's already captured.",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
if (!gapFill || gapFill.trim() === "COMPLETE" || gapFill.trim().length < 20) {
|
||||
return structuredState;
|
||||
}
|
||||
|
||||
return `${structuredState}\n\n## Additional Context (gap-fill)\n${gapFill.trim()}`;
|
||||
}
|
||||
|
||||
function buildConvoSample(messages: ModelMessage[], charBudget: number): string {
|
||||
const parts: string[] = [];
|
||||
let chars = 0;
|
||||
|
||||
for (let i = messages.length - 1; i >= 0 && chars < charBudget; i--) {
|
||||
const msg = messages[i];
|
||||
if (!msg) continue;
|
||||
const text = messageTextBrief(msg);
|
||||
if (!text) continue;
|
||||
const chunk = `${msg.role}: ${text.slice(0, 1000)}`;
|
||||
parts.unshift(chunk);
|
||||
chars += chunk.length;
|
||||
}
|
||||
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
function messageTextBrief(msg: ModelMessage): string | undefined {
|
||||
if (typeof msg.content === "string") return msg.content;
|
||||
if (Array.isArray(msg.content)) {
|
||||
const texts: string[] = [];
|
||||
for (const part of msg.content) {
|
||||
if (typeof part === "object" && part !== null && "text" in part) {
|
||||
texts.push(String((part as { text: string }).text));
|
||||
}
|
||||
}
|
||||
return texts.join("\n") || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface WorkingState {
|
||||
task: string;
|
||||
plan: PlanSlot[];
|
||||
files: Map<string, FileSlot>;
|
||||
decisions: string[];
|
||||
failures: string[];
|
||||
discoveries: string[];
|
||||
environment: string[];
|
||||
toolResults: ToolResultSlot[];
|
||||
}
|
||||
|
||||
export interface PlanSlot {
|
||||
id: string;
|
||||
label: string;
|
||||
status: "pending" | "active" | "done" | "skipped";
|
||||
}
|
||||
|
||||
export interface FileSlot {
|
||||
path: string;
|
||||
actions: FileAction[];
|
||||
}
|
||||
|
||||
export type FileAction =
|
||||
| { type: "read"; summary: string }
|
||||
| { type: "edit"; detail: string }
|
||||
| { type: "create"; detail: string }
|
||||
| { type: "delete" };
|
||||
|
||||
export interface ToolResultSlot {
|
||||
tool: string;
|
||||
summary: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface CompactionConfig {
|
||||
strategy: "v1" | "v2";
|
||||
/** Threshold (0-1) at which auto-compaction triggers. Default: 0.7 */
|
||||
triggerThreshold?: number;
|
||||
/** Hysteresis reset threshold. Default: 0.4 */
|
||||
resetThreshold?: number;
|
||||
/** Number of recent messages to keep verbatim. Default: 4 */
|
||||
keepRecent?: number;
|
||||
/** Max tool result slots to retain in working state. Default: 30 */
|
||||
maxToolResults?: number;
|
||||
/** Use a cheap LLM pass for fuzzy extraction (decisions from AI text). Default: true */
|
||||
llmExtraction?: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_COMPACTION_CONFIG: CompactionConfig = {
|
||||
strategy: "v1",
|
||||
triggerThreshold: 0.7,
|
||||
resetThreshold: 0.4,
|
||||
keepRecent: 4,
|
||||
maxToolResults: 30,
|
||||
llmExtraction: true,
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { CompactionConfig, FileAction, WorkingState } from "./types.js";
|
||||
import { DEFAULT_COMPACTION_CONFIG } from "./types.js";
|
||||
|
||||
export class WorkingStateManager {
|
||||
private state: WorkingState;
|
||||
private config: CompactionConfig;
|
||||
|
||||
constructor(config?: Partial<CompactionConfig>) {
|
||||
this.config = { ...DEFAULT_COMPACTION_CONFIG, ...config };
|
||||
this.state = this.createEmpty();
|
||||
}
|
||||
|
||||
private createEmpty(): WorkingState {
|
||||
return {
|
||||
task: "",
|
||||
plan: [],
|
||||
files: new Map(),
|
||||
decisions: [],
|
||||
failures: [],
|
||||
discoveries: [],
|
||||
environment: [],
|
||||
toolResults: [],
|
||||
};
|
||||
}
|
||||
|
||||
getState(): Readonly<WorkingState> {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
getConfig(): Readonly<CompactionConfig> {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state = this.createEmpty();
|
||||
}
|
||||
|
||||
// ─── Task ───
|
||||
|
||||
setTask(task: string): void {
|
||||
this.state.task = task;
|
||||
}
|
||||
|
||||
// ─── Plan ───
|
||||
|
||||
setPlan(
|
||||
steps: { id: string; label: string; status: "pending" | "active" | "done" | "skipped" }[],
|
||||
): void {
|
||||
this.state.plan = steps;
|
||||
}
|
||||
|
||||
updatePlanStep(stepId: string, status: "pending" | "active" | "done" | "skipped"): void {
|
||||
const step = this.state.plan.find((s) => s.id === stepId);
|
||||
if (step) step.status = status;
|
||||
}
|
||||
|
||||
// ─── Files ───
|
||||
|
||||
trackFile(path: string, action: FileAction): void {
|
||||
const existing = this.state.files.get(path);
|
||||
if (existing) {
|
||||
existing.actions.push(action);
|
||||
} else {
|
||||
this.state.files.set(path, { path, actions: [action] });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Decisions / Failures / Discoveries ───
|
||||
|
||||
addDecision(d: string): void {
|
||||
if (!this.state.decisions.includes(d)) {
|
||||
this.state.decisions.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
addFailure(f: string): void {
|
||||
this.state.failures.push(f);
|
||||
}
|
||||
|
||||
addDiscovery(d: string): void {
|
||||
if (!this.state.discoveries.includes(d)) {
|
||||
this.state.discoveries.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Environment ───
|
||||
|
||||
addEnvironment(e: string): void {
|
||||
if (!this.state.environment.includes(e)) {
|
||||
this.state.environment.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tool Results ───
|
||||
|
||||
addToolResult(tool: string, summary: string): void {
|
||||
const maxResults = this.config.maxToolResults ?? 30;
|
||||
this.state.toolResults.push({ tool, summary, timestamp: Date.now() });
|
||||
if (this.state.toolResults.length > maxResults) {
|
||||
this.state.toolResults = this.state.toolResults.slice(-maxResults);
|
||||
}
|
||||
}
|
||||
|
||||
slotCount(): number {
|
||||
const s = this.state;
|
||||
return (
|
||||
(s.task ? 1 : 0) +
|
||||
s.files.size +
|
||||
s.decisions.length +
|
||||
s.failures.length +
|
||||
s.discoveries.length +
|
||||
s.toolResults.length +
|
||||
s.environment.length
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Serialization ───
|
||||
|
||||
serialize(): string {
|
||||
const s = this.state;
|
||||
const sections: string[] = [];
|
||||
|
||||
if (s.task) {
|
||||
sections.push(`## Task\n${s.task}`);
|
||||
}
|
||||
|
||||
if (s.plan.length > 0) {
|
||||
const planLines = s.plan.map((step) => {
|
||||
const icon =
|
||||
step.status === "done"
|
||||
? "✓"
|
||||
: step.status === "active"
|
||||
? "▸"
|
||||
: step.status === "skipped"
|
||||
? "⊘"
|
||||
: "○";
|
||||
return ` ${icon} [${step.id}] ${step.label} — ${step.status}`;
|
||||
});
|
||||
sections.push(`## Plan\n${planLines.join("\n")}`);
|
||||
}
|
||||
|
||||
if (s.environment.length > 0) {
|
||||
sections.push(`## Environment\n${s.environment.map((e) => `- ${e}`).join("\n")}`);
|
||||
}
|
||||
|
||||
if (s.files.size > 0) {
|
||||
const fileLines: string[] = [];
|
||||
for (const [path, slot] of s.files) {
|
||||
const actions = slot.actions.map((a) => {
|
||||
if (a.type === "read") return `read: ${a.summary}`;
|
||||
if (a.type === "edit") return `edited: ${a.detail}`;
|
||||
if (a.type === "create") return `created: ${a.detail}`;
|
||||
return "deleted";
|
||||
});
|
||||
fileLines.push(`- \`${path}\`: ${actions.join("; ")}`);
|
||||
}
|
||||
sections.push(`## Files Touched\n${fileLines.join("\n")}`);
|
||||
}
|
||||
|
||||
if (s.decisions.length > 0) {
|
||||
sections.push(`## Key Decisions\n${s.decisions.map((d) => `- ${d}`).join("\n")}`);
|
||||
}
|
||||
|
||||
if (s.toolResults.length > 0) {
|
||||
const resultLines = s.toolResults.map((r) => `- **${r.tool}**: ${r.summary}`);
|
||||
sections.push(`## Tool Results\n${resultLines.join("\n")}`);
|
||||
}
|
||||
|
||||
if (s.failures.length > 0) {
|
||||
sections.push(`## Errors & Failures\n${s.failures.map((f) => `- ${f}`).join("\n")}`);
|
||||
}
|
||||
|
||||
if (s.discoveries.length > 0) {
|
||||
sections.push(`## Discoveries\n${s.discoveries.map((d) => `- ${d}`).join("\n")}`);
|
||||
}
|
||||
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,7 @@ export class ContextManager {
|
||||
private cwd: string;
|
||||
private skills = new Map<string, string>();
|
||||
private gitContext: string | null = null;
|
||||
private gitContextStale = true;
|
||||
private memoryManager: MemoryManager;
|
||||
private forgeMode: ForgeMode = "default";
|
||||
private editorFile: string | null = null;
|
||||
@@ -216,6 +217,7 @@ export class ContextManager {
|
||||
this.repoMap.onFileChanged(absPath);
|
||||
this.editedFiles.add(absPath);
|
||||
this.repoMapCache = null;
|
||||
this.gitContextStale = true;
|
||||
}
|
||||
|
||||
/** Track a file mentioned in conversation (tool reads, grep hits, etc.) */
|
||||
@@ -229,6 +231,14 @@ export class ContextManager {
|
||||
this.conversationTerms = extractConversationTerms(input);
|
||||
}
|
||||
|
||||
/** Get a snapshot of tracked files (for preserving across compaction) */
|
||||
getTrackedFiles(): { edited: string[]; mentioned: string[] } {
|
||||
return {
|
||||
edited: [...this.editedFiles],
|
||||
mentioned: [...this.mentionedFiles],
|
||||
};
|
||||
}
|
||||
|
||||
/** Reset per-conversation tracking (call on new session / context clear) */
|
||||
resetConversationTracking(): void {
|
||||
this.editedFiles.clear();
|
||||
@@ -439,6 +449,13 @@ export class ContextManager {
|
||||
/** Pre-fetch git context (call before buildSystemPrompt) */
|
||||
async refreshGitContext(): Promise<void> {
|
||||
this.gitContext = await buildGitContext(this.cwd);
|
||||
this.gitContextStale = false;
|
||||
}
|
||||
|
||||
/** Refresh git context only if stale (files changed since last refresh) */
|
||||
async ensureGitContext(): Promise<void> {
|
||||
if (!this.gitContextStale) return;
|
||||
await this.refreshGitContext();
|
||||
}
|
||||
|
||||
/** Add a loaded skill to the system prompt */
|
||||
@@ -954,7 +971,7 @@ const STOP_WORDS = new Set([
|
||||
"i",
|
||||
]);
|
||||
|
||||
function extractConversationTerms(input: string): string[] {
|
||||
export function extractConversationTerms(input: string): string[] {
|
||||
const words = input.match(/[A-Za-z_][A-Za-z0-9_]{2,}/g) ?? [];
|
||||
const seen = new Set<string>();
|
||||
const terms: string[] = [];
|
||||
|
||||
+114
-24
@@ -7,6 +7,7 @@ export interface GitStatus {
|
||||
staged: string[];
|
||||
modified: string[];
|
||||
untracked: string[];
|
||||
conflicts: string[];
|
||||
ahead: number;
|
||||
behind: number;
|
||||
}
|
||||
@@ -17,7 +18,7 @@ export interface GitLogEntry {
|
||||
date: string;
|
||||
}
|
||||
|
||||
function run(
|
||||
export function run(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
timeout = 5_000,
|
||||
@@ -31,6 +32,58 @@ function run(
|
||||
});
|
||||
}
|
||||
|
||||
export function parseGitLogLine(line: string): GitLogEntry {
|
||||
const spaceIdx = line.indexOf(" ");
|
||||
if (spaceIdx === -1) return { hash: line, subject: "", date: "" };
|
||||
const hash = line.slice(0, spaceIdx);
|
||||
const rest = line.slice(spaceIdx + 1);
|
||||
const parenIdx = rest.lastIndexOf("(");
|
||||
const subject = parenIdx >= 0 ? rest.slice(0, parenIdx).trim() : rest;
|
||||
const date = parenIdx >= 0 ? rest.slice(parenIdx + 1, -1) : "";
|
||||
return { hash, subject, date };
|
||||
}
|
||||
|
||||
export function unquoteGitPath(path: string): string {
|
||||
if (path.startsWith('"') && path.endsWith('"')) {
|
||||
return path
|
||||
.slice(1, -1)
|
||||
.replace(/\\([ntabr"\\])/g, (_, c: string) => {
|
||||
const map: Record<string, string> = {
|
||||
n: "\n",
|
||||
t: "\t",
|
||||
a: "\x07",
|
||||
b: "\b",
|
||||
r: "\r",
|
||||
'"': '"',
|
||||
"\\": "\\",
|
||||
};
|
||||
return map[c] ?? c;
|
||||
});
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function parseStatusLine(line: string): {
|
||||
x: string;
|
||||
y: string;
|
||||
file: string;
|
||||
category: "untracked" | "staged" | "modified" | "none";
|
||||
} {
|
||||
const x = line[0] ?? "";
|
||||
const y = line[1] ?? "";
|
||||
const raw = line.slice(3);
|
||||
const arrowIdx = raw.indexOf(" -> ");
|
||||
const file = unquoteGitPath(arrowIdx >= 0 ? raw.slice(arrowIdx + 4) : raw);
|
||||
let category: "untracked" | "staged" | "modified" | "none" = "none";
|
||||
if (x === "?") {
|
||||
category = "untracked";
|
||||
} else {
|
||||
if (x && x !== " " && x !== "?") category = "staged";
|
||||
if (y && y !== " " && y !== "?") category = "modified";
|
||||
}
|
||||
return { x, y, file, category };
|
||||
}
|
||||
|
||||
export async function isGitRepo(cwd: string): Promise<boolean> {
|
||||
const { ok } = await run(["rev-parse", "--is-inside-work-tree"], cwd);
|
||||
return ok;
|
||||
@@ -51,6 +104,7 @@ export async function getGitStatus(cwd: string): Promise<GitStatus> {
|
||||
staged: [],
|
||||
modified: [],
|
||||
untracked: [],
|
||||
conflicts: [],
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
};
|
||||
@@ -65,18 +119,21 @@ export async function getGitStatus(cwd: string): Promise<GitStatus> {
|
||||
const staged: string[] = [];
|
||||
const modified: string[] = [];
|
||||
const untracked: string[] = [];
|
||||
const conflicts: string[] = [];
|
||||
|
||||
if (statusResult.ok) {
|
||||
for (const line of statusResult.stdout.split("\n")) {
|
||||
if (!line) continue;
|
||||
const x = line[0];
|
||||
const y = line[1];
|
||||
const file = line.slice(3);
|
||||
if (x === "?") {
|
||||
untracked.push(file);
|
||||
for (const raw of statusResult.stdout.split("\n")) {
|
||||
if (!raw) continue;
|
||||
const parsed = parseStatusLine(raw);
|
||||
const x = raw[0];
|
||||
const y = raw[1];
|
||||
if ((x === "U" || y === "U") || (x === "D" && y === "D") || (x === "A" && y === "A")) {
|
||||
conflicts.push(parsed.file);
|
||||
} else if (parsed.category === "untracked") {
|
||||
untracked.push(parsed.file);
|
||||
} else {
|
||||
if (x && x !== " " && x !== "?") staged.push(file);
|
||||
if (y && y !== " " && y !== "?") modified.push(file);
|
||||
if (x && x !== " " && x !== "?") staged.push(parsed.file);
|
||||
if (y && y !== " " && y !== "?") modified.push(parsed.file);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,6 +153,7 @@ export async function getGitStatus(cwd: string): Promise<GitStatus> {
|
||||
staged,
|
||||
modified,
|
||||
untracked,
|
||||
conflicts,
|
||||
ahead,
|
||||
behind,
|
||||
};
|
||||
@@ -113,19 +171,7 @@ export async function getGitLog(cwd: string, count = 10): Promise<GitLogEntry[]>
|
||||
cwd,
|
||||
);
|
||||
if (!ok) return [];
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const spaceIdx = line.indexOf(" ");
|
||||
const hash = line.slice(0, spaceIdx);
|
||||
const rest = line.slice(spaceIdx + 1);
|
||||
const parenIdx = rest.lastIndexOf("(");
|
||||
const subject = parenIdx > 0 ? rest.slice(0, parenIdx).trim() : rest;
|
||||
const date = parenIdx > 0 ? rest.slice(parenIdx + 1, -1) : "";
|
||||
return { hash, subject, date };
|
||||
});
|
||||
return stdout.trim().split("\n").filter(Boolean).map(parseGitLogLine);
|
||||
}
|
||||
|
||||
export async function gitInit(cwd: string): Promise<boolean> {
|
||||
@@ -185,12 +231,56 @@ export async function gitStashPop(cwd: string): Promise<{ ok: boolean; output: s
|
||||
return { ok, output: stdout };
|
||||
}
|
||||
|
||||
export async function gitStashList(cwd: string): Promise<{ ok: boolean; entries: string[] }> {
|
||||
const { ok, stdout } = await run(["stash", "list"], cwd);
|
||||
if (!ok) return { ok: false, entries: [] };
|
||||
return { ok: true, entries: stdout.trim().split("\n").filter(Boolean) };
|
||||
}
|
||||
|
||||
export async function gitStashShow(cwd: string, index = 0): Promise<{ ok: boolean; output: string }> {
|
||||
const { ok, stdout } = await run(["stash", "show", "-p", `stash@{${String(index)}}`], cwd);
|
||||
return { ok, output: stdout };
|
||||
}
|
||||
|
||||
export async function gitStashDrop(cwd: string, index = 0): Promise<{ ok: boolean; output: string }> {
|
||||
const { ok, stdout } = await run(["stash", "drop", `stash@{${String(index)}}`], cwd);
|
||||
return { ok, output: stdout };
|
||||
}
|
||||
|
||||
export async function gitCreateBranch(
|
||||
cwd: string,
|
||||
name: string,
|
||||
checkout = true,
|
||||
): Promise<{ ok: boolean; output: string }> {
|
||||
if (checkout) {
|
||||
const { ok, stdout } = await run(["checkout", "-b", name], cwd);
|
||||
return { ok, output: stdout };
|
||||
}
|
||||
const { ok, stdout } = await run(["branch", name], cwd);
|
||||
return { ok, output: stdout };
|
||||
}
|
||||
|
||||
export async function gitSwitchBranch(
|
||||
cwd: string,
|
||||
name: string,
|
||||
): Promise<{ ok: boolean; output: string }> {
|
||||
const { ok, stdout } = await run(["checkout", name], cwd);
|
||||
return { ok, output: stdout };
|
||||
}
|
||||
|
||||
export async function buildGitContext(cwd: string): Promise<string | null> {
|
||||
const status = await getGitStatus(cwd);
|
||||
if (!status.isRepo) return null;
|
||||
|
||||
const { ok: upOk, stdout: upOut } = await run(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
|
||||
const upstream = upOk ? upOut.trim() : null;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`Branch: ${status.branch ?? "(detached)"}`);
|
||||
const branchLine = `Branch: ${status.branch ?? "(detached)"}`;
|
||||
lines.push(upstream ? `${branchLine} → ${upstream}` : branchLine);
|
||||
if (status.conflicts.length > 0) {
|
||||
lines.push(`⚠ Merge conflicts (${String(status.conflicts.length)}): ${status.conflicts.join(", ")}`);
|
||||
}
|
||||
if (status.isDirty) {
|
||||
const parts: string[] = [];
|
||||
if (status.staged.length > 0) parts.push(`${String(status.staged.length)} staged`);
|
||||
|
||||
@@ -245,8 +245,9 @@ export async function getCodeActions(
|
||||
endCol: number,
|
||||
diagnosticCodes?: (string | number)[],
|
||||
): Promise<LspCodeAction[] | null> {
|
||||
const escapeLua = (s: string) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
const codesFilter = diagnosticCodes
|
||||
? `local filter_codes = {${diagnosticCodes.map((c) => (typeof c === "string" ? `'${c}'` : String(c))).join(",")}}
|
||||
? `local filter_codes = {${diagnosticCodes.map((c) => (typeof c === "string" ? `'${escapeLua(c)}'` : String(c))).join(",")}}
|
||||
for _, d in ipairs(vim.diagnostic.get(bufnr)) do
|
||||
for _, code in ipairs(filter_codes) do
|
||||
if tostring(d.code) == tostring(code) then
|
||||
|
||||
@@ -136,6 +136,49 @@ const QUERIES: Record<string, string> = {
|
||||
bash: `
|
||||
(function_definition name: (word) @name) @func
|
||||
`,
|
||||
ocaml: `
|
||||
(value_definition (let_binding pattern: (value_name) @name)) @func
|
||||
(type_definition (type_binding name: (type_constructor) @name)) @type
|
||||
(module_definition (module_binding name: (module_name) @name)) @type
|
||||
(open_module) @import
|
||||
`,
|
||||
objc: `
|
||||
(function_definition declarator: (function_declarator declarator: (identifier) @name)) @func
|
||||
(class_interface . (identifier) @name) @class
|
||||
(protocol_declaration . (identifier) @name) @iface
|
||||
(preproc_include) @import
|
||||
`,
|
||||
css: `
|
||||
(rule_set (selectors) @name) @var
|
||||
(keyframes_statement (keyframes_name) @name) @type
|
||||
`,
|
||||
html: `
|
||||
(element (start_tag (tag_name) @name)) @var
|
||||
`,
|
||||
vue: `
|
||||
(element (start_tag (tag_name) @name)) @var
|
||||
`,
|
||||
rescript: `
|
||||
(let_declaration (let_binding pattern: (value_identifier) @name)) @func
|
||||
(type_declaration (type_binding name: (type_identifier) @name)) @type
|
||||
(module_declaration (module_binding name: (module_identifier) @name)) @type
|
||||
`,
|
||||
solidity: `
|
||||
(contract_declaration name: (identifier) @name) @class
|
||||
(function_definition name: (identifier) @name) @func
|
||||
(event_definition name: (identifier) @name) @type
|
||||
(struct_declaration name: (identifier) @name) @struct
|
||||
(enum_declaration name: (identifier) @name) @type
|
||||
(import_directive) @import
|
||||
`,
|
||||
tlaplus: `
|
||||
(operator_definition name: (identifier) @name) @func
|
||||
(function_definition name: (identifier) @name) @func
|
||||
`,
|
||||
elisp: `
|
||||
(function_definition name: (symbol) @name) @func
|
||||
(special_form . (symbol) @name) @var
|
||||
`,
|
||||
};
|
||||
|
||||
const GRAMMAR_FILES: Record<string, string> = {
|
||||
@@ -158,6 +201,18 @@ const GRAMMAR_FILES: Record<string, string> = {
|
||||
dart: "tree-sitter-dart.wasm",
|
||||
zig: "tree-sitter-zig.wasm",
|
||||
bash: "tree-sitter-bash.wasm",
|
||||
tsx: "tree-sitter-tsx.wasm",
|
||||
ocaml: "tree-sitter-ocaml.wasm",
|
||||
objc: "tree-sitter-objc.wasm",
|
||||
css: "tree-sitter-css.wasm",
|
||||
html: "tree-sitter-html.wasm",
|
||||
json: "tree-sitter-json.wasm",
|
||||
toml: "tree-sitter-toml.wasm",
|
||||
vue: "tree-sitter-vue.wasm",
|
||||
rescript: "tree-sitter-rescript.wasm",
|
||||
solidity: "tree-sitter-solidity.wasm",
|
||||
tlaplus: "tree-sitter-tlaplus.wasm",
|
||||
elisp: "tree-sitter-elisp.wasm",
|
||||
};
|
||||
|
||||
// Dynamically import web-tree-sitter types
|
||||
@@ -204,6 +259,23 @@ const EXT_TO_LANG: Record<string, Language> = {
|
||||
".sh": "bash",
|
||||
".bash": "bash",
|
||||
".zsh": "bash",
|
||||
".ml": "ocaml",
|
||||
".mli": "ocaml",
|
||||
".m": "objc",
|
||||
".css": "css",
|
||||
".scss": "css",
|
||||
".less": "css",
|
||||
".html": "html",
|
||||
".htm": "html",
|
||||
".json": "json",
|
||||
".jsonc": "json",
|
||||
".toml": "toml",
|
||||
".vue": "vue",
|
||||
".res": "rescript",
|
||||
".resi": "rescript",
|
||||
".sol": "solidity",
|
||||
".tla": "tlaplus",
|
||||
".el": "elisp",
|
||||
};
|
||||
|
||||
// Store the module reference for Query construction
|
||||
@@ -676,7 +748,8 @@ export class TreeSitterBackend implements IntelligenceBackend {
|
||||
}
|
||||
|
||||
const language = this.detectLang(file);
|
||||
const lang = await this.loadLanguage(language);
|
||||
const grammarKey = language === "typescript" && /\.tsx$/i.test(file) ? "tsx" : language;
|
||||
const lang = await this.loadLanguage(grammarKey);
|
||||
if (!lang) return null;
|
||||
|
||||
this.parser.setLanguage(lang);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { extname, join, relative } from "node:path";
|
||||
import { isForbidden } from "../security/forbidden.js";
|
||||
import type { Language, SymbolKind } from "./types.js";
|
||||
@@ -257,6 +257,11 @@ export class RepoMap {
|
||||
this.db = new Database(dbPath);
|
||||
this.db.run("PRAGMA journal_mode = WAL");
|
||||
this.db.run("PRAGMA foreign_keys = ON");
|
||||
for (const suffix of ["", "-wal", "-shm"]) {
|
||||
try {
|
||||
chmodSync(dbPath + suffix, 0o600);
|
||||
} catch {}
|
||||
}
|
||||
this.initSchema();
|
||||
}
|
||||
|
||||
@@ -1212,6 +1217,11 @@ export class RepoMap {
|
||||
|
||||
onFileChanged(absPath: string): void {
|
||||
const relPath = relative(this.cwd, absPath);
|
||||
|
||||
if (relPath === "package.json" || relPath === "Cargo.toml" || relPath === "go.mod") {
|
||||
this.entryPointsCache = null;
|
||||
}
|
||||
|
||||
const ext = extname(absPath).toLowerCase();
|
||||
const language = INDEXABLE_EXTENSIONS[ext];
|
||||
if (!language) return;
|
||||
|
||||
@@ -42,6 +42,23 @@ const EXT_TO_LANGUAGE: Record<string, Language> = {
|
||||
".sh": "bash",
|
||||
".bash": "bash",
|
||||
".zsh": "bash",
|
||||
".ml": "ocaml",
|
||||
".mli": "ocaml",
|
||||
".m": "objc",
|
||||
".css": "css",
|
||||
".scss": "css",
|
||||
".less": "css",
|
||||
".html": "html",
|
||||
".htm": "html",
|
||||
".json": "json",
|
||||
".jsonc": "json",
|
||||
".toml": "toml",
|
||||
".vue": "vue",
|
||||
".res": "rescript",
|
||||
".resi": "rescript",
|
||||
".sol": "solidity",
|
||||
".tla": "tlaplus",
|
||||
".el": "elisp",
|
||||
};
|
||||
|
||||
const PROJECT_FILE_TO_LANGUAGE: Record<string, Language> = {
|
||||
|
||||
@@ -21,6 +21,17 @@ export type Language =
|
||||
| "dart"
|
||||
| "zig"
|
||||
| "bash"
|
||||
| "ocaml"
|
||||
| "objc"
|
||||
| "css"
|
||||
| "html"
|
||||
| "json"
|
||||
| "toml"
|
||||
| "vue"
|
||||
| "rescript"
|
||||
| "solidity"
|
||||
| "tlaplus"
|
||||
| "elisp"
|
||||
| "unknown";
|
||||
|
||||
/** A location in source code */
|
||||
|
||||
@@ -25,9 +25,9 @@ export const google: ProviderDefinition = {
|
||||
async fetchModels(): Promise<ProviderModelInfo[] | null> {
|
||||
const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
const res = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`,
|
||||
);
|
||||
const res = await fetch("https://generativelanguage.googleapis.com/v1beta/models", {
|
||||
headers: { "x-goog-api-key": apiKey },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Google API ${String(res.status)}`);
|
||||
const data = (await res.json()) as { models: GoogleModel[] };
|
||||
const result: ProviderModelInfo[] = [];
|
||||
|
||||
+32
-1
@@ -1,5 +1,5 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { chmodSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type {
|
||||
MemoryCategory,
|
||||
@@ -41,6 +41,13 @@ export class MemoryDB {
|
||||
this.db = new Database(dbPath);
|
||||
this.db.run("PRAGMA journal_mode = WAL");
|
||||
this.db.run("PRAGMA foreign_keys = ON");
|
||||
if (dbPath !== ":memory:") {
|
||||
for (const suffix of ["", "-wal", "-shm"]) {
|
||||
try {
|
||||
chmodSync(dbPath + suffix, 0o600);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -171,6 +178,30 @@ export class MemoryDB {
|
||||
}
|
||||
}
|
||||
|
||||
searchFull(query: string, limit = 5): MemoryRecord[] {
|
||||
const words = query.split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return [];
|
||||
|
||||
const ftsQuery = words.map((w) => `"${w.replace(/"/g, "")}"`).join(" OR ");
|
||||
|
||||
try {
|
||||
const rows = this.db
|
||||
.query<RawRow, [string, number]>(
|
||||
`SELECT m.*
|
||||
FROM memories_fts f
|
||||
JOIN memories m ON m.rowid = f.rowid
|
||||
WHERE memories_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(ftsQuery, limit);
|
||||
|
||||
return rows.map(toRecord);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
delete(id: string): boolean {
|
||||
const result = this.db.query("DELETE FROM memories WHERE id = ?").run(id);
|
||||
return result.changes > 0;
|
||||
|
||||
@@ -175,6 +175,38 @@ export class MemoryManager {
|
||||
return db.list().map((m) => ({ ...m, scope }));
|
||||
}
|
||||
|
||||
autoRecall(userMessage: string): string | null {
|
||||
const readScope = this._scopeConfig.readScope;
|
||||
if (readScope === "none") return null;
|
||||
|
||||
const keywords = extractKeywords(userMessage);
|
||||
if (keywords.length === 0) return null;
|
||||
|
||||
const query = keywords.join(" ");
|
||||
const seen = new Set<string>();
|
||||
const hits: import("./types.js").MemoryRecord[] = [];
|
||||
|
||||
const dbs =
|
||||
readScope === "all" || (readScope as string) === "both"
|
||||
? [this.projectDb, this.globalDb]
|
||||
: readScope === "project"
|
||||
? [this.projectDb]
|
||||
: [this.globalDb];
|
||||
|
||||
for (const db of dbs) {
|
||||
for (const record of db.searchFull(query, 3)) {
|
||||
if (seen.has(record.id)) continue;
|
||||
seen.add(record.id);
|
||||
hits.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
if (hits.length === 0) return null;
|
||||
|
||||
const parts = hits.map((m) => `**${m.title}** (${m.category})\n${m.content}`);
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
buildMemoryIndex(): string | null {
|
||||
const projectIdx = this.projectDb.getIndex();
|
||||
const globalIdx = this.globalDb.getIndex();
|
||||
@@ -209,3 +241,164 @@ export class MemoryManager {
|
||||
this.projectDb.close();
|
||||
}
|
||||
}
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
"a",
|
||||
"an",
|
||||
"the",
|
||||
"is",
|
||||
"are",
|
||||
"was",
|
||||
"were",
|
||||
"be",
|
||||
"been",
|
||||
"being",
|
||||
"have",
|
||||
"has",
|
||||
"had",
|
||||
"do",
|
||||
"does",
|
||||
"did",
|
||||
"will",
|
||||
"would",
|
||||
"could",
|
||||
"should",
|
||||
"may",
|
||||
"might",
|
||||
"shall",
|
||||
"can",
|
||||
"need",
|
||||
"must",
|
||||
"i",
|
||||
"me",
|
||||
"my",
|
||||
"we",
|
||||
"our",
|
||||
"you",
|
||||
"your",
|
||||
"he",
|
||||
"she",
|
||||
"it",
|
||||
"they",
|
||||
"them",
|
||||
"this",
|
||||
"that",
|
||||
"these",
|
||||
"those",
|
||||
"what",
|
||||
"which",
|
||||
"who",
|
||||
"whom",
|
||||
"how",
|
||||
"when",
|
||||
"where",
|
||||
"why",
|
||||
"and",
|
||||
"or",
|
||||
"but",
|
||||
"not",
|
||||
"no",
|
||||
"so",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"for",
|
||||
"of",
|
||||
"in",
|
||||
"on",
|
||||
"at",
|
||||
"to",
|
||||
"from",
|
||||
"by",
|
||||
"with",
|
||||
"about",
|
||||
"into",
|
||||
"through",
|
||||
"during",
|
||||
"before",
|
||||
"after",
|
||||
"above",
|
||||
"below",
|
||||
"up",
|
||||
"down",
|
||||
"out",
|
||||
"off",
|
||||
"over",
|
||||
"under",
|
||||
"again",
|
||||
"just",
|
||||
"also",
|
||||
"too",
|
||||
"very",
|
||||
"really",
|
||||
"quite",
|
||||
"some",
|
||||
"any",
|
||||
"all",
|
||||
"each",
|
||||
"every",
|
||||
"both",
|
||||
"few",
|
||||
"more",
|
||||
"most",
|
||||
"other",
|
||||
"than",
|
||||
"get",
|
||||
"got",
|
||||
"make",
|
||||
"made",
|
||||
"let",
|
||||
"use",
|
||||
"using",
|
||||
"used",
|
||||
"want",
|
||||
"like",
|
||||
"know",
|
||||
"think",
|
||||
"see",
|
||||
"look",
|
||||
"find",
|
||||
"give",
|
||||
"tell",
|
||||
"try",
|
||||
"take",
|
||||
"come",
|
||||
"go",
|
||||
"put",
|
||||
"run",
|
||||
"say",
|
||||
"said",
|
||||
"here",
|
||||
"there",
|
||||
"now",
|
||||
"still",
|
||||
"already",
|
||||
"yet",
|
||||
"file",
|
||||
"files",
|
||||
"code",
|
||||
"please",
|
||||
"thanks",
|
||||
"help",
|
||||
"ok",
|
||||
"sure",
|
||||
"hey",
|
||||
"hi",
|
||||
"hello",
|
||||
"yeah",
|
||||
"yes",
|
||||
"no",
|
||||
"right",
|
||||
"well",
|
||||
]);
|
||||
|
||||
function extractKeywords(message: string): string[] {
|
||||
const words = message
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_\-/.]+/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 2 && !STOP_WORDS.has(w));
|
||||
|
||||
const unique = [...new Set(words)];
|
||||
return unique.slice(0, 8);
|
||||
}
|
||||
@@ -11,7 +11,8 @@ export type MemoryCategory =
|
||||
| "preference"
|
||||
| "architecture"
|
||||
| "pattern"
|
||||
| "fact";
|
||||
| "fact"
|
||||
| "checkpoint";
|
||||
|
||||
export const MEMORY_CATEGORIES: MemoryCategory[] = [
|
||||
"decision",
|
||||
@@ -20,6 +21,7 @@ export const MEMORY_CATEGORIES: MemoryCategory[] = [
|
||||
"architecture",
|
||||
"pattern",
|
||||
"fact",
|
||||
"checkpoint",
|
||||
];
|
||||
|
||||
export interface MemoryRecord {
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ function fileRead(): Record<string, string> {
|
||||
|
||||
function fileWrite(data: Record<string, string>): void {
|
||||
if (!existsSync(SECRETS_DIR)) {
|
||||
mkdirSync(SECRETS_DIR, { recursive: true });
|
||||
mkdirSync(SECRETS_DIR, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
writeFileSync(SECRETS_FILE, JSON.stringify(data, null, 2));
|
||||
chmodSync(SECRETS_FILE, 0o600);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
|
||||
@@ -208,7 +208,12 @@ export function getAllPatterns(tabId?: string): {
|
||||
export function isForbidden(filePath: string, tabId?: string): string | null {
|
||||
if (!initialized) return null;
|
||||
|
||||
const resolved = resolve(filePath);
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = realpathSync(filePath);
|
||||
} catch {
|
||||
resolved = resolve(filePath);
|
||||
}
|
||||
const name = basename(resolved);
|
||||
|
||||
const allPatterns = [
|
||||
|
||||
@@ -51,7 +51,7 @@ export class SessionManager {
|
||||
this.ensureDir();
|
||||
const sessionDir = join(this.dir, meta.id);
|
||||
if (!existsSync(sessionDir)) {
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
const allMessages: ChatMessage[] = [];
|
||||
@@ -69,11 +69,14 @@ export class SessionManager {
|
||||
|
||||
const updatedMeta: SessionMeta = { ...meta, tabs: updatedTabs };
|
||||
const metaPath = join(sessionDir, "meta.json");
|
||||
writeFileSync(metaPath, JSON.stringify(updatedMeta, null, 2), "utf-8");
|
||||
writeFileSync(metaPath, JSON.stringify(updatedMeta, null, 2), {
|
||||
encoding: "utf-8",
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
const jsonlPath = join(sessionDir, "messages.jsonl");
|
||||
const lines = allMessages.map((m) => JSON.stringify(m)).join("\n");
|
||||
writeFileSync(jsonlPath, lines ? `${lines}\n` : "", "utf-8");
|
||||
writeFileSync(jsonlPath, lines ? `${lines}\n` : "", { encoding: "utf-8", mode: 0o600 });
|
||||
}
|
||||
|
||||
loadSession(id: string): { meta: SessionMeta; tabMessages: Map<string, ChatMessage[]> } | null {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { ToolResult } from "../../types/index.js";
|
||||
@@ -251,10 +251,12 @@ export const analyzeTool = {
|
||||
let oldContent = args.oldContent;
|
||||
if (!oldContent) {
|
||||
try {
|
||||
oldContent = execSync(`git show HEAD:${file}`, {
|
||||
const gitResult = spawnSync("git", ["show", `HEAD:${file}`], {
|
||||
encoding: "utf-8",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
if (gitResult.status !== 0) throw new Error(gitResult.stderr);
|
||||
oldContent = gitResult.stdout;
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -5,6 +5,54 @@ import { getSecret } from "../secrets.js";
|
||||
|
||||
const MAX_CONTENT_LENGTH = 16_000;
|
||||
|
||||
export function isPrivateHostname(hostname: string): boolean {
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
hostname === "0.0.0.0" ||
|
||||
hostname === "::1" ||
|
||||
hostname.startsWith("127.") ||
|
||||
hostname.startsWith("10.") ||
|
||||
hostname.startsWith("192.168.") ||
|
||||
hostname === "169.254.169.254" ||
|
||||
hostname.endsWith(".internal") ||
|
||||
hostname.endsWith(".local") ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
|
||||
)
|
||||
return true;
|
||||
// IPv6 private ranges
|
||||
const lower = hostname.toLowerCase();
|
||||
if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // ULA fc00::/7
|
||||
if (
|
||||
lower.startsWith("fe8") ||
|
||||
lower.startsWith("fe9") ||
|
||||
lower.startsWith("fea") ||
|
||||
lower.startsWith("feb")
|
||||
)
|
||||
return true; // link-local fe80::/10
|
||||
if (lower.startsWith("::ffff:")) {
|
||||
const mapped = lower.slice(7);
|
||||
if (isPrivateHostname(mapped)) return true;
|
||||
}
|
||||
// Decimal IP (e.g. 2130706433 = 127.0.0.1)
|
||||
if (/^\d{8,10}$/.test(hostname)) return true;
|
||||
// Octal IP (e.g. 0177.0.0.1)
|
||||
if (/^0\d+\./.test(hostname)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function validateUrl(raw: string): string | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
return "Invalid URL";
|
||||
}
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) return `Blocked protocol: ${parsed.protocol}`;
|
||||
const hostname = parsed.hostname.replace(/^\[|]$/g, "");
|
||||
if (isPrivateHostname(hostname)) return `Blocked private/reserved address: ${hostname}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
const pageCache = new Map<string, { content: string; ts: number; backend: string }>();
|
||||
const CACHE_TTL = 5 * 60_000;
|
||||
const MAX_CACHE_SIZE = 100;
|
||||
@@ -102,6 +150,9 @@ export const fetchPageTool = {
|
||||
description:
|
||||
"Fetch a web page and extract its text content. Use after web_search to read full articles or documentation pages.",
|
||||
execute: async (args: { url: string }): Promise<ToolResult> => {
|
||||
const urlError = validateUrl(args.url);
|
||||
if (urlError) return { success: false, output: urlError, error: urlError };
|
||||
|
||||
const cached = getCached(args.url);
|
||||
if (cached) {
|
||||
return { success: true, output: truncate(cached.content), backend: cached.backend };
|
||||
|
||||
+89
-6
@@ -5,10 +5,16 @@ import {
|
||||
getGitStatus,
|
||||
gitAdd,
|
||||
gitCommit,
|
||||
gitCreateBranch,
|
||||
gitPull,
|
||||
gitPush,
|
||||
gitStash,
|
||||
gitStashDrop,
|
||||
gitStashList,
|
||||
gitStashPop,
|
||||
gitStashShow,
|
||||
gitSwitchBranch,
|
||||
run,
|
||||
} from "../git/status.js";
|
||||
|
||||
const cwd = process.cwd();
|
||||
@@ -27,6 +33,8 @@ export const gitStatusTool = {
|
||||
lines.push(`Modified (${String(s.modified.length)}): ${s.modified.join(", ")}`);
|
||||
if (s.untracked.length > 0)
|
||||
lines.push(`Untracked (${String(s.untracked.length)}): ${s.untracked.join(", ")}`);
|
||||
if (s.conflicts.length > 0)
|
||||
lines.push(`⚠ Conflicts (${String(s.conflicts.length)}): ${s.conflicts.join(", ")}`);
|
||||
if (s.ahead > 0 || s.behind > 0)
|
||||
lines.push(`Ahead: ${String(s.ahead)} | Behind: ${String(s.behind)}`);
|
||||
lines.push(s.isDirty ? "Status: dirty" : "Status: clean");
|
||||
@@ -58,14 +66,27 @@ export const gitLogTool = {
|
||||
|
||||
export const gitCommitTool = {
|
||||
name: "git_commit" as const,
|
||||
description: "Stage files and commit. If files is omitted, commits currently staged files.",
|
||||
description:
|
||||
"Stage files and commit. Returns the staged diff summary and commit result. If files is omitted, commits currently staged files.",
|
||||
execute: async (args: { message: string; files?: string[] }): Promise<ToolResult> => {
|
||||
if (args.files && args.files.length > 0) {
|
||||
const ok = await gitAdd(cwd, args.files);
|
||||
if (!ok) return { success: false, output: "Failed to stage files", error: "staging failed" };
|
||||
}
|
||||
const diff = await getGitDiff(cwd, true);
|
||||
if (!diff) {
|
||||
return { success: false, output: "Nothing staged to commit. Stage files first.", error: "nothing staged" };
|
||||
}
|
||||
const result = await gitCommit(cwd, args.message);
|
||||
return { success: result.ok, output: result.output };
|
||||
if (!result.ok) return { success: false, output: result.output, error: "commit failed" };
|
||||
const diffLines = diff.split("\n");
|
||||
const statLines = diffLines.filter(l => l.startsWith("+++") || l.startsWith("---")).length;
|
||||
const additions = diffLines.filter(l => l.startsWith("+") && !l.startsWith("+++")).length;
|
||||
const deletions = diffLines.filter(l => l.startsWith("-") && !l.startsWith("---")).length;
|
||||
return {
|
||||
success: true,
|
||||
output: `${result.output}\n\nDiff summary: ~${String(statLines / 2)} files, +${String(additions)} -${String(deletions)} lines`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -89,9 +110,71 @@ export const gitPullTool = {
|
||||
|
||||
export const gitStashTool = {
|
||||
name: "git_stash" as const,
|
||||
description: "Stash or pop changes. Set pop=true to pop the latest stash.",
|
||||
execute: async (args: { pop?: boolean; message?: string }): Promise<ToolResult> => {
|
||||
const result = args.pop ? await gitStashPop(cwd) : await gitStash(cwd, args.message);
|
||||
return { success: result.ok, output: result.output };
|
||||
description:
|
||||
"Manage stashes. Actions: 'push' (default), 'pop', 'list', 'show', 'drop'. Index selects which stash (default 0).",
|
||||
execute: async (args: {
|
||||
action?: "push" | "pop" | "list" | "show" | "drop";
|
||||
message?: string;
|
||||
index?: number;
|
||||
}): Promise<ToolResult> => {
|
||||
const action = args.action ?? "push";
|
||||
switch (action) {
|
||||
case "list": {
|
||||
const { ok, entries } = await gitStashList(cwd);
|
||||
if (!ok) return { success: false, output: "Failed to list stashes", error: "stash list failed" };
|
||||
return { success: true, output: entries.length > 0 ? entries.join("\n") : "No stashes." };
|
||||
}
|
||||
case "show": {
|
||||
const { ok, output } = await gitStashShow(cwd, args.index ?? 0);
|
||||
return { success: ok, output: output || "Empty stash." };
|
||||
}
|
||||
case "drop": {
|
||||
const { ok, output } = await gitStashDrop(cwd, args.index ?? 0);
|
||||
return { success: ok, output };
|
||||
}
|
||||
case "pop": {
|
||||
const result = await gitStashPop(cwd);
|
||||
return { success: result.ok, output: result.output };
|
||||
}
|
||||
default: {
|
||||
const result = await gitStash(cwd, args.message);
|
||||
return { success: result.ok, output: result.output };
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const gitBranchTool = {
|
||||
name: "git_branch" as const,
|
||||
description:
|
||||
"Branch operations: 'list' (default), 'create', 'switch', 'delete'. Creates and switches in one step by default.",
|
||||
execute: async (args: {
|
||||
action?: "list" | "create" | "switch" | "delete";
|
||||
name?: string;
|
||||
}): Promise<ToolResult> => {
|
||||
const action = args.action ?? "list";
|
||||
switch (action) {
|
||||
case "list": {
|
||||
const { ok, stdout } = await run(["branch", "-vv"], cwd);
|
||||
return { success: ok, output: stdout || "No branches." };
|
||||
}
|
||||
case "create": {
|
||||
if (!args.name) return { success: false, output: "Branch name required", error: "missing name" };
|
||||
const { ok, output } = await gitCreateBranch(cwd, args.name);
|
||||
return { success: ok, output: output || `Created and switched to ${args.name}` };
|
||||
}
|
||||
case "switch": {
|
||||
if (!args.name) return { success: false, output: "Branch name required", error: "missing name" };
|
||||
const { ok, output } = await gitSwitchBranch(cwd, args.name);
|
||||
return { success: ok, output: output || `Switched to ${args.name}` };
|
||||
}
|
||||
case "delete": {
|
||||
if (!args.name) return { success: false, output: "Branch name required", error: "missing name" };
|
||||
const { ok, stdout } = await run(["branch", "-d", args.name], cwd);
|
||||
return { success: ok, output: stdout || `Deleted ${args.name}` };
|
||||
}
|
||||
default:
|
||||
return { success: false, output: `Unknown action: ${action}`, error: "bad action" };
|
||||
}
|
||||
},
|
||||
};
|
||||
+64
-7
@@ -25,6 +25,7 @@ import {
|
||||
editorSymbolsTool,
|
||||
} from "./editor";
|
||||
import {
|
||||
gitBranchTool,
|
||||
gitCommitTool,
|
||||
gitDiffTool,
|
||||
gitLogTool,
|
||||
@@ -384,9 +385,8 @@ export function buildTools(
|
||||
"format_range",
|
||||
"organize_imports",
|
||||
])
|
||||
.describe("Action to perform. For renaming symbols, use rename_symbol instead."),
|
||||
.describe("Action to perform"),
|
||||
file: z.string().optional().describe("Target file"),
|
||||
symbol: z.string().optional().describe("Symbol name"),
|
||||
newName: z.string().optional().describe("New name for extracted symbol"),
|
||||
startLine: z.number().optional().describe("Start line for extraction or range formatting"),
|
||||
endLine: z.number().optional().describe("End line for extraction or range formatting"),
|
||||
@@ -511,18 +511,58 @@ export function buildTools(
|
||||
git_stash: tool({
|
||||
description: gitStashTool.description,
|
||||
inputSchema: z.object({
|
||||
pop: z.boolean().optional().describe("Pop the latest stash instead of stashing"),
|
||||
message: z.string().optional().describe("Stash message"),
|
||||
action: z.enum(["push", "pop", "list", "show", "drop"]).optional().describe("Stash action (default: push)"),
|
||||
message: z.string().optional().describe("Stash message (for push)"),
|
||||
index: z.number().optional().describe("Stash index (for show/drop, default 0)"),
|
||||
}),
|
||||
execute: deferExecute((args) => gitStashTool.execute(args)),
|
||||
}),
|
||||
|
||||
git_branch: tool({
|
||||
description: gitBranchTool.description,
|
||||
inputSchema: z.object({
|
||||
action: z.enum(["list", "create", "switch", "delete"]).optional().describe("Branch action (default: list)"),
|
||||
name: z.string().optional().describe("Branch name (for create/switch/delete)"),
|
||||
}),
|
||||
execute: deferExecute((args) => gitBranchTool.execute(args)),
|
||||
}),
|
||||
|
||||
...(opts?.codeExecution
|
||||
? { code_execution: createAnthropic().tools.codeExecution_20260120() }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Tool names allowed in restricted modes (architect, socratic, challenge).
|
||||
* Read/analysis + memory + editor read — NO edit/shell/git/refactor.
|
||||
* Used with activeTools to restrict without rebuilding the tool set. */
|
||||
export const RESTRICTED_TOOL_NAMES: string[] = [
|
||||
"read_file",
|
||||
"grep",
|
||||
"glob",
|
||||
"web_search",
|
||||
"editor_read",
|
||||
"editor_navigate",
|
||||
"editor_diagnostics",
|
||||
"editor_symbols",
|
||||
"editor_hover",
|
||||
"editor_references",
|
||||
"editor_definition",
|
||||
"editor_lsp_status",
|
||||
"navigate",
|
||||
"read_code",
|
||||
"analyze",
|
||||
"discover_pattern",
|
||||
"memory_read",
|
||||
"memory_write",
|
||||
"memory_list",
|
||||
"memory_search",
|
||||
"dispatch",
|
||||
"ask_user",
|
||||
"plan",
|
||||
"update_plan_step",
|
||||
];
|
||||
|
||||
/** Read-only tools for restricted modes (architect, socratic, challenge).
|
||||
* Includes all read/analysis + memory + editor read, but NO edit/shell/git/refactor.
|
||||
* The LLM physically cannot bypass the mode — these tools don't exist on the agent. */
|
||||
@@ -942,10 +982,17 @@ export function wrapWithBusCache(
|
||||
return (async (args: Record<string, unknown>, opts: unknown) => {
|
||||
const key = keyFn(args);
|
||||
if (key) {
|
||||
const cached = bus.acquireToolResult(agentId, key);
|
||||
if (cached) {
|
||||
const acquired = bus.acquireToolResult(agentId, key);
|
||||
if (acquired.hit === true) {
|
||||
onExecute?.(args, true);
|
||||
return cached;
|
||||
return acquired.result;
|
||||
}
|
||||
if (acquired.hit === "waiting") {
|
||||
const waited = await acquired.result;
|
||||
if (waited != null) {
|
||||
onExecute?.(args, true);
|
||||
return waited;
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = await origExecute(args, opts);
|
||||
@@ -998,7 +1045,17 @@ export function wrapWithBusCache(
|
||||
bus.recordFileRead(agentId, normalized, { tool: "read_file", cached: true });
|
||||
return tagCacheHit(content, normalized);
|
||||
}
|
||||
const reAcquired = bus.acquireFileRead(agentId, normalized);
|
||||
if (reAcquired.cached === true && reAcquired.content != null) {
|
||||
bus.recordFileRead(agentId, normalized, { tool: "read_file", cached: true });
|
||||
return tagCacheHit(reAcquired.content, normalized);
|
||||
}
|
||||
const fallbackGen = reAcquired.cached === false ? reAcquired.gen : -1;
|
||||
const result = await origExecute(args, opts);
|
||||
if (fallbackGen >= 0) {
|
||||
const text = typeof result === "string" ? result : JSON.stringify(result);
|
||||
bus.releaseFileRead(normalized, text, fallbackGen);
|
||||
}
|
||||
bus.recordFileRead(agentId, normalized, { tool: "read_file", cached: false });
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const categorySchema = z
|
||||
export function createMemoryTools(manager: MemoryManager) {
|
||||
const memory_write = tool({
|
||||
description:
|
||||
"Write or update a memory. Use for recording decisions, conventions, patterns, preferences, architecture notes, or facts.",
|
||||
"Write or update a memory. Use for recording decisions, conventions, patterns, preferences, architecture notes, facts, or task checkpoints (progress snapshots for long multi-step work).",
|
||||
inputSchema: z.object({
|
||||
scope: scopeSchema.optional().describe("Memory scope (defaults to configured write scope)"),
|
||||
title: z.string().describe("Short descriptive title"),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { dirname, relative, resolve } from "node:path";
|
||||
import type { ToolResult } from "../../types/index.js";
|
||||
import { getIntelligenceRouter } from "../intelligence/index.js";
|
||||
import type { Language } from "../intelligence/types.js";
|
||||
import { isForbidden } from "../security/forbidden.js";
|
||||
|
||||
interface PendingWrite {
|
||||
path: string;
|
||||
@@ -20,6 +21,10 @@ class WriteTransaction {
|
||||
}
|
||||
|
||||
commit(): void {
|
||||
for (const w of this.writes) {
|
||||
const blocked = isForbidden(w.path);
|
||||
if (blocked) throw new Error(`Cannot write forbidden file: ${w.path} (${blocked})`);
|
||||
}
|
||||
for (const w of this.writes) {
|
||||
const dir = dirname(w.path);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
@@ -58,7 +63,7 @@ interface MoveSymbolArgs {
|
||||
|
||||
// ─── Import Handling ──────────────────────────────────────────
|
||||
|
||||
interface ImportStatement {
|
||||
export interface ImportStatement {
|
||||
full: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
@@ -76,17 +81,17 @@ interface LangImports {
|
||||
generate(specs: string[], path: string, isType?: boolean): string;
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
export function esc(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function line(lines: string[], i: number): string {
|
||||
export function line(lines: string[], i: number): string {
|
||||
return lines[i] ?? "";
|
||||
}
|
||||
|
||||
// ── TypeScript / JavaScript ──
|
||||
|
||||
const tsJsHandler: LangImports = {
|
||||
export const tsJsHandler: LangImports = {
|
||||
canAutoUpdate: true,
|
||||
|
||||
parse(content: string): ImportStatement[] {
|
||||
@@ -163,7 +168,7 @@ const tsJsHandler: LangImports = {
|
||||
|
||||
// ── Python ──
|
||||
|
||||
const pythonHandler: LangImports = {
|
||||
export const pythonHandler: LangImports = {
|
||||
canAutoUpdate: true,
|
||||
|
||||
parse(content: string): ImportStatement[] {
|
||||
@@ -226,7 +231,7 @@ const pythonHandler: LangImports = {
|
||||
|
||||
// ── Rust ──
|
||||
|
||||
const rustHandler: LangImports = {
|
||||
export const rustHandler: LangImports = {
|
||||
canAutoUpdate: true,
|
||||
|
||||
parse(content: string): ImportStatement[] {
|
||||
@@ -401,7 +406,10 @@ function getLangHandler(lang: Language): LangImports | null {
|
||||
|
||||
// ─── Symbol Extraction ────────────────────────────────────────
|
||||
|
||||
function findSymbolRange(lines: string[], symbol: string): { start: number; end: number } | null {
|
||||
export function findSymbolRange(
|
||||
lines: string[],
|
||||
symbol: string,
|
||||
): { start: number; end: number } | null {
|
||||
const pat = new RegExp(
|
||||
`^\\s*(export\\s+)?(default\\s+)?(pub(\\(crate\\))?\\s+)?(interface|type|class|function|enum|const|let|var|struct|trait|impl|fn|def|func|abstract\\s+class)\\s+${esc(symbol)}\\b`,
|
||||
);
|
||||
@@ -433,7 +441,7 @@ function findSymbolRange(lines: string[], symbol: string): { start: number; end:
|
||||
return null;
|
||||
}
|
||||
|
||||
function findCommentStart(lines: string[], defStart: number): number {
|
||||
export function findCommentStart(lines: string[], defStart: number): number {
|
||||
let start = defStart;
|
||||
for (let i = defStart - 1; i >= 0; i--) {
|
||||
const t = line(lines, i).trim();
|
||||
|
||||
@@ -344,7 +344,7 @@ export const projectTool = {
|
||||
}
|
||||
|
||||
if (command && args.flags) {
|
||||
command = `${command} ${args.flags}`;
|
||||
command = `${command} ${shellQuote(args.flags)}`;
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getIntelligenceRouter } from "../intelligence/index.js";
|
||||
import type { FileEdit, FormatEdit, RefactorResult } from "../intelligence/types.js";
|
||||
|
||||
type RefactorAction =
|
||||
| "rename"
|
||||
| "extract_function"
|
||||
| "extract_variable"
|
||||
| "format"
|
||||
@@ -15,7 +14,6 @@ type RefactorAction =
|
||||
interface RefactorArgs {
|
||||
action: RefactorAction;
|
||||
file?: string;
|
||||
symbol?: string;
|
||||
newName?: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
@@ -86,8 +84,7 @@ function formatResult(result: RefactorResult, applied: boolean): string {
|
||||
export const refactorTool = {
|
||||
name: "refactor",
|
||||
description:
|
||||
"Extract functions/variables, format code, and organize imports. " +
|
||||
"Compiler-guaranteed safety. For renaming symbols, use rename_symbol instead (simpler, auto-locates).",
|
||||
"Extract functions/variables, format code, and organize imports. Compiler-guaranteed safety.",
|
||||
execute: async (args: RefactorArgs): Promise<ToolResult> => {
|
||||
try {
|
||||
const router = getIntelligenceRouter(process.cwd());
|
||||
@@ -96,48 +93,6 @@ export const refactorTool = {
|
||||
const shouldApply = args.apply ?? true;
|
||||
|
||||
switch (args.action) {
|
||||
case "rename": {
|
||||
const symbol = args.symbol;
|
||||
const newName = args.newName;
|
||||
if (!symbol) {
|
||||
return {
|
||||
success: false,
|
||||
output: "symbol is required for rename",
|
||||
error: "missing symbol",
|
||||
};
|
||||
}
|
||||
if (!newName) {
|
||||
return {
|
||||
success: false,
|
||||
output: "newName is required for rename",
|
||||
error: "missing newName",
|
||||
};
|
||||
}
|
||||
if (!file) {
|
||||
return { success: false, output: "file is required for rename", error: "missing file" };
|
||||
}
|
||||
|
||||
const tracked = await router.executeWithFallbackTracked(language, "rename", (b) =>
|
||||
b.rename ? b.rename(file, symbol, newName) : Promise.resolve(null),
|
||||
);
|
||||
|
||||
if (!tracked) {
|
||||
return {
|
||||
success: false,
|
||||
output: `Cannot rename '${symbol}' — no backend supports rename for ${language}`,
|
||||
error: "unsupported",
|
||||
};
|
||||
}
|
||||
|
||||
let diagOutput: string | null = null;
|
||||
if (shouldApply) {
|
||||
diagOutput = await applyAndDiagnose(tracked.value.edits, router);
|
||||
}
|
||||
let output = formatResult(tracked.value, shouldApply);
|
||||
if (diagOutput) output += `\n${diagOutput}`;
|
||||
return { success: true, output, backend: tracked.backend };
|
||||
}
|
||||
|
||||
case "extract_function": {
|
||||
const startLine = args.startLine;
|
||||
const endLine = args.endLine;
|
||||
|
||||
@@ -3,9 +3,12 @@ import { resolve } from "node:path";
|
||||
import type { ToolResult } from "../../types/index.js";
|
||||
import { getIntelligenceRouter } from "../intelligence/index.js";
|
||||
import type { FileEdit } from "../intelligence/types.js";
|
||||
import { isForbidden } from "../security/forbidden.js";
|
||||
|
||||
function applyEdits(edits: FileEdit[]): void {
|
||||
for (const edit of edits) {
|
||||
const blocked = isForbidden(edit.file);
|
||||
if (blocked) throw new Error(`Cannot edit forbidden file: ${edit.file} (${blocked})`);
|
||||
writeFileSync(edit.file, edit.newContent, "utf-8");
|
||||
}
|
||||
}
|
||||
@@ -14,7 +17,7 @@ function applyEdits(edits: FileEdit[]): void {
|
||||
* Replace a symbol in code regions only — skips string literals and comments.
|
||||
* Uses a state machine to track whether we're inside a string or comment.
|
||||
*/
|
||||
function replaceInCode(source: string, escapedSymbol: string, newName: string): string {
|
||||
export function replaceInCode(source: string, escapedSymbol: string, newName: string): string {
|
||||
const symbolRe = new RegExp(`\\b${escapedSymbol}\\b`, "g");
|
||||
const result: string[] = [];
|
||||
let i = 0;
|
||||
@@ -50,8 +53,8 @@ function replaceInCode(source: string, escapedSymbol: string, newName: string):
|
||||
continue;
|
||||
}
|
||||
|
||||
// String literal (single, double, backtick)
|
||||
if (source[i] === '"' || source[i] === "'" || source[i] === "`") {
|
||||
// String literals (single, double)
|
||||
if (source[i] === '"' || source[i] === "'") {
|
||||
const quote = source[i] as string;
|
||||
let j = i + 1;
|
||||
while (j < source.length) {
|
||||
@@ -70,11 +73,59 @@ function replaceInCode(source: string, escapedSymbol: string, newName: string):
|
||||
continue;
|
||||
}
|
||||
|
||||
// Template literal — recurse into ${} interpolations as code
|
||||
if (source[i] === "`") {
|
||||
let j = i + 1;
|
||||
while (j < source.length) {
|
||||
if (source[j] === "\\") {
|
||||
j += 2;
|
||||
continue;
|
||||
}
|
||||
if (source[j] === "$" && source[j + 1] === "{") {
|
||||
result.push(source.slice(i, j + 2));
|
||||
j += 2;
|
||||
let depth = 1;
|
||||
const exprStart = j;
|
||||
while (j < source.length && depth > 0) {
|
||||
if (source[j] === "{") depth++;
|
||||
else if (source[j] === "}") depth--;
|
||||
if (depth > 0) j++;
|
||||
}
|
||||
const expr = source.slice(exprStart, j);
|
||||
result.push(replaceInCode(expr, escapedSymbol, newName));
|
||||
if (j < source.length) {
|
||||
result.push("}");
|
||||
j++;
|
||||
}
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
if (source[j] === "`") {
|
||||
j++;
|
||||
break;
|
||||
}
|
||||
j++;
|
||||
}
|
||||
result.push(source.slice(i, j));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Code region — scan to next potential string/comment start, replace symbols
|
||||
let end = i;
|
||||
while (end < source.length) {
|
||||
const ch = source[end] as string;
|
||||
if (ch === "/" || ch === '"' || ch === "'" || ch === "`" || ch === "#") break;
|
||||
if (ch === '"' || ch === "'" || ch === "`") break;
|
||||
if (ch === "/" && (source[end + 1] === "/" || source[end + 1] === "*")) break;
|
||||
if (
|
||||
ch === "#" &&
|
||||
(end === 0 ||
|
||||
source[end - 1] === "\n" ||
|
||||
source[end - 1] === " " ||
|
||||
source[end - 1] === "\t")
|
||||
) {
|
||||
break;
|
||||
}
|
||||
end++;
|
||||
}
|
||||
|
||||
@@ -246,6 +297,8 @@ export const renameSymbolTool = {
|
||||
for (const ref of remaining) {
|
||||
try {
|
||||
const content = readFileSync(ref, "utf-8");
|
||||
const refBlocked = isForbidden(ref);
|
||||
if (refBlocked) continue;
|
||||
const updated = replaceInCode(content, escaped, args.newName);
|
||||
if (updated !== content) {
|
||||
writeFileSync(ref, updated, "utf-8");
|
||||
|
||||
+24
-5
@@ -79,9 +79,9 @@ function checkShellForbidden(command: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Block subshell / variable expansion referencing forbidden filenames directly
|
||||
// Block subshell / variable expansion — extract inner content and check paths
|
||||
if (SUBSHELL_RE.test(command)) {
|
||||
for (const pattern of [
|
||||
const SENSITIVE_KW = [
|
||||
"env",
|
||||
"pem",
|
||||
"key",
|
||||
@@ -90,9 +90,28 @@ function checkShellForbidden(command: string): string | null {
|
||||
"npmrc",
|
||||
"netrc",
|
||||
"htpasswd",
|
||||
]) {
|
||||
if (command.toLowerCase().includes(pattern)) {
|
||||
return `suspicious subshell referencing "${pattern}"`;
|
||||
"ssh",
|
||||
"token",
|
||||
"passwd",
|
||||
"shadow",
|
||||
"aws",
|
||||
];
|
||||
const lower = command.toLowerCase();
|
||||
for (const kw of SENSITIVE_KW) {
|
||||
if (lower.includes(kw)) return `suspicious subshell referencing "${kw}"`;
|
||||
}
|
||||
for (const m of command.matchAll(/\$\(([^)]+)\)/g)) {
|
||||
const inner = m[1] ?? "";
|
||||
for (const arg of extractAllPathLikeArgs(inner)) {
|
||||
const blocked = isForbidden(arg);
|
||||
if (blocked) return blocked;
|
||||
}
|
||||
}
|
||||
for (const m of command.matchAll(/`([^`]+)`/g)) {
|
||||
const inner = m[1] ?? "";
|
||||
for (const arg of extractAllPathLikeArgs(inner)) {
|
||||
const blocked = isForbidden(arg);
|
||||
if (blocked) return blocked;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+373
-90
@@ -9,6 +9,14 @@ import { createForgeAgent } from "../core/agents/index.js";
|
||||
import { repairToolCall, sanitizeToolInputsStep } from "../core/agents/stream-options.js";
|
||||
import { onAgentStats, onMultiAgentEvent } from "../core/agents/subagent-events.js";
|
||||
import { buildSubagentTools, type SharedCacheRef } from "../core/agents/subagent-tools.js";
|
||||
import {
|
||||
buildV2Summary,
|
||||
extractFromAssistantMessage,
|
||||
extractFromToolCall,
|
||||
extractFromToolResult,
|
||||
extractFromUserMessage,
|
||||
WorkingStateManager,
|
||||
} from "../core/compaction/index.js";
|
||||
import type { ContextManager } from "../core/context/manager.js";
|
||||
import { setCoAuthorEnabled } from "../core/git/status.js";
|
||||
import { getModelContextWindow, getShortModelLabel } from "../core/llm/models.js";
|
||||
@@ -48,6 +56,71 @@ function safeParseArgs(raw: string | undefined): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
const PATH_ARG_KEYS = new Set([
|
||||
"file",
|
||||
"path",
|
||||
"filePath",
|
||||
"file_path",
|
||||
"target_file",
|
||||
"source_file",
|
||||
"target",
|
||||
]);
|
||||
|
||||
function reprimeContextFromMessages(cm: ContextManager, msgs: ModelMessage[]): void {
|
||||
try {
|
||||
for (const msg of msgs) {
|
||||
if (typeof msg.content === "string") {
|
||||
extractPathsFromText(msg.content, cm);
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(msg.content)) continue;
|
||||
for (const part of msg.content) {
|
||||
if (typeof part !== "object" || part === null) continue;
|
||||
const typed = part as { type?: string; text?: string; args?: Record<string, unknown> };
|
||||
if (typed.type === "tool-call" && typed.args && typeof typed.args === "object") {
|
||||
for (const [key, val] of Object.entries(typed.args)) {
|
||||
if (PATH_ARG_KEYS.has(key) && typeof val === "string" && val.length > 0) {
|
||||
cm.trackMentionedFile(val);
|
||||
}
|
||||
if (key === "files" && Array.isArray(val)) {
|
||||
for (const f of val) {
|
||||
if (typeof f === "string") cm.trackMentionedFile(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ("text" in typed && typeof typed.text === "string") {
|
||||
extractPathsFromText(typed.text, cm);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — partial priming is better than crashing compaction/restore
|
||||
}
|
||||
}
|
||||
|
||||
const BACKTICK_PATH_RE = /`([^`\s]+)`/g;
|
||||
|
||||
function extractPathsFromText(text: string, cm: ContextManager): void {
|
||||
if (text.length > 500_000) return;
|
||||
for (const match of text.matchAll(BACKTICK_PATH_RE)) {
|
||||
if (match[1] && looksLikeFilePath(match[1])) {
|
||||
cm.trackMentionedFile(match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function looksLikeFilePath(s: string): boolean {
|
||||
if (s.length < 3 || s.length > 300) return false;
|
||||
if (/[<>{}[\]|&;$!()@#=+]/.test(s)) return false;
|
||||
if (s.startsWith("http://") || s.startsWith("https://")) return false;
|
||||
if (/\s/.test(s)) return false;
|
||||
if (!s.includes("/")) return false;
|
||||
const lastDot = s.lastIndexOf(".");
|
||||
if (lastDot < 0) return false;
|
||||
const ext = s.slice(lastDot + 1);
|
||||
return ext.length > 0 && ext.length <= 10 && /^[a-zA-Z0-9]+$/.test(ext);
|
||||
}
|
||||
|
||||
// ─── Types ───
|
||||
|
||||
export interface TabState {
|
||||
@@ -411,6 +484,39 @@ export function useChat({
|
||||
const [isCompacting, setIsCompacting] = useState(false);
|
||||
const isCompactingRef = useRef(false);
|
||||
const pendingCompactRef = useRef(false);
|
||||
const initialStrategy = effectiveConfig.compaction?.strategy ?? "v1";
|
||||
const workingStateRef = useRef<WorkingStateManager | null>(
|
||||
initialStrategy === "v2" ? new WorkingStateManager(effectiveConfig.compaction) : null,
|
||||
);
|
||||
const prevCompactionStrategy = useRef(effectiveConfig.compaction?.strategy);
|
||||
|
||||
// Sync store on mount (useRef initializer doesn't trigger the change block)
|
||||
const didInitCompaction = useRef(false);
|
||||
if (!didInitCompaction.current) {
|
||||
didInitCompaction.current = true;
|
||||
useStatusBarStore.getState().setCompactionStrategy(initialStrategy);
|
||||
}
|
||||
|
||||
// React to compaction strategy changes: create/destroy WSM as needed
|
||||
if (effectiveConfig.compaction?.strategy !== prevCompactionStrategy.current) {
|
||||
prevCompactionStrategy.current = effectiveConfig.compaction?.strategy;
|
||||
const strategy = effectiveConfig.compaction?.strategy ?? "v1";
|
||||
useStatusBarStore.getState().setCompactionStrategy(strategy);
|
||||
if (strategy === "v2") {
|
||||
workingStateRef.current = new WorkingStateManager(effectiveConfig.compaction);
|
||||
} else {
|
||||
workingStateRef.current = null;
|
||||
useStatusBarStore.getState().setV2Slots(0);
|
||||
}
|
||||
}
|
||||
|
||||
const syncV2SlotsRef = useRef(() => {
|
||||
if (workingStateRef.current) {
|
||||
useStatusBarStore.getState().setV2Slots(workingStateRef.current.slotCount());
|
||||
}
|
||||
});
|
||||
const syncV2Slots = syncV2SlotsRef.current;
|
||||
|
||||
const handleSubmitRef = useRef<(input: string) => void>(() => {});
|
||||
const summarizeConversationRef = useRef<(opts?: { skipQueueDrain?: boolean }) => Promise<void>>(
|
||||
async () => {},
|
||||
@@ -451,6 +557,7 @@ export function useChat({
|
||||
|
||||
isCompactingRef.current = true;
|
||||
setIsCompacting(true);
|
||||
useStatusBarStore.getState().setCompacting(true);
|
||||
try {
|
||||
const compactModelId = resolveTaskModel(
|
||||
"compact",
|
||||
@@ -477,7 +584,9 @@ export function useChat({
|
||||
}, 0);
|
||||
const beforePct = Math.round((beforeChars / charsPerToken / contextWindow) * 100);
|
||||
|
||||
const KEEP_RECENT = 4;
|
||||
const compactionCfg = effectiveConfig.compaction;
|
||||
const isV2 = compactionCfg?.strategy === "v2";
|
||||
const KEEP_RECENT = compactionCfg?.keepRecent ?? 4;
|
||||
const keepStart = Math.max(0, currentCore.length - KEEP_RECENT);
|
||||
const olderMessages = currentCore.slice(0, keepStart);
|
||||
const recentMessages = currentCore.slice(keepStart);
|
||||
@@ -498,84 +607,150 @@ export function useChat({
|
||||
return;
|
||||
}
|
||||
|
||||
const strategyLabel = isV2 ? "v2 incremental" : modelLabel;
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "system",
|
||||
content: `Compacting ${olderMessages.length} messages with ${modelLabel}...`,
|
||||
content: `Compacting ${olderMessages.length} messages with ${strategyLabel}...`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
|
||||
const formatMessage = (m: ModelMessage, charLimit: number) => {
|
||||
const role = m.role;
|
||||
if (typeof m.content === "string") {
|
||||
return `${role}: ${m.content.slice(0, charLimit)}`;
|
||||
const planContext = (() => {
|
||||
const plan = activePlanRef.current;
|
||||
if (!plan) return "";
|
||||
const lines = [`\n## Active Plan: ${plan.title}`];
|
||||
for (const step of plan.steps) {
|
||||
const icon =
|
||||
step.status === "done"
|
||||
? "✓"
|
||||
: step.status === "active"
|
||||
? "▸"
|
||||
: step.status === "skipped"
|
||||
? "⊘"
|
||||
: "○";
|
||||
lines.push(` ${icon} [${step.id}] ${step.label} — ${step.status}`);
|
||||
}
|
||||
if (Array.isArray(m.content)) {
|
||||
const parts = m.content
|
||||
.map((p) => {
|
||||
if (typeof p === "object" && p !== null) {
|
||||
if ("text" in p) return String((p as { text: string }).text).slice(0, charLimit);
|
||||
if ("type" in p && (p as { type: string }).type === "tool-result") {
|
||||
const tr = p as { toolName?: string; result?: unknown };
|
||||
const resultStr = tr.result != null ? JSON.stringify(tr.result) : "null";
|
||||
return `[tool-result: ${tr.toolName ?? "unknown"} → ${resultStr.slice(0, 3000)}]`;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(p).slice(0, 1500);
|
||||
})
|
||||
.join("\n");
|
||||
return `${role}: ${parts}`;
|
||||
}
|
||||
return `${role}: [complex content]`;
|
||||
};
|
||||
|
||||
const convoText = olderMessages.map((m) => formatMessage(m, 4000)).join("\n\n");
|
||||
return lines.join("\n");
|
||||
})();
|
||||
|
||||
const { providerOptions, headers } = buildProviderOptions(compactModelId, effectiveConfig);
|
||||
const { text: summary } = await generateText({
|
||||
model,
|
||||
maxOutputTokens: 8192,
|
||||
...(providerOptions && Object.keys(providerOptions).length > 0
|
||||
? { providerOptions }
|
||||
: {}),
|
||||
...(headers ? { headers } : {}),
|
||||
prompt: [
|
||||
"You are compacting the OLDER portion of a coding assistant conversation.",
|
||||
"The most recent messages will be preserved verbatim — focus on summarizing what came before.",
|
||||
"",
|
||||
"Create a structured summary with these sections:",
|
||||
"",
|
||||
"## Environment",
|
||||
"Project type, key technologies, working directory, any config details mentioned.",
|
||||
"",
|
||||
"## Files Touched",
|
||||
"Every file path that was read, edited, or created. For EDITS: include the specific old→new changes (function signatures, variable names, logic). For READS: note key content found.",
|
||||
"",
|
||||
"## Tool Results",
|
||||
"Key tool results that inform future decisions: grep matches, test output, diagnostics, build errors. Include literal output where it matters — don't just say 'tests passed', say which tests and any warnings.",
|
||||
"",
|
||||
"## Key Decisions",
|
||||
"Architectural choices, design patterns chosen, trade-offs discussed.",
|
||||
"",
|
||||
"## Work Completed",
|
||||
"What was accomplished. Include specific function names, variable names, code patterns.",
|
||||
"",
|
||||
"## Errors & Resolutions",
|
||||
"Problems encountered and how they were resolved. Include the actual error messages.",
|
||||
"",
|
||||
"## Current State",
|
||||
"What was being worked on at the end of this section. What remains to be done.",
|
||||
"",
|
||||
"Be thorough — this summary is the only record of the older conversation.",
|
||||
"CRITICAL: Preserve specific details from tool results (file contents, error messages, test output). Generic summaries like 'edited file X' are useless — include WHAT was changed.",
|
||||
"",
|
||||
"CONVERSATION TO SUMMARIZE:",
|
||||
convoText,
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
let summary: string;
|
||||
|
||||
if (isV2 && workingStateRef.current) {
|
||||
// ─── V2: Incremental structured extraction ───
|
||||
// The working state was built incrementally during the conversation.
|
||||
// Inject plan context if active, then serialize + optional gap-fill.
|
||||
const wsm = workingStateRef.current;
|
||||
if (activePlanRef.current) {
|
||||
wsm.setPlan(
|
||||
activePlanRef.current.steps.map((s) => ({
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
status: s.status,
|
||||
})),
|
||||
);
|
||||
}
|
||||
summary = await buildV2Summary({
|
||||
wsm,
|
||||
olderMessages,
|
||||
model: compactionCfg?.llmExtraction !== false ? model : undefined,
|
||||
providerOptions,
|
||||
headers,
|
||||
skipLlm: compactionCfg?.llmExtraction === false,
|
||||
});
|
||||
} else {
|
||||
// ─── V1: Full LLM batch summarization ───
|
||||
const formatMessage = (m: ModelMessage, charLimit: number) => {
|
||||
const role = m.role;
|
||||
if (typeof m.content === "string") {
|
||||
return `${role}: ${m.content.slice(0, charLimit)}`;
|
||||
}
|
||||
if (Array.isArray(m.content)) {
|
||||
const parts = m.content
|
||||
.map((p) => {
|
||||
if (typeof p === "object" && p !== null) {
|
||||
if ("text" in p)
|
||||
return String((p as { text: string }).text).slice(0, charLimit);
|
||||
if ("type" in p && (p as { type: string }).type === "tool-result") {
|
||||
const tr = p as { toolName?: string; result?: unknown };
|
||||
const resultStr = tr.result != null ? JSON.stringify(tr.result) : "null";
|
||||
return `[tool-result: ${tr.toolName ?? "unknown"} → ${resultStr.slice(0, 8000)}]`;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(p).slice(0, 3000);
|
||||
})
|
||||
.join("\n");
|
||||
return `${role}: ${parts}`;
|
||||
}
|
||||
return `${role}: [complex content]`;
|
||||
};
|
||||
|
||||
const convoText = olderMessages.map((m) => formatMessage(m, 6000)).join("\n\n");
|
||||
|
||||
const { text: v1Summary } = await generateText({
|
||||
model,
|
||||
maxOutputTokens: 8192,
|
||||
...(providerOptions && Object.keys(providerOptions).length > 0
|
||||
? { providerOptions }
|
||||
: {}),
|
||||
...(headers ? { headers } : {}),
|
||||
prompt: [
|
||||
"You are compacting the OLDER portion of a coding assistant conversation.",
|
||||
"The most recent messages will be preserved verbatim — focus on summarizing what came before.",
|
||||
"",
|
||||
"Create a structured summary with these sections:",
|
||||
"",
|
||||
"## Environment",
|
||||
"Project type, key technologies, working directory, any config details mentioned.",
|
||||
"",
|
||||
"## Files Touched",
|
||||
"Every file path that was read, edited, or created. For EDITS: include the specific old→new changes (function signatures, variable names, logic). For READS: note key content found.",
|
||||
"",
|
||||
"## Tool Results",
|
||||
"Key tool results that inform future decisions: grep matches, test output, diagnostics, build errors. Include literal output where it matters — don't just say 'tests passed', say which tests and any warnings.",
|
||||
"",
|
||||
"## Key Decisions",
|
||||
"Architectural choices, design patterns chosen, trade-offs discussed.",
|
||||
"",
|
||||
"## Work Completed",
|
||||
"What was accomplished. Include specific function names, variable names, code patterns.",
|
||||
"",
|
||||
"## Errors & Resolutions",
|
||||
"Problems encountered and how they were resolved. Include the actual error messages.",
|
||||
"",
|
||||
"## Current State",
|
||||
"What was being worked on at the end of this section. What remains to be done.",
|
||||
planContext
|
||||
? `\n${planContext}\nINCLUDE the plan progress above VERBATIM in ## Current State so the agent knows which steps are done/active/pending.`
|
||||
: "",
|
||||
"",
|
||||
"Be thorough — this summary is the only record of the older conversation.",
|
||||
"CRITICAL: Preserve specific details from tool results (file contents, error messages, test output). Generic summaries like 'edited file X' are useless — include WHAT was changed.",
|
||||
"",
|
||||
"CONVERSATION TO SUMMARIZE:",
|
||||
convoText,
|
||||
].join("\n"),
|
||||
});
|
||||
summary = v1Summary;
|
||||
}
|
||||
|
||||
if (!summary || summary.trim().length < 50) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "system",
|
||||
content:
|
||||
"Compaction produced an empty or too-short summary — aborting to preserve context.",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const summaryMsg: ModelMessage = {
|
||||
role: "user" as const,
|
||||
@@ -597,6 +772,18 @@ export function useChat({
|
||||
const newMessages = [summaryMsg, ackMsg, ...recentMessages];
|
||||
setCoreMessages(newMessages);
|
||||
|
||||
const trackedFiles = contextManager.getTrackedFiles();
|
||||
contextManager.resetConversationTracking();
|
||||
for (const f of trackedFiles.edited) {
|
||||
try {
|
||||
contextManager.onFileChanged(f);
|
||||
} catch {
|
||||
// File may have been deleted — skip re-tracking
|
||||
}
|
||||
}
|
||||
for (const f of trackedFiles.mentioned) contextManager.trackMentionedFile(f);
|
||||
reprimeContextFromMessages(contextManager, recentMessages);
|
||||
|
||||
const newCoreChars = newMessages.reduce((sum, m) => {
|
||||
if (typeof m.content === "string") return sum + m.content.length;
|
||||
if (Array.isArray(m.content)) {
|
||||
@@ -638,6 +825,7 @@ export function useChat({
|
||||
} finally {
|
||||
isCompactingRef.current = false;
|
||||
setIsCompacting(false);
|
||||
useStatusBarStore.getState().setCompacting(false);
|
||||
if (!opts?.skipQueueDrain) {
|
||||
setMessageQueue((queue) => {
|
||||
if (queue.length > 0) {
|
||||
@@ -659,17 +847,25 @@ export function useChat({
|
||||
const autoSummarizedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const systemChars = contextManager.getContextBreakdown().reduce((sum, s) => sum + s.chars, 0);
|
||||
const totalChars = systemChars + coreChars;
|
||||
const totalChars = systemChars + chatChars;
|
||||
const contextBudgetChars = getModelContextWindow(activeModelRef.current) * 3;
|
||||
const pct = totalChars / contextBudgetChars;
|
||||
if (pct > 0.7 && !autoSummarizedRef.current && coreMessagesRef.current.length >= 6) {
|
||||
const triggerAt = effectiveConfig.compaction?.triggerThreshold ?? 0.7;
|
||||
const resetAt = effectiveConfig.compaction?.resetThreshold ?? 0.4;
|
||||
if (pct > triggerAt && !autoSummarizedRef.current && coreMessagesRef.current.length >= 6) {
|
||||
autoSummarizedRef.current = true;
|
||||
summarizeConversation();
|
||||
}
|
||||
if (pct < 0.4) {
|
||||
if (pct < resetAt) {
|
||||
autoSummarizedRef.current = false;
|
||||
}
|
||||
}, [coreChars, contextManager, summarizeConversation]);
|
||||
}, [
|
||||
chatChars,
|
||||
contextManager,
|
||||
summarizeConversation,
|
||||
effectiveConfig.compaction?.triggerThreshold,
|
||||
effectiveConfig.compaction?.resetThreshold,
|
||||
]);
|
||||
|
||||
// Interactive callbacks for plan/question tools
|
||||
const interactiveCallbacks = useMemo<InteractiveCallbacks>(
|
||||
@@ -860,12 +1056,15 @@ export function useChat({
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
|
||||
const currentCoreMessages = coreMessagesRef.current;
|
||||
const newCoreMessages: ModelMessage[] = [
|
||||
...currentCoreMessages,
|
||||
{ role: "user" as const, content: input },
|
||||
];
|
||||
const userCoreMsg: ModelMessage = { role: "user" as const, content: input };
|
||||
const newCoreMessages: ModelMessage[] = [...currentCoreMessages, userCoreMsg];
|
||||
setCoreMessages(newCoreMessages);
|
||||
|
||||
if (workingStateRef.current) {
|
||||
extractFromUserMessage(workingStateRef.current, userCoreMsg);
|
||||
syncV2Slots();
|
||||
}
|
||||
|
||||
const estimatedTokens = tokenUsageRef.current.total;
|
||||
contextManager.updateConversationContext(input, estimatedTokens);
|
||||
|
||||
@@ -892,6 +1091,7 @@ export function useChat({
|
||||
abortRef.current = abortController;
|
||||
|
||||
let fullText = "";
|
||||
let lastIncrementalSave = 0;
|
||||
const completedCalls: import("../types/index.js").ToolCall[] = [];
|
||||
const finalSegments: MessageSegment[] = [];
|
||||
|
||||
@@ -985,6 +1185,8 @@ export function useChat({
|
||||
taskType,
|
||||
);
|
||||
|
||||
await contextManager.ensureGitContext();
|
||||
|
||||
const agent = planModeRef.current
|
||||
? new ToolLoopAgent({
|
||||
id: "forge-plan",
|
||||
@@ -1107,9 +1309,11 @@ export function useChat({
|
||||
sessionId: sessionIdRef.current,
|
||||
});
|
||||
})();
|
||||
result = (await currentAgent.stream({
|
||||
// biome-ignore lint/suspicious/noExplicitAny: agent union type varies by plan mode — callOptionsSchema differs
|
||||
result = (await (currentAgent as any).stream({
|
||||
messages: newCoreMessages,
|
||||
abortSignal: abortController.signal,
|
||||
options: { userMessage: input },
|
||||
})) as unknown as StreamTextResult<ToolSet, never>;
|
||||
break;
|
||||
} catch (err: unknown) {
|
||||
@@ -1206,10 +1410,10 @@ export function useChat({
|
||||
};
|
||||
|
||||
flushTimerRef.current = setInterval(() => {
|
||||
if (Date.now() - lastFlushTime.current < 100) return;
|
||||
if (Date.now() - lastFlushTime.current < 30) return;
|
||||
lastFlushTime.current = Date.now();
|
||||
flushStreamState();
|
||||
}, 150);
|
||||
}, 40);
|
||||
|
||||
let streamEventCount = 0;
|
||||
for await (const part of result.fullStream) {
|
||||
@@ -1251,6 +1455,7 @@ export function useChat({
|
||||
}
|
||||
}
|
||||
}
|
||||
queueMicrotaskFlush();
|
||||
break;
|
||||
}
|
||||
case "tool-input-start": {
|
||||
@@ -1297,12 +1502,23 @@ export function useChat({
|
||||
tc.result = resultStr;
|
||||
}
|
||||
toolCharsRef.current += resultStr.length;
|
||||
const parsedArgs = safeParseArgs(toolCallArgs.get(part.toolCallId));
|
||||
completedCalls.push({
|
||||
id: part.toolCallId,
|
||||
name: part.toolName,
|
||||
args: safeParseArgs(toolCallArgs.get(part.toolCallId)),
|
||||
args: parsedArgs,
|
||||
result: { success: true, output: resultStr },
|
||||
});
|
||||
if (workingStateRef.current) {
|
||||
extractFromToolCall(workingStateRef.current, part.toolName, parsedArgs);
|
||||
extractFromToolResult(
|
||||
workingStateRef.current,
|
||||
part.toolName,
|
||||
resultStr,
|
||||
parsedArgs,
|
||||
);
|
||||
syncV2Slots();
|
||||
}
|
||||
queueMicrotaskFlush();
|
||||
break;
|
||||
}
|
||||
@@ -1313,12 +1529,20 @@ export function useChat({
|
||||
tc.state = "error";
|
||||
tc.error = String(part.error);
|
||||
}
|
||||
const errorArgs = safeParseArgs(toolCallArgs.get(part.toolCallId));
|
||||
completedCalls.push({
|
||||
id: part.toolCallId,
|
||||
name: part.toolName,
|
||||
args: safeParseArgs(toolCallArgs.get(part.toolCallId)),
|
||||
args: errorArgs,
|
||||
result: { success: false, output: "", error: String(part.error) },
|
||||
});
|
||||
if (workingStateRef.current) {
|
||||
extractFromToolCall(workingStateRef.current, part.toolName, errorArgs);
|
||||
workingStateRef.current.addFailure(
|
||||
`${part.toolName}: ${String(part.error).slice(0, 200)}`,
|
||||
);
|
||||
syncV2Slots();
|
||||
}
|
||||
queueMicrotaskFlush();
|
||||
break;
|
||||
}
|
||||
@@ -1344,6 +1568,43 @@ export function useChat({
|
||||
streamingCharsRef.current = 0;
|
||||
if (stepIn > 0) pendingContextTokens.current = stepIn;
|
||||
pendingLastStepOutput.current = stepOut;
|
||||
|
||||
if (completedCalls.length > 0 && Date.now() - lastIncrementalSave > 10_000) {
|
||||
lastIncrementalSave = Date.now();
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
const snapshot = getWorkspaceSnapshot?.();
|
||||
if (!snapshot) return;
|
||||
const partialMsg: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: "assistant",
|
||||
content: fullText,
|
||||
timestamp: Date.now(),
|
||||
toolCalls: [...completedCalls],
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const allMsgs = [...prev, partialMsg];
|
||||
const { meta, tabMessages } = buildSessionMeta({
|
||||
sessionId: sessionIdRef.current,
|
||||
title: SessionManager.deriveTitle(allMsgs),
|
||||
cwd,
|
||||
snapshot,
|
||||
currentTabMessages: allMsgs.filter(
|
||||
(m) => m.role !== "system" || m.showInChat,
|
||||
),
|
||||
});
|
||||
try {
|
||||
sessionManager.saveSession(meta, tabMessages);
|
||||
} catch {
|
||||
// Incremental save is best-effort — final save will retry
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} catch {
|
||||
// Don't let checkpoint failures interrupt streaming
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
@@ -1401,6 +1662,14 @@ export function useChat({
|
||||
}
|
||||
setActivePlan(null);
|
||||
|
||||
if (workingStateRef.current && fullText.length > 0) {
|
||||
extractFromAssistantMessage(workingStateRef.current, {
|
||||
role: "assistant",
|
||||
content: fullText,
|
||||
});
|
||||
syncV2Slots();
|
||||
}
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: "assistant",
|
||||
@@ -1604,16 +1873,27 @@ export function useChat({
|
||||
} else if (pendingCompactRef.current) {
|
||||
// Compact was requested during generation — run it now that state is settled
|
||||
pendingCompactRef.current = false;
|
||||
summarizeConversationRef.current({ skipQueueDrain: true }).then(() => {
|
||||
// After compact, auto-continue the conversation
|
||||
setTimeout(
|
||||
() =>
|
||||
handleSubmitRef.current(
|
||||
"Continue from where you left off. Complete any remaining work.",
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
const planSnapshot = activePlanRef.current;
|
||||
summarizeConversationRef
|
||||
.current({ skipQueueDrain: true })
|
||||
.then(() => {
|
||||
const planHint = planSnapshot
|
||||
? (() => {
|
||||
const active = planSnapshot.steps.find((s) => s.status === "active");
|
||||
const done = planSnapshot.steps.filter((s) => s.status === "done").length;
|
||||
const total = planSnapshot.steps.length;
|
||||
return ` You are executing plan "${planSnapshot.title}" — ${String(done)}/${String(total)} steps done.${active ? ` Currently on step [${active.id}]: ${active.label}.` : ""}`;
|
||||
})()
|
||||
: "";
|
||||
setTimeout(
|
||||
() =>
|
||||
handleSubmitRef.current(
|
||||
`Continue from where you left off.${planHint} Complete any remaining work.`,
|
||||
),
|
||||
0,
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
} else {
|
||||
// Process message queue
|
||||
setMessageQueue((queue) => {
|
||||
@@ -1640,6 +1920,7 @@ export function useChat({
|
||||
getWorkspaceSnapshot,
|
||||
setTokenUsage,
|
||||
setActivePlan,
|
||||
syncV2Slots,
|
||||
],
|
||||
);
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
@@ -1706,6 +1987,7 @@ export function useChat({
|
||||
setIsLoading(false);
|
||||
autoSummarizedRef.current = false;
|
||||
contextManager.resetConversationTracking();
|
||||
reprimeContextFromMessages(contextManager, state.coreMessages);
|
||||
},
|
||||
[setTokenUsage, contextManager, setActivePlan],
|
||||
);
|
||||
@@ -1716,6 +1998,7 @@ export function useChat({
|
||||
const data = sessionManager.loadSessionMessages(sessionId);
|
||||
if (!data) return;
|
||||
contextManager.resetConversationTracking();
|
||||
reprimeContextFromMessages(contextManager, data.coreMessages);
|
||||
sessionIdRef.current = sessionId;
|
||||
setMessages(data.messages);
|
||||
setCoreMessages(data.coreMessages);
|
||||
|
||||
@@ -27,12 +27,18 @@ interface StatusBarState {
|
||||
chatChars: number;
|
||||
subagentChars: number;
|
||||
rssMB: number;
|
||||
compacting: boolean;
|
||||
compactionStrategy: "v1" | "v2";
|
||||
v2Slots: number;
|
||||
|
||||
setTokenUsage: (usage: TokenUsage) => void;
|
||||
resetTokenUsage: () => void;
|
||||
setContext: (contextTokens: number, chatChars: number) => void;
|
||||
setSubagentChars: (chars: number) => void;
|
||||
setRssMB: (mb: number) => void;
|
||||
setCompacting: (v: boolean) => void;
|
||||
setCompactionStrategy: (s: "v1" | "v2") => void;
|
||||
setV2Slots: (n: number) => void;
|
||||
}
|
||||
|
||||
export const useStatusBarStore = create<StatusBarState>()((set) => ({
|
||||
@@ -41,12 +47,18 @@ export const useStatusBarStore = create<StatusBarState>()((set) => ({
|
||||
chatChars: 0,
|
||||
subagentChars: 0,
|
||||
rssMB: Math.round(process.memoryUsage().rss / 1024 / 1024),
|
||||
compacting: false,
|
||||
compactionStrategy: "v1",
|
||||
v2Slots: 0,
|
||||
|
||||
setTokenUsage: (usage) => set({ tokenUsage: usage }),
|
||||
resetTokenUsage: () => set({ tokenUsage: { ...ZERO_USAGE } }),
|
||||
setContext: (contextTokens, chatChars) => set({ contextTokens, chatChars, subagentChars: 0 }),
|
||||
setSubagentChars: (chars) => set({ subagentChars: chars }),
|
||||
setRssMB: (mb) => set({ rssMB: mb }),
|
||||
setCompacting: (v) => set({ compacting: v }),
|
||||
setCompactionStrategy: (s) => set({ compactionStrategy: s }),
|
||||
setV2Slots: (n) => set({ v2Slots: n }),
|
||||
}));
|
||||
|
||||
export function resetStatusBarStore(): void {
|
||||
@@ -55,6 +67,7 @@ export function resetStatusBarStore(): void {
|
||||
contextTokens: 0,
|
||||
chatChars: 0,
|
||||
subagentChars: 0,
|
||||
compacting: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -178,6 +178,21 @@ export interface ContextManagementConfig {
|
||||
clearThinking?: boolean;
|
||||
}
|
||||
|
||||
export interface CompactionConfig {
|
||||
/** "v1" = LLM batch summarization (default), "v2" = incremental structured extraction */
|
||||
strategy?: "v1" | "v2";
|
||||
/** Threshold (0-1) at which auto-compaction triggers. Default: 0.7 */
|
||||
triggerThreshold?: number;
|
||||
/** Hysteresis reset threshold. Default: 0.4 */
|
||||
resetThreshold?: number;
|
||||
/** Number of recent messages to keep verbatim. Default: 4 */
|
||||
keepRecent?: number;
|
||||
/** Max tool result slots to retain in working state (v2 only). Default: 30 */
|
||||
maxToolResults?: number;
|
||||
/** Use a cheap LLM gap-fill pass for fuzzy extraction (v2 only). Default: true */
|
||||
llmExtraction?: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
defaultModel: string;
|
||||
routerRules: RouterRule[];
|
||||
@@ -197,6 +212,7 @@ export interface AppConfig {
|
||||
thinking?: ThinkingConfig;
|
||||
performance?: PerformanceConfig;
|
||||
contextManagement?: ContextManagementConfig;
|
||||
compaction?: CompactionConfig;
|
||||
codeExecution?: boolean;
|
||||
/** Enable web search tool for all LLMs. Always prompts for approval before searching. Default: true */
|
||||
webSearch?: boolean;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { computeDiff, langFromPath } from "../src/core/diff.js";
|
||||
|
||||
describe("langFromPath", () => {
|
||||
it("returns correct language for known extensions", () => {
|
||||
expect(langFromPath("file.ts")).toBe("ts");
|
||||
expect(langFromPath("file.py")).toBe("python");
|
||||
expect(langFromPath("file.go")).toBe("go");
|
||||
expect(langFromPath("file.rs")).toBe("rust");
|
||||
});
|
||||
|
||||
it("returns empty string for no extension", () => {
|
||||
expect(langFromPath("Makefile")).toBe("");
|
||||
expect(langFromPath("Dockerfile")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for unknown extensions", () => {
|
||||
expect(langFromPath("file.xyz")).toBe("");
|
||||
expect(langFromPath("file.md")).toBe("");
|
||||
});
|
||||
|
||||
it("handles hidden files (dot at position 0 of filename)", () => {
|
||||
// .gitignore → lastIndexOf(".") = 0 in basename, ext = ".gitignore"
|
||||
expect(langFromPath(".gitignore")).toBe("");
|
||||
});
|
||||
|
||||
it("handles dotfiles with known extensions", () => {
|
||||
expect(langFromPath(".eslintrc.js")).toBe("js");
|
||||
});
|
||||
|
||||
it("handles paths with dots in directories", () => {
|
||||
expect(langFromPath("src/v2.0/file.ts")).toBe("ts");
|
||||
});
|
||||
|
||||
it("handles uppercase extensions (case-insensitive via toLowerCase)", () => {
|
||||
// The code calls .toLowerCase() on the extension
|
||||
expect(langFromPath("file.TS")).toBe("ts");
|
||||
expect(langFromPath("file.Ts")).toBe("ts");
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
expect(langFromPath("")).toBe("");
|
||||
});
|
||||
|
||||
it("handles path that is just a dot", () => {
|
||||
expect(langFromPath(".")).toBe("");
|
||||
});
|
||||
|
||||
it("handles path ending in dot", () => {
|
||||
// "file." → lastIndexOf(".") = 4, ext = "" → EXT_MAP[""] = undefined → ""
|
||||
expect(langFromPath("file.")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeDiff", () => {
|
||||
it("handles creation (empty old string)", () => {
|
||||
const result = computeDiff("", "line1\nline2\nline3");
|
||||
expect(result.isCreation).toBe(true);
|
||||
expect(result.added).toBe(3);
|
||||
expect(result.removed).toBe(0);
|
||||
});
|
||||
|
||||
it("handles identical strings (no diff)", () => {
|
||||
const result = computeDiff("foo\nbar", "foo\nbar");
|
||||
expect(result.added).toBe(0);
|
||||
expect(result.removed).toBe(0);
|
||||
});
|
||||
|
||||
it("handles both empty strings", () => {
|
||||
const result = computeDiff("", "");
|
||||
expect(result.isCreation).toBe(true);
|
||||
expect(result.added).toBe(1); // split("") → [""]
|
||||
});
|
||||
|
||||
it("handles single character diff", () => {
|
||||
const result = computeDiff("a", "b");
|
||||
expect(result.added).toBe(1);
|
||||
expect(result.removed).toBe(1);
|
||||
});
|
||||
|
||||
it("collapses creation with >10 lines to 3+collapsed+2", () => {
|
||||
const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`);
|
||||
const result = computeDiff("", lines.join("\n"));
|
||||
expect(result.isCreation).toBe(true);
|
||||
// Should have 3 add lines + 1 collapsed + 2 add lines = 6
|
||||
expect(result.lines.length).toBe(6);
|
||||
expect(result.lines[3]?.kind).toBe("collapsed");
|
||||
expect(result.lines[3]?.collapsedCount).toBe(15); // 20 - 5
|
||||
});
|
||||
|
||||
it("applies startLine offset correctly", () => {
|
||||
const result = computeDiff("old", "new", 10);
|
||||
const addLine = result.lines.find((l) => l.kind === "add");
|
||||
expect(addLine?.newNum).toBe(10); // offset applied
|
||||
});
|
||||
|
||||
it("handles very large matrices by falling back to full remove+add", () => {
|
||||
// LCS guard: n * m > 100_000
|
||||
const a = Array.from({ length: 400 }, (_, i) => `old${i}`).join("\n");
|
||||
const b = Array.from({ length: 300 }, (_, i) => `new${i}`).join("\n");
|
||||
// 400 * 300 = 120,000 > 100,000 → should use fallback
|
||||
const result = computeDiff(a, b);
|
||||
expect(result.removed).toBe(400);
|
||||
expect(result.added).toBe(300);
|
||||
});
|
||||
|
||||
it("collapses long runs of context lines", () => {
|
||||
const oldLines = Array.from({ length: 20 }, (_, i) => `line${i}`);
|
||||
const newLines = [...oldLines];
|
||||
newLines[0] = "changed";
|
||||
const result = computeDiff(oldLines.join("\n"), newLines.join("\n"));
|
||||
const collapsed = result.lines.find((l) => l.kind === "collapsed");
|
||||
expect(collapsed).toBeDefined();
|
||||
});
|
||||
|
||||
it("handles trailing newline differences", () => {
|
||||
const result = computeDiff("foo\n", "foo\n\n");
|
||||
// "foo\n" splits to ["foo", ""], "foo\n\n" splits to ["foo", "", ""]
|
||||
expect(result.added).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeDiff — edge cases", () => {
|
||||
it("handles deletion (non-empty old, empty new)", () => {
|
||||
const result = computeDiff("old content\nline 2\n", "");
|
||||
expect(result.isCreation).toBe(false);
|
||||
expect(result.removed).toBeGreaterThan(0);
|
||||
const removeLines = result.lines.filter((l) => l.kind === "remove");
|
||||
expect(removeLines.some((l) => l.content === "old content")).toBe(true);
|
||||
expect(removeLines.some((l) => l.content === "line 2")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles whitespace-only changes", () => {
|
||||
const result = computeDiff(" foo()", " foo()");
|
||||
expect(result.removed).toBe(1);
|
||||
expect(result.added).toBe(1);
|
||||
const removeLine = result.lines.find((l) => l.kind === "remove");
|
||||
const addLine = result.lines.find((l) => l.kind === "add");
|
||||
expect(removeLine?.content).toBe(" foo()");
|
||||
expect(addLine?.content).toBe(" foo()");
|
||||
});
|
||||
|
||||
it("handles single-line file", () => {
|
||||
const result = computeDiff("old", "new");
|
||||
expect(result.removed).toBe(1);
|
||||
expect(result.added).toBe(1);
|
||||
});
|
||||
|
||||
it("handles unicode content", () => {
|
||||
const result = computeDiff("café\n日本語", "café\n中文");
|
||||
expect(result.removed).toBe(1);
|
||||
expect(result.added).toBe(1);
|
||||
const removeLine = result.lines.find((l) => l.kind === "remove");
|
||||
const addLine = result.lines.find((l) => l.kind === "add");
|
||||
expect(removeLine?.content).toBe("日本語");
|
||||
expect(addLine?.content).toBe("中文");
|
||||
});
|
||||
|
||||
it("handles very long lines (10K chars)", () => {
|
||||
const long = "x".repeat(10000);
|
||||
const result = computeDiff(long, `${long}y`);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.added).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("handles file with only newlines", () => {
|
||||
const result = computeDiff("\n\n\n", "\n\n");
|
||||
expect(result).toBeDefined();
|
||||
expect(result.removed).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("langFromPath — additional extensions", () => {
|
||||
it("handles .scss", () => expect(langFromPath("file.scss")).toBe("css"));
|
||||
it("handles .jsx", () => expect(langFromPath("file.jsx")).toBe("jsx"));
|
||||
it("handles .mjs (unmapped)", () => expect(langFromPath("file.mjs")).toBe(""));
|
||||
it("handles .cjs (unmapped)", () => expect(langFromPath("file.cjs")).toBe(""));
|
||||
it("handles .mts (unmapped)", () => expect(langFromPath("file.mts")).toBe(""));
|
||||
it("handles .yml (unmapped)", () => expect(langFromPath("file.yml")).toBe(""));
|
||||
it("handles .sh", () => expect(langFromPath("file.sh")).toBe("bash"));
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
/**
|
||||
* Tests for fuzzyWhitespaceMatch from edit-file.ts.
|
||||
* This is the fallback when exact string match fails — it normalizes
|
||||
* leading whitespace (tabs↔spaces) and adapts indentation.
|
||||
* Every file edit that doesn't match exactly goes through this.
|
||||
*/
|
||||
|
||||
function fuzzyWhitespaceMatch(
|
||||
content: string,
|
||||
oldStr: string,
|
||||
newStr: string,
|
||||
): { oldStr: string; newStr: string } | null {
|
||||
const contentLines = content.split("\n");
|
||||
const oldLines = oldStr.split("\n");
|
||||
if (oldLines.length === 0) return null;
|
||||
|
||||
const normalize = (line: string) => line.replace(/^[\t ]+/, "").trimEnd();
|
||||
const normalizedOld = oldLines.map(normalize);
|
||||
|
||||
for (let i = 0; i <= contentLines.length - oldLines.length; i++) {
|
||||
let match = true;
|
||||
for (let j = 0; j < oldLines.length; j++) {
|
||||
if (normalize(contentLines[i + j] as string) !== normalizedOld[j]) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
const actualOld = contentLines.slice(i, i + oldLines.length).join("\n");
|
||||
if (content.split(actualOld).length - 1 !== 1) continue;
|
||||
|
||||
const newLines = newStr.split("\n");
|
||||
const correctedNew = newLines
|
||||
.map((newLine, idx) => {
|
||||
const oldLine = oldLines[idx];
|
||||
if (!oldLine) return newLine;
|
||||
const oldIndent = oldLine.match(/^[\t ]*/)?.[0] ?? "";
|
||||
const actualLine = contentLines[i + idx] as string;
|
||||
const actualIndent = actualLine.match(/^[\t ]*/)?.[0] ?? "";
|
||||
if (oldIndent === actualIndent) return newLine;
|
||||
const newIndent = newLine.match(/^[\t ]*/)?.[0] ?? "";
|
||||
if (newIndent === oldIndent) {
|
||||
return actualIndent + newLine.slice(oldIndent.length);
|
||||
}
|
||||
return newLine;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return { oldStr: actualOld, newStr: correctedNew };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatMetricDelta(label: string, before: number, after: number): string {
|
||||
const delta = after - before;
|
||||
if (delta === 0) return "";
|
||||
const sign = delta > 0 ? "+" : "";
|
||||
return `${label}: ${String(before)}→${String(after)} (${sign}${String(delta)})`;
|
||||
}
|
||||
|
||||
describe("fuzzyWhitespaceMatch — tabs vs spaces", () => {
|
||||
it("matches tabs in content when oldStr uses spaces", () => {
|
||||
const content = "function foo() {\n\tconst x = 1;\n\treturn x;\n}";
|
||||
const oldStr = " const x = 1;\n return x;";
|
||||
const newStr = " const x = 2;\n return x;";
|
||||
const result = fuzzyWhitespaceMatch(content, oldStr, newStr);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.oldStr).toBe("\tconst x = 1;\n\treturn x;");
|
||||
expect(result!.newStr).toBe("\tconst x = 2;\n\treturn x;");
|
||||
});
|
||||
|
||||
it("matches spaces in content when oldStr uses tabs", () => {
|
||||
const content = "function foo() {\n const x = 1;\n}";
|
||||
const oldStr = "\tconst x = 1;";
|
||||
const newStr = "\tconst y = 1;";
|
||||
const result = fuzzyWhitespaceMatch(content, oldStr, newStr);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.oldStr).toBe(" const x = 1;");
|
||||
expect(result!.newStr).toBe(" const y = 1;");
|
||||
});
|
||||
|
||||
it("handles mixed indentation levels", () => {
|
||||
const content = "\t\tif (true) {\n\t\t\treturn 1;\n\t\t}";
|
||||
const oldStr = " if (true) {\n return 1;\n }";
|
||||
const newStr = " if (false) {\n return 0;\n }";
|
||||
const result = fuzzyWhitespaceMatch(content, oldStr, newStr);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.oldStr).toContain("\t\tif (true)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fuzzyWhitespaceMatch — no match", () => {
|
||||
it("returns null when content doesn't match", () => {
|
||||
const content = "const a = 1;\nconst b = 2;";
|
||||
const oldStr = "const c = 3;";
|
||||
const newStr = "const c = 4;";
|
||||
expect(fuzzyWhitespaceMatch(content, oldStr, newStr)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty oldStr (single empty line)", () => {
|
||||
const content = "hello\nworld";
|
||||
const result = fuzzyWhitespaceMatch(content, "", "new");
|
||||
// oldLines = [""] which is length 1, normalize("") = ""
|
||||
// This might match the first empty-ish line or not
|
||||
// The key is it doesn't crash
|
||||
expect(result === null || result.oldStr !== undefined).toBe(true);
|
||||
});
|
||||
|
||||
it("returns null when match is ambiguous (duplicate blocks)", () => {
|
||||
const content = " foo();\n bar();\n foo();\n bar();";
|
||||
const oldStr = " foo();\n bar();";
|
||||
const newStr = " baz();";
|
||||
// content.split(actualOld).length - 1 !== 1 → skip
|
||||
expect(fuzzyWhitespaceMatch(content, oldStr, newStr)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fuzzyWhitespaceMatch — edge cases", () => {
|
||||
it("handles trailing whitespace differences", () => {
|
||||
const content = " const x = 1; \n const y = 2;";
|
||||
const oldStr = "const x = 1;\nconst y = 2;";
|
||||
const newStr = "const x = 99;\nconst y = 99;";
|
||||
const result = fuzzyWhitespaceMatch(content, oldStr, newStr);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("handles single-line match", () => {
|
||||
const content = "\t\treturn true;";
|
||||
const oldStr = " return true;";
|
||||
const newStr = " return false;";
|
||||
const result = fuzzyWhitespaceMatch(content, oldStr, newStr);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.newStr).toBe("\t\treturn false;");
|
||||
});
|
||||
|
||||
it("preserves newStr lines beyond oldStr length", () => {
|
||||
const content = "\tconst x = 1;";
|
||||
const oldStr = " const x = 1;";
|
||||
const newStr = " const x = 1;\n const y = 2;";
|
||||
const result = fuzzyWhitespaceMatch(content, oldStr, newStr);
|
||||
expect(result).not.toBeNull();
|
||||
// Second line of newStr has no corresponding oldLine, kept as-is
|
||||
expect(result!.newStr).toContain("const y = 2;");
|
||||
});
|
||||
|
||||
it("handles content with blank lines between matches", () => {
|
||||
const content = "function foo() {\n\n\tconst x = 1;\n}";
|
||||
const oldStr = " const x = 1;";
|
||||
const newStr = " const x = 2;";
|
||||
const result = fuzzyWhitespaceMatch(content, oldStr, newStr);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("handles 4-space vs 2-space indentation", () => {
|
||||
const content = " const x = 1;\n const y = 2;";
|
||||
const oldStr = " const x = 1;\n const y = 2;";
|
||||
const newStr = " const z = 3;\n const w = 4;";
|
||||
const result = fuzzyWhitespaceMatch(content, oldStr, newStr);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.newStr).toBe(" const z = 3;\n const w = 4;");
|
||||
});
|
||||
|
||||
it("handles no indentation in content", () => {
|
||||
const content = "return 1;";
|
||||
const oldStr = " return 1;";
|
||||
const newStr = " return 2;";
|
||||
const result = fuzzyWhitespaceMatch(content, oldStr, newStr);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.newStr).toBe("return 2;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fuzzyWhitespaceMatch — large input", () => {
|
||||
it("handles 10000 lines without hanging", () => {
|
||||
const lines = Array.from({ length: 10000 }, (_, i) => `\tline_${i}();`);
|
||||
lines[9990] = "\ttarget();";
|
||||
const content = lines.join("\n");
|
||||
const oldStr = " target();";
|
||||
const newStr = " replacement();";
|
||||
const result = fuzzyWhitespaceMatch(content, oldStr, newStr);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.newStr).toBe("\treplacement();");
|
||||
});
|
||||
|
||||
it("handles 1000-line oldStr", () => {
|
||||
const oldLines = Array.from({ length: 1000 }, (_, i) => ` stmt_${i}();`);
|
||||
const contentLines = oldLines.map((l) => `\t${l.trim()}`);
|
||||
const content = contentLines.join("\n");
|
||||
const oldStr = oldLines.join("\n");
|
||||
const newStr = oldLines.map((l) => l.replace("stmt", "newStmt")).join("\n");
|
||||
const result = fuzzyWhitespaceMatch(content, oldStr, newStr);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMetricDelta", () => {
|
||||
it("returns empty for no change", () => {
|
||||
expect(formatMetricDelta("Complexity", 5, 5)).toBe("");
|
||||
});
|
||||
|
||||
it("shows positive delta", () => {
|
||||
expect(formatMetricDelta("Lines", 10, 15)).toBe("Lines: 10→15 (+5)");
|
||||
});
|
||||
|
||||
it("shows negative delta", () => {
|
||||
expect(formatMetricDelta("Lines", 15, 10)).toBe("Lines: 15→10 (-5)");
|
||||
});
|
||||
|
||||
it("handles zero values", () => {
|
||||
expect(formatMetricDelta("X", 0, 3)).toBe("X: 0→3 (+3)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { extractConversationTerms } from "../src/core/context/manager.js";
|
||||
|
||||
describe("extractConversationTerms", () => {
|
||||
it("extracts meaningful terms", () => {
|
||||
const terms = extractConversationTerms("refactor the database connection pool");
|
||||
expect(terms).toContain("refactor");
|
||||
expect(terms).toContain("database");
|
||||
expect(terms).toContain("connection");
|
||||
expect(terms).toContain("pool");
|
||||
});
|
||||
|
||||
it("filters stop words", () => {
|
||||
const terms = extractConversationTerms("can you with this code file");
|
||||
expect(terms).toEqual([]);
|
||||
});
|
||||
|
||||
it("deduplicates case-insensitively", () => {
|
||||
const terms = extractConversationTerms("Foo foo FOO bar");
|
||||
expect(terms).toEqual(["Foo", "bar"]);
|
||||
});
|
||||
|
||||
it("limits to 15 terms", () => {
|
||||
const words = Array.from({ length: 30 }, (_, i) => `word${i}`).join(" ");
|
||||
const terms = extractConversationTerms(words);
|
||||
expect(terms).toHaveLength(15);
|
||||
});
|
||||
|
||||
it("skips words shorter than 3 chars", () => {
|
||||
const terms = extractConversationTerms("ab cd ef gh ij kl mn op");
|
||||
expect(terms).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles underscore-prefixed identifiers", () => {
|
||||
const terms = extractConversationTerms("_privateVar __init__ _x");
|
||||
expect(terms).toContain("_privateVar");
|
||||
expect(terms).toContain("__init__");
|
||||
});
|
||||
|
||||
it("handles empty input", () => {
|
||||
expect(extractConversationTerms("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles input with only special characters", () => {
|
||||
expect(extractConversationTerms("!@#$%^&*()")).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles camelCase as single word", () => {
|
||||
const terms = extractConversationTerms("myFunctionName");
|
||||
expect(terms).toEqual(["myFunctionName"]);
|
||||
});
|
||||
|
||||
it("handles numbers in identifiers", () => {
|
||||
const terms = extractConversationTerms("test123 abc456");
|
||||
expect(terms).toContain("test123");
|
||||
expect(terms).toContain("abc456");
|
||||
});
|
||||
|
||||
it("does not extract words starting with a number", () => {
|
||||
const terms = extractConversationTerms("123test 456abc");
|
||||
expect(terms).toContain("test");
|
||||
expect(terms).toContain("abc");
|
||||
});
|
||||
|
||||
// Edge cases
|
||||
|
||||
it("handles unicode identifiers — only ASCII fragments extracted", () => {
|
||||
const terms = extractConversationTerms("café résumé naïve");
|
||||
expect(terms).not.toContain("café");
|
||||
expect(terms).not.toContain("résumé");
|
||||
expect(terms).not.toContain("naïve");
|
||||
expect(terms).toContain("caf");
|
||||
expect(terms).toContain("sum");
|
||||
});
|
||||
|
||||
it("handles very long input without hanging", () => {
|
||||
const chunk = "alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa quebec romeo sierra tango ";
|
||||
const longInput = chunk.repeat(500);
|
||||
const start = performance.now();
|
||||
const terms = extractConversationTerms(longInput);
|
||||
const elapsed = performance.now() - start;
|
||||
expect(elapsed).toBeLessThan(1000);
|
||||
expect(terms.length).toBeLessThanOrEqual(15);
|
||||
});
|
||||
|
||||
it("filters stop words in various cases", () => {
|
||||
const terms = extractConversationTerms("THE And FOR");
|
||||
expect(terms).toEqual([]);
|
||||
});
|
||||
|
||||
it("splits hyphenated words and skips short fragments", () => {
|
||||
const terms = extractConversationTerms("my-component");
|
||||
expect(terms).not.toContain("my");
|
||||
expect(terms).toContain("component");
|
||||
});
|
||||
|
||||
it("splits dotted identifiers", () => {
|
||||
const terms = extractConversationTerms("foo.bar.baz");
|
||||
expect(terms).toContain("foo");
|
||||
expect(terms).toContain("bar");
|
||||
expect(terms).toContain("baz");
|
||||
});
|
||||
|
||||
it("deduplicates repeated words", () => {
|
||||
const terms = extractConversationTerms("foo foo foo foo");
|
||||
expect(terms).toEqual(["foo"]);
|
||||
});
|
||||
|
||||
it("extracts code keywords that are not stop words", () => {
|
||||
const terms = extractConversationTerms("const return import export");
|
||||
expect(terms).toContain("const");
|
||||
expect(terms).toContain("return");
|
||||
expect(terms).toContain("import");
|
||||
expect(terms).toContain("export");
|
||||
});
|
||||
|
||||
it("caps at exactly 15 unique words", () => {
|
||||
const words = Array.from({ length: 20 }, (_, i) => `term${String(i).padStart(2, "0")}`).join(" ");
|
||||
const terms = extractConversationTerms(words);
|
||||
expect(terms).toHaveLength(15);
|
||||
expect(terms[0]).toBe("term00");
|
||||
expect(terms[14]).toBe("term14");
|
||||
});
|
||||
|
||||
it("handles input with newlines and tabs", () => {
|
||||
const terms = extractConversationTerms("alpha\nbravo\tcharlie\n\tdelta");
|
||||
expect(terms).toContain("alpha");
|
||||
expect(terms).toContain("bravo");
|
||||
expect(terms).toContain("charlie");
|
||||
expect(terms).toContain("delta");
|
||||
});
|
||||
|
||||
it("extracts nothing from numbers-only input", () => {
|
||||
const terms = extractConversationTerms("123 456 789");
|
||||
expect(terms).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts a single 3-char word", () => {
|
||||
const terms = extractConversationTerms("foo");
|
||||
expect(terms).toEqual(["foo"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { isPrivateHostname, validateUrl } from "../src/core/tools/fetch-page.js";
|
||||
|
||||
describe("isPrivateHostname — IPv4", () => {
|
||||
it("blocks localhost", () => expect(isPrivateHostname("localhost")).toBe(true));
|
||||
it("blocks 127.0.0.1", () => expect(isPrivateHostname("127.0.0.1")).toBe(true));
|
||||
it("blocks 127.0.0.2 (startsWith 127.)", () => expect(isPrivateHostname("127.0.0.2")).toBe(true));
|
||||
it("blocks 127.255.255.255", () => expect(isPrivateHostname("127.255.255.255")).toBe(true));
|
||||
it("blocks 0.0.0.0", () => expect(isPrivateHostname("0.0.0.0")).toBe(true));
|
||||
it("blocks 10.x.x.x", () => expect(isPrivateHostname("10.0.0.1")).toBe(true));
|
||||
it("blocks 10.255.255.255", () => expect(isPrivateHostname("10.255.255.255")).toBe(true));
|
||||
it("blocks 192.168.x.x", () => expect(isPrivateHostname("192.168.1.1")).toBe(true));
|
||||
it("blocks 192.168.0.0", () => expect(isPrivateHostname("192.168.0.0")).toBe(true));
|
||||
it("blocks 172.16-31.x.x", () => {
|
||||
expect(isPrivateHostname("172.16.0.1")).toBe(true);
|
||||
expect(isPrivateHostname("172.31.255.255")).toBe(true);
|
||||
});
|
||||
it("allows 172.32.x.x (not private)", () => expect(isPrivateHostname("172.32.0.1")).toBe(false));
|
||||
it("allows 172.15.0.1 (below range)", () => expect(isPrivateHostname("172.15.0.1")).toBe(false));
|
||||
it("blocks AWS metadata endpoint", () => expect(isPrivateHostname("169.254.169.254")).toBe(true));
|
||||
it("allows 169.254.0.1 (only exact endpoint blocked)", () => expect(isPrivateHostname("169.254.0.1")).toBe(false));
|
||||
it("allows public IPs", () => {
|
||||
expect(isPrivateHostname("8.8.8.8")).toBe(false);
|
||||
expect(isPrivateHostname("1.1.1.1")).toBe(false);
|
||||
});
|
||||
it("blocks .internal suffix", () => expect(isPrivateHostname("api.internal")).toBe(true));
|
||||
it("blocks metadata.google.internal", () => expect(isPrivateHostname("metadata.google.internal")).toBe(true));
|
||||
it("blocks .local suffix", () => expect(isPrivateHostname("mypc.local")).toBe(true));
|
||||
it("allows normal domains", () => {
|
||||
expect(isPrivateHostname("example.com")).toBe(false);
|
||||
expect(isPrivateHostname("api.github.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPrivateHostname — IPv6", () => {
|
||||
it("blocks ::1 loopback", () => {
|
||||
expect(isPrivateHostname("::1")).toBe(true);
|
||||
});
|
||||
it("does not block [::1] (production checks ::1 without brackets)", () => {
|
||||
expect(isPrivateHostname("[::1]")).toBe(false);
|
||||
});
|
||||
it("blocks ULA fc00::/7", () => {
|
||||
expect(isPrivateHostname("fc00::1")).toBe(true);
|
||||
expect(isPrivateHostname("fd12:3456::1")).toBe(true);
|
||||
});
|
||||
it("blocks link-local fe80::/10", () => {
|
||||
expect(isPrivateHostname("fe80::1")).toBe(true);
|
||||
expect(isPrivateHostname("feb0::1")).toBe(true);
|
||||
});
|
||||
it("allows global unicast", () => {
|
||||
expect(isPrivateHostname("2001:db8::1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPrivateHostname — bypass attempts", () => {
|
||||
it("blocks IPv6-mapped IPv4 (::ffff:127.0.0.1)", () => {
|
||||
expect(isPrivateHostname("::ffff:127.0.0.1")).toBe(true);
|
||||
});
|
||||
it("blocks IPv6-mapped private (::ffff:10.0.0.1)", () => {
|
||||
expect(isPrivateHostname("::ffff:10.0.0.1")).toBe(true);
|
||||
});
|
||||
it("blocks decimal IP (2130706433 = 127.0.0.1)", () => {
|
||||
expect(isPrivateHostname("2130706433")).toBe(true);
|
||||
});
|
||||
it("blocks octal IP (0177.0.0.1 = 127.0.0.1)", () => {
|
||||
expect(isPrivateHostname("0177.0.0.1")).toBe(true);
|
||||
});
|
||||
it("allows normal numeric-looking hostnames (short)", () => {
|
||||
expect(isPrivateHostname("1234567")).toBe(false); // 7 digits, below threshold
|
||||
});
|
||||
it("GAP: doesn't block hex IP (0x7f000001)", () => {
|
||||
expect(isPrivateHostname("0x7f000001")).toBe(false);
|
||||
});
|
||||
it("GAP: doesn't block DNS rebinding (attacker.com resolving to 127.0.0.1)", () => {
|
||||
expect(isPrivateHostname("attacker.com")).toBe(false);
|
||||
});
|
||||
it("case insensitive for IPv6", () => {
|
||||
expect(isPrivateHostname("FC00::1")).toBe(true);
|
||||
expect(isPrivateHostname("FE80::1")).toBe(true);
|
||||
});
|
||||
it("handles empty string", () => {
|
||||
expect(isPrivateHostname("")).toBe(false);
|
||||
});
|
||||
it("does not block empty brackets", () => {
|
||||
expect(isPrivateHostname("[]")).toBe(false);
|
||||
});
|
||||
it("does not treat hostname with port as private", () => {
|
||||
expect(isPrivateHostname("example.com:8080")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateUrl", () => {
|
||||
it("allows valid public URL", () => {
|
||||
expect(validateUrl("https://example.com")).toBeNull();
|
||||
});
|
||||
it("rejects invalid URL", () => {
|
||||
expect(validateUrl("not a url")).toBe("Invalid URL");
|
||||
});
|
||||
it("rejects empty string", () => {
|
||||
expect(validateUrl("")).toBe("Invalid URL");
|
||||
});
|
||||
it("blocks private URL (localhost)", () => {
|
||||
const result = validateUrl("http://localhost/api");
|
||||
expect(result).toContain("Blocked");
|
||||
});
|
||||
it("blocks private IP URL (10.0.0.1)", () => {
|
||||
const result = validateUrl("http://10.0.0.1/secret");
|
||||
expect(result).toContain("Blocked");
|
||||
});
|
||||
it("blocks FTP protocol", () => {
|
||||
const result = validateUrl("ftp://example.com");
|
||||
expect(result).toContain("Blocked protocol");
|
||||
});
|
||||
it("blocks file protocol", () => {
|
||||
const result = validateUrl("file:///etc/passwd");
|
||||
expect(result).toContain("Blocked protocol");
|
||||
});
|
||||
it("blocks data URI", () => {
|
||||
const result = validateUrl("data:text/html,<h1>hi</h1>");
|
||||
expect(result).toContain("Blocked protocol");
|
||||
});
|
||||
it("allows URL with auth", () => {
|
||||
expect(validateUrl("http://user:[email protected]/api")).toBeNull();
|
||||
});
|
||||
it("allows URL with port", () => {
|
||||
expect(validateUrl("http://example.com:8080/api")).toBeNull();
|
||||
});
|
||||
it("blocks private URL with port", () => {
|
||||
const result = validateUrl("http://127.0.0.1:3000/api");
|
||||
expect(result).toContain("Blocked");
|
||||
});
|
||||
it("blocks IPv6 loopback URL (brackets stripped by URL parser)", () => {
|
||||
const result = validateUrl("http://[::1]/api");
|
||||
expect(result).toContain("Blocked");
|
||||
});
|
||||
it("GAP: IPv6-mapped IPv4 URL normalized by URL parser (::ffff:7f00:1)", () => {
|
||||
// URL parser normalizes ::ffff:127.0.0.1 → ::ffff:7f00:1, bypassing the mapped-IPv4 check
|
||||
const result = validateUrl("http://[::ffff:127.0.0.1]/api");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,465 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
// We need to test globToRegex behavior indirectly through isForbidden
|
||||
// since globToRegex is not exported. We'll test via the public API.
|
||||
import {
|
||||
addProjectPattern,
|
||||
addSessionPattern,
|
||||
buildForbiddenContext,
|
||||
clearTabSessionPatterns,
|
||||
getAllPatterns,
|
||||
initForbidden,
|
||||
isForbidden,
|
||||
removeProjectPattern,
|
||||
removeSessionPattern,
|
||||
} from "../src/core/security/forbidden.js";
|
||||
|
||||
const TMP = join(tmpdir(), `forbidden-test-${Date.now()}`);
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(TMP, { recursive: true });
|
||||
initForbidden(TMP);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("isForbidden - builtin patterns", () => {
|
||||
it("blocks .env files", () => {
|
||||
const f = join(TMP, ".env");
|
||||
writeFileSync(f, "SECRET=1");
|
||||
expect(isForbidden(f)).toBe(".env");
|
||||
});
|
||||
|
||||
it("blocks .env.local variant", () => {
|
||||
const f = join(TMP, ".env.local");
|
||||
writeFileSync(f, "SECRET=1");
|
||||
expect(isForbidden(f)).toBe(".env.*");
|
||||
});
|
||||
|
||||
it("blocks PEM files", () => {
|
||||
const f = join(TMP, "cert.pem");
|
||||
writeFileSync(f, "-----BEGIN---");
|
||||
expect(isForbidden(f)).toBe("*.pem");
|
||||
});
|
||||
|
||||
it("blocks private keys", () => {
|
||||
const f = join(TMP, "server.key");
|
||||
writeFileSync(f, "key-data");
|
||||
expect(isForbidden(f)).toBe("*.key");
|
||||
});
|
||||
|
||||
it("blocks id_rsa", () => {
|
||||
const f = join(TMP, "id_rsa");
|
||||
writeFileSync(f, "rsa-data");
|
||||
expect(isForbidden(f)).toBe("id_rsa");
|
||||
});
|
||||
|
||||
it("blocks credentials.json", () => {
|
||||
const f = join(TMP, "credentials.json");
|
||||
writeFileSync(f, "{}");
|
||||
expect(isForbidden(f)).toBe("credentials.json");
|
||||
});
|
||||
|
||||
it("allows normal source files", () => {
|
||||
const f = join(TMP, "index.ts");
|
||||
writeFileSync(f, "export {}");
|
||||
expect(isForbidden(f)).toBeNull();
|
||||
});
|
||||
|
||||
it("allows package.json", () => {
|
||||
const f = join(TMP, "package.json");
|
||||
writeFileSync(f, "{}");
|
||||
expect(isForbidden(f)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isForbidden - glob patterns", () => {
|
||||
it("** matches nested paths", () => {
|
||||
addSessionPattern("**/secrets/**");
|
||||
const f = join(TMP, "config", "secrets", "db.yaml");
|
||||
mkdirSync(join(TMP, "config", "secrets"), { recursive: true });
|
||||
writeFileSync(f, "password: 123");
|
||||
expect(isForbidden(f)).toBe("**/secrets/**");
|
||||
});
|
||||
|
||||
it("? matches single character", () => {
|
||||
addSessionPattern("secret?.txt");
|
||||
const f = join(TMP, "secret1.txt");
|
||||
writeFileSync(f, "data");
|
||||
expect(isForbidden(f)).toBe("secret?.txt");
|
||||
});
|
||||
|
||||
it("pattern matching is case-insensitive", () => {
|
||||
const f = join(TMP, ".ENV");
|
||||
writeFileSync(f, "SECRET=1");
|
||||
expect(isForbidden(f)).toBe(".env");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session patterns", () => {
|
||||
it("adds and checks session patterns", () => {
|
||||
addSessionPattern("*.secret");
|
||||
const f = join(TMP, "data.secret");
|
||||
writeFileSync(f, "x");
|
||||
expect(isForbidden(f)).toBe("*.secret");
|
||||
});
|
||||
|
||||
it("removes session patterns", () => {
|
||||
addSessionPattern("*.secret");
|
||||
removeSessionPattern("*.secret");
|
||||
const f = join(TMP, "data.secret");
|
||||
writeFileSync(f, "x");
|
||||
expect(isForbidden(f)).toBeNull();
|
||||
});
|
||||
|
||||
it("tab-specific patterns are isolated", () => {
|
||||
addSessionPattern("tab1.secret", "tab1");
|
||||
addSessionPattern("tab2.secret", "tab2");
|
||||
|
||||
const patterns = getAllPatterns("tab1");
|
||||
expect(patterns.session).toContain("tab1.secret");
|
||||
expect(patterns.session).not.toContain("tab2.secret");
|
||||
});
|
||||
|
||||
it("clearing tab patterns doesn't affect other tabs", () => {
|
||||
addSessionPattern("a.secret", "tab1");
|
||||
addSessionPattern("b.secret", "tab2");
|
||||
clearTabSessionPatterns("tab1");
|
||||
|
||||
const tab2 = getAllPatterns("tab2");
|
||||
expect(tab2.session).toContain("b.secret");
|
||||
});
|
||||
|
||||
it("default patterns apply to all tabs", () => {
|
||||
addSessionPattern("global.secret"); // no tabId = "default"
|
||||
const tab1 = getAllPatterns("tab1");
|
||||
expect(tab1.session).toContain("global.secret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isForbidden - edge cases", () => {
|
||||
it("handles non-existent file path (realpath fails, falls back to resolve)", () => {
|
||||
// File doesn't exist on disk — realpathSync will throw, fallback to resolve
|
||||
expect(isForbidden("/nonexistent/path/.env")).toBe(".env");
|
||||
});
|
||||
|
||||
it("handles path with symlink components", () => {
|
||||
const dir = join("/tmp", `forbidden-test-${Date.now()}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const f = join(dir, ".env");
|
||||
writeFileSync(f, "data");
|
||||
expect(isForbidden(f)).toBe(".env");
|
||||
rmSync(dir, { recursive: true });
|
||||
});
|
||||
|
||||
it("handles service-account glob pattern", () => {
|
||||
const f = join(TMP, "service-account-prod.json");
|
||||
writeFileSync(f, "{}");
|
||||
expect(isForbidden(f)).toBe("service-account*.json");
|
||||
});
|
||||
|
||||
it("handles nested aws credentials path", () => {
|
||||
const dir = join(TMP, ".aws");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const f = join(dir, "credentials");
|
||||
writeFileSync(f, "aws_access_key_id=xxx");
|
||||
expect(isForbidden(f)).toBe("**/.aws/credentials");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isForbidden — edge cases (extended)", () => {
|
||||
it("path traversal attempt", () => {
|
||||
expect(isForbidden("../../../.env")).toBe(".env");
|
||||
expect(isForbidden("foo/../../.env.local")).toBe(".env.*");
|
||||
});
|
||||
|
||||
it("hidden files with private extensions", () => {
|
||||
expect(isForbidden("/home/user/.ssh/id_rsa")).toBe("id_rsa");
|
||||
expect(isForbidden("/home/user/.ssh/id_ed25519")).toBe("id_ed25519");
|
||||
expect(isForbidden("/home/user/.ssh/id_ecdsa")).toBe("id_ecdsa");
|
||||
});
|
||||
|
||||
it("common secret files", () => {
|
||||
expect(isForbidden("config/credentials.json")).toBe("credentials.json");
|
||||
expect(isForbidden(".env.production")).toBe(".env.*");
|
||||
expect(isForbidden(".env.development.local")).toBe(".env.*");
|
||||
expect(isForbidden("secrets.yaml")).toBe("secrets.yaml");
|
||||
expect(isForbidden("secrets.yml")).toBe("secrets.yml");
|
||||
});
|
||||
|
||||
it("allows normal config files", () => {
|
||||
const f1 = join(TMP, "tsconfig.json");
|
||||
writeFileSync(f1, "{}");
|
||||
expect(isForbidden(f1)).toBeNull();
|
||||
|
||||
const f2 = join(TMP, "package.json");
|
||||
writeFileSync(f2, "{}");
|
||||
expect(isForbidden(f2)).toBeNull();
|
||||
|
||||
const f3 = join(TMP, "biome.json");
|
||||
writeFileSync(f3, "{}");
|
||||
expect(isForbidden(f3)).toBeNull();
|
||||
|
||||
const f4 = join(TMP, "src", "config.ts");
|
||||
mkdirSync(join(TMP, "src"), { recursive: true });
|
||||
writeFileSync(f4, "export {}");
|
||||
expect(isForbidden(f4)).toBeNull();
|
||||
});
|
||||
|
||||
it("case sensitivity for extensions", () => {
|
||||
const f1 = join(TMP, "cert.PEM");
|
||||
writeFileSync(f1, "data");
|
||||
expect(isForbidden(f1)).toBe("*.pem");
|
||||
|
||||
const f2 = join(TMP, "key.KEY");
|
||||
writeFileSync(f2, "data");
|
||||
expect(isForbidden(f2)).toBe("*.key");
|
||||
});
|
||||
|
||||
it("deeply nested forbidden file", () => {
|
||||
expect(isForbidden("a/b/c/d/e/f/.env")).toBe(".env");
|
||||
expect(isForbidden("a/b/c/d/e/f/id_rsa")).toBe("id_rsa");
|
||||
});
|
||||
|
||||
it("files that look similar but are not forbidden", () => {
|
||||
const f1 = join(TMP, "environment.ts");
|
||||
writeFileSync(f1, "export {}");
|
||||
expect(isForbidden(f1)).toBeNull();
|
||||
|
||||
const f2 = join(TMP, "key-manager.ts");
|
||||
writeFileSync(f2, "export {}");
|
||||
expect(isForbidden(f2)).toBeNull();
|
||||
|
||||
const f3 = join(TMP, "credentials-guide.md");
|
||||
writeFileSync(f3, "# Guide");
|
||||
expect(isForbidden(f3)).toBeNull();
|
||||
});
|
||||
|
||||
it("empty string", () => {
|
||||
expect(isForbidden("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("project patterns from file", () => {
|
||||
it("loads patterns from .soulforge/forbidden.json", () => {
|
||||
const dir = join(TMP, ".soulforge");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "forbidden.json"), JSON.stringify({ patterns: ["*.custom"] }));
|
||||
initForbidden(TMP);
|
||||
|
||||
const f = join(TMP, "data.custom");
|
||||
writeFileSync(f, "x");
|
||||
expect(isForbidden(f)).toBe("*.custom");
|
||||
});
|
||||
|
||||
it("handles malformed forbidden.json gracefully", () => {
|
||||
const dir = join(TMP, ".soulforge");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "forbidden.json"), "not-json");
|
||||
expect(() => initForbidden(TMP)).not.toThrow();
|
||||
});
|
||||
|
||||
it("handles forbidden.json with missing patterns key", () => {
|
||||
const dir = join(TMP, ".soulforge");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "forbidden.json"), JSON.stringify({ other: "data" }));
|
||||
initForbidden(TMP);
|
||||
const patterns = getAllPatterns();
|
||||
expect(patterns.project).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isForbidden before initForbidden", () => {
|
||||
it("returns null (permits everything) when called before init", () => {
|
||||
// SECURITY NOTE: isForbidden is a no-op before initForbidden — it returns null
|
||||
// for ALL paths, including obviously sensitive ones. Callers must ensure
|
||||
// initForbidden runs before any file access checks.
|
||||
|
||||
// We need a fresh module to test uninitialized state. Since the module uses
|
||||
// global `initialized = false` that gets set to true in beforeEach via
|
||||
// initForbidden, we isolate by importing a second copy via Bun's loader cache bust.
|
||||
// However, since the existing beforeEach already calls initForbidden, we cannot
|
||||
// truly test pre-init in this suite without reimporting. Instead, we document
|
||||
// the behavior by reading the source: `if (!initialized) return null;`
|
||||
//
|
||||
// For a functional test, we rely on the fact that the module state persists —
|
||||
// after initForbidden is called, isForbidden works. The security concern is
|
||||
// that any code path reaching isForbidden before initForbidden will silently
|
||||
// allow access.
|
||||
expect(typeof isForbidden).toBe("function");
|
||||
|
||||
// Verify that after init (which beforeEach does), it correctly blocks
|
||||
expect(isForbidden("/anything/.env")).toBe(".env");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addProjectPattern / removeProjectPattern", () => {
|
||||
let projectDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
projectDir = join(TMP, `proj-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
initForbidden(projectDir);
|
||||
});
|
||||
|
||||
it("adds a pattern and isForbidden matches it", () => {
|
||||
addProjectPattern(projectDir, "*.secret");
|
||||
|
||||
const f = join(projectDir, "data.secret");
|
||||
writeFileSync(f, "sensitive");
|
||||
expect(isForbidden(f)).toBe("*.secret");
|
||||
});
|
||||
|
||||
it("creates .soulforge/forbidden.json on disk with correct format", () => {
|
||||
addProjectPattern(projectDir, "*.vault");
|
||||
|
||||
const filePath = join(projectDir, ".soulforge", "forbidden.json");
|
||||
const raw = readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
expect(parsed).toEqual({ patterns: ["*.vault"] });
|
||||
});
|
||||
|
||||
it("does not duplicate an existing pattern", () => {
|
||||
addProjectPattern(projectDir, "*.dup");
|
||||
addProjectPattern(projectDir, "*.dup");
|
||||
|
||||
const filePath = join(projectDir, ".soulforge", "forbidden.json");
|
||||
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
||||
expect(parsed.patterns.filter((p: string) => p === "*.dup")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("appends multiple patterns", () => {
|
||||
addProjectPattern(projectDir, "*.a");
|
||||
addProjectPattern(projectDir, "*.b");
|
||||
|
||||
const filePath = join(projectDir, ".soulforge", "forbidden.json");
|
||||
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
||||
expect(parsed.patterns).toEqual(["*.a", "*.b"]);
|
||||
});
|
||||
|
||||
it("removes a pattern and isForbidden no longer matches it", () => {
|
||||
addProjectPattern(projectDir, "*.removeme");
|
||||
removeProjectPattern(projectDir, "*.removeme");
|
||||
|
||||
const f = join(projectDir, "data.removeme");
|
||||
writeFileSync(f, "x");
|
||||
expect(isForbidden(f)).toBeNull();
|
||||
});
|
||||
|
||||
it("updates the file on disk after removal", () => {
|
||||
addProjectPattern(projectDir, "*.keep");
|
||||
addProjectPattern(projectDir, "*.drop");
|
||||
removeProjectPattern(projectDir, "*.drop");
|
||||
|
||||
const filePath = join(projectDir, ".soulforge", "forbidden.json");
|
||||
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
||||
expect(parsed.patterns).toEqual(["*.keep"]);
|
||||
});
|
||||
|
||||
it("removing a non-existent pattern is a no-op", () => {
|
||||
addProjectPattern(projectDir, "*.exists");
|
||||
removeProjectPattern(projectDir, "*.ghost");
|
||||
|
||||
const filePath = join(projectDir, ".soulforge", "forbidden.json");
|
||||
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
||||
expect(parsed.patterns).toEqual(["*.exists"]);
|
||||
});
|
||||
|
||||
it("project patterns appear in getAllPatterns", () => {
|
||||
addProjectPattern(projectDir, "*.projpat");
|
||||
const all = getAllPatterns();
|
||||
expect(all.project).toContain("*.projpat");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildForbiddenContext", () => {
|
||||
it("returns a non-empty string after init", () => {
|
||||
const ctx = buildForbiddenContext();
|
||||
expect(ctx.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("includes the header line", () => {
|
||||
const ctx = buildForbiddenContext();
|
||||
expect(ctx).toContain("## Forbidden Files (Security)");
|
||||
});
|
||||
|
||||
it("includes builtin patterns in output", () => {
|
||||
const ctx = buildForbiddenContext();
|
||||
expect(ctx).toContain("`.env`");
|
||||
expect(ctx).toContain("`*.pem`");
|
||||
expect(ctx).toContain("`id_rsa`");
|
||||
});
|
||||
|
||||
it("includes session patterns when added", () => {
|
||||
addSessionPattern("*.customctx");
|
||||
const ctx = buildForbiddenContext();
|
||||
expect(ctx).toContain("`*.customctx`");
|
||||
});
|
||||
|
||||
it("includes project patterns after addProjectPattern", () => {
|
||||
addProjectPattern(TMP, "*.projctx");
|
||||
const ctx = buildForbiddenContext();
|
||||
expect(ctx).toContain("`*.projctx`");
|
||||
});
|
||||
|
||||
it("deduplicates patterns", () => {
|
||||
addSessionPattern(".env");
|
||||
const ctx = buildForbiddenContext();
|
||||
const count = ctx.split("`.env`").length - 1;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("respects tabId filter for session patterns", () => {
|
||||
addSessionPattern("tab-only.secret", "special-tab");
|
||||
const withTab = buildForbiddenContext("special-tab");
|
||||
const withoutTab = buildForbiddenContext("other-tab");
|
||||
expect(withTab).toContain("`tab-only.secret`");
|
||||
expect(withoutTab).not.toContain("`tab-only.secret`");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllPatterns", () => {
|
||||
it("returns builtin patterns", () => {
|
||||
const all = getAllPatterns();
|
||||
expect(all.builtin.length).toBeGreaterThan(0);
|
||||
expect(all.builtin).toContain(".env");
|
||||
expect(all.builtin).toContain("*.pem");
|
||||
expect(all.builtin).toContain("id_rsa");
|
||||
});
|
||||
|
||||
it("returns empty arrays for unconfigured sources", () => {
|
||||
initForbidden(TMP);
|
||||
const all = getAllPatterns();
|
||||
expect(all.session).toEqual([]);
|
||||
expect(all.aiignore).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns session patterns after adding them", () => {
|
||||
addSessionPattern("*.sess");
|
||||
const all = getAllPatterns();
|
||||
expect(all.session).toContain("*.sess");
|
||||
});
|
||||
|
||||
it("returns project patterns after addProjectPattern", () => {
|
||||
addProjectPattern(TMP, "*.proj");
|
||||
const all = getAllPatterns();
|
||||
expect(all.project).toContain("*.proj");
|
||||
});
|
||||
|
||||
it("has aiignore field populated when .aiignore exists", () => {
|
||||
writeFileSync(join(TMP, ".aiignore"), "vendor/\n# comment\nbuild/");
|
||||
initForbidden(TMP);
|
||||
const all = getAllPatterns();
|
||||
expect(all.aiignore).toEqual(["vendor/", "build/"]);
|
||||
});
|
||||
|
||||
it("global field reflects global config", () => {
|
||||
const all = getAllPatterns();
|
||||
expect(Array.isArray(all.global)).toBe(true);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,430 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { writeFileSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { RegexBackend } from "../src/core/intelligence/backends/regex.js";
|
||||
import { replaceInCode } from "../src/core/tools/rename-symbol.js";
|
||||
import { tsJsHandler } from "../src/core/tools/move-symbol.js";
|
||||
|
||||
const TMP = join(tmpdir(), `hang-safety-${Date.now()}`);
|
||||
const backend = new RegexBackend();
|
||||
|
||||
function writeTemp(name: string, content: string): string {
|
||||
const path = join(TMP, name);
|
||||
writeFileSync(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
beforeAll(() => mkdirSync(TMP, { recursive: true }));
|
||||
afterAll(() => rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
// ── replaceInCode hang safety across languages ──
|
||||
|
||||
describe("replaceInCode — hang safety", () => {
|
||||
// JavaScript / TypeScript
|
||||
it("JS: regex literal with slashes", () => {
|
||||
expect(replaceInCode("const re = /foo/g; foo();", "foo", "bar")).toBe("const re = /bar/g; bar();");
|
||||
});
|
||||
|
||||
it("JS: chained division operators", () => {
|
||||
expect(replaceInCode("foo / foo / foo", "foo", "bar")).toBe("bar / bar / bar");
|
||||
});
|
||||
|
||||
it("JS: division in ternary", () => {
|
||||
expect(replaceInCode("x ? a / foo : foo / b", "foo", "bar")).toBe("x ? a / bar : bar / b");
|
||||
});
|
||||
|
||||
it("JS: nested template literals", () => {
|
||||
const src = "`outer ${`inner ${foo}`} end`";
|
||||
const result = replaceInCode(src, "foo", "bar");
|
||||
expect(result).toContain("bar");
|
||||
});
|
||||
|
||||
it("JS: template literal with unmatched brace in string", () => {
|
||||
const src = '`${foo + "{"}`';
|
||||
const result = replaceInCode(src, "foo", "bar");
|
||||
expect(result).toContain("bar");
|
||||
});
|
||||
|
||||
it("JS: unclosed template literal at EOF", () => {
|
||||
const src = "`${foo";
|
||||
expect(() => replaceInCode(src, "foo", "bar")).not.toThrow();
|
||||
});
|
||||
|
||||
it("JS: empty template literal", () => {
|
||||
expect(replaceInCode("``; foo()", "foo", "bar")).toBe("``; bar()");
|
||||
});
|
||||
|
||||
it("JS: template literal with only static text", () => {
|
||||
expect(replaceInCode("`hello world`; foo()", "foo", "bar")).toBe("`hello world`; bar()");
|
||||
});
|
||||
|
||||
// Python
|
||||
it("Python: hash in string literal (not a comment)", () => {
|
||||
expect(replaceInCode('foo = "# not a comment"; foo()', "foo", "bar")).toBe('bar = "# not a comment"; bar()');
|
||||
});
|
||||
|
||||
it("Python: hash after tab (is a comment)", () => {
|
||||
expect(replaceInCode("x = foo\t# foo\nfoo()", "foo", "bar")).toBe("x = bar\t# foo\nbar()");
|
||||
});
|
||||
|
||||
it("Python: multiple hash comments", () => {
|
||||
const src = "# foo\n# foo\n# foo\nfoo()";
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe("# foo\n# foo\n# foo\nbar()");
|
||||
});
|
||||
|
||||
it("Python: triple-quoted string (each quote handled individually)", () => {
|
||||
const src = '"""foo"""; foo()';
|
||||
const result = replaceInCode(src, "foo", "bar");
|
||||
expect(result).toContain("bar()");
|
||||
});
|
||||
|
||||
// CSS
|
||||
it("CSS: color hex values", () => {
|
||||
expect(replaceInCode(".foo { color: #fff; }", "foo", "bar")).toBe(".bar { color: #fff; }");
|
||||
});
|
||||
|
||||
it("CSS: multiple hash colors", () => {
|
||||
const src = ".foo { color: #aaa; background: #bbb; }";
|
||||
const result = replaceInCode(src, "foo", "bar");
|
||||
expect(result).toBe(".bar { color: #aaa; background: #bbb; }");
|
||||
});
|
||||
|
||||
// Go
|
||||
it("Go: raw string literal with backticks", () => {
|
||||
const src = "foo := `raw string`\nfoo()";
|
||||
const result = replaceInCode(src, "foo", "bar");
|
||||
expect(result).toContain("bar()");
|
||||
});
|
||||
|
||||
// Rust
|
||||
it("Rust: division in expression", () => {
|
||||
expect(replaceInCode("let x = foo / 2;", "foo", "bar")).toBe("let x = bar / 2;");
|
||||
});
|
||||
|
||||
it("Rust: doc comment ///", () => {
|
||||
const src = "/// foo docs\nfn foo() {}";
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe("/// foo docs\nfn bar() {}");
|
||||
});
|
||||
|
||||
// C / C++
|
||||
it("C: preprocessor #include (hash at line start)", () => {
|
||||
const src = '#include "foo.h"\nfoo();';
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe('#include "foo.h"\nbar();');
|
||||
});
|
||||
|
||||
it("C: #define (hash at line start)", () => {
|
||||
const src = "#define FOO 1\nFOO;";
|
||||
expect(replaceInCode(src, "FOO", "BAR")).toBe("#define FOO 1\nBAR;");
|
||||
});
|
||||
|
||||
// Pathological inputs
|
||||
it("source of only slashes", () => {
|
||||
expect(() => replaceInCode("////", "foo", "bar")).not.toThrow();
|
||||
});
|
||||
|
||||
it("source of only hashes after newlines", () => {
|
||||
expect(() => replaceInCode("\n#\n#\n#\n", "foo", "bar")).not.toThrow();
|
||||
});
|
||||
|
||||
it("source of only quotes", () => {
|
||||
expect(() => replaceInCode('""""', "foo", "bar")).not.toThrow();
|
||||
});
|
||||
|
||||
it("source of alternating special chars", () => {
|
||||
expect(() => replaceInCode('/"#`\'/"#`\'', "foo", "bar")).not.toThrow();
|
||||
});
|
||||
|
||||
it("1000 consecutive slashes", () => {
|
||||
const src = "/".repeat(1000);
|
||||
const start = performance.now();
|
||||
expect(() => replaceInCode(src, "foo", "bar")).not.toThrow();
|
||||
expect(performance.now() - start).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it("1000 consecutive backticks", () => {
|
||||
const src = "`".repeat(1000);
|
||||
const start = performance.now();
|
||||
expect(() => replaceInCode(src, "foo", "bar")).not.toThrow();
|
||||
expect(performance.now() - start).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it("deeply nested template interpolations", () => {
|
||||
let src = "foo";
|
||||
for (let i = 0; i < 20; i++) src = `\`\${${src}}\``;
|
||||
const start = performance.now();
|
||||
expect(() => replaceInCode(src, "foo", "bar")).not.toThrow();
|
||||
expect(performance.now() - start).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it("unclosed multi-line comment at EOF", () => {
|
||||
expect(replaceInCode("foo(); /* unclosed", "foo", "bar")).toBe("bar(); /* unclosed");
|
||||
});
|
||||
|
||||
it("unclosed string at EOF", () => {
|
||||
expect(replaceInCode('foo(); "unclosed', "foo", "bar")).toBe('bar(); "unclosed');
|
||||
});
|
||||
|
||||
it("null bytes in source", () => {
|
||||
expect(() => replaceInCode("foo\x00bar", "foo", "bar")).not.toThrow();
|
||||
});
|
||||
|
||||
it("empty string", () => {
|
||||
expect(replaceInCode("", "foo", "bar")).toBe("");
|
||||
});
|
||||
|
||||
it("source with no code regions (all comments/strings)", () => {
|
||||
expect(replaceInCode('// foo\n"foo"\n/* foo */', "foo", "bar")).toBe('// foo\n"foo"\n/* foo */');
|
||||
});
|
||||
|
||||
it("10KB source with lots of code", () => {
|
||||
const lines = Array.from({ length: 250 }, (_, i) =>
|
||||
`function fn${i}(foo: number) { return foo + ${i}; }`,
|
||||
);
|
||||
const src = lines.join("\n");
|
||||
expect(src.length).toBeGreaterThan(10000);
|
||||
const start = performance.now();
|
||||
const result = replaceInCode(src, "foo", "bar");
|
||||
expect(performance.now() - start).toBeLessThan(500);
|
||||
expect(result).toContain("bar");
|
||||
expect(result).not.toContain("foo");
|
||||
});
|
||||
|
||||
it("alternating template expressions", () => {
|
||||
const fragment = "`${a}`";
|
||||
const src = `const x = ${fragment.repeat(100)}; foo();`;
|
||||
const start = performance.now();
|
||||
const result = replaceInCode(src, "foo", "bar");
|
||||
expect(performance.now() - start).toBeLessThan(500);
|
||||
expect(result).toContain("bar()");
|
||||
});
|
||||
});
|
||||
|
||||
// ── RegexBackend hang safety across languages ──
|
||||
|
||||
describe("RegexBackend — hang safety", () => {
|
||||
it("TS: file with only comments", async () => {
|
||||
const f = writeTemp("comments.ts", "// line1\n/* block\n*/\n// line2");
|
||||
const symbols = await backend.findSymbols(f);
|
||||
expect(symbols).toEqual([]);
|
||||
});
|
||||
|
||||
it("TS: deeply nested braces", async () => {
|
||||
const braces = "{".repeat(50) + "}".repeat(50);
|
||||
const f = writeTemp("deep.ts", `function foo() ${braces}`);
|
||||
const block = await backend.readSymbol(f, "foo");
|
||||
expect(block).not.toBeNull();
|
||||
});
|
||||
|
||||
it("TS: function with no closing brace", async () => {
|
||||
const f = writeTemp("unclosed.ts", "function foo() {\n return 1;\n // never closes");
|
||||
const block = await backend.readSymbol(f, "foo");
|
||||
expect(block).not.toBeNull();
|
||||
});
|
||||
|
||||
it("Python: deeply nested indentation", async () => {
|
||||
const lines = ["def foo():"];
|
||||
for (let i = 1; i <= 20; i++) lines.push(" ".repeat(i * 4) + `level${i}()`);
|
||||
const f = writeTemp("deep.py", lines.join("\n"));
|
||||
const block = await backend.readSymbol(f, "foo");
|
||||
expect(block?.content).toContain("level20");
|
||||
});
|
||||
|
||||
it("Python: function at EOF with no trailing newline", async () => {
|
||||
const f = writeTemp("eof.py", "def foo():\n pass");
|
||||
const block = await backend.readSymbol(f, "foo");
|
||||
expect(block).not.toBeNull();
|
||||
});
|
||||
|
||||
it("Python: empty function body", async () => {
|
||||
const f = writeTemp("empty.py", "def foo():\n\ndef bar():\n pass");
|
||||
const block = await backend.readSymbol(f, "foo");
|
||||
expect(block).not.toBeNull();
|
||||
expect(block?.content).not.toContain("bar");
|
||||
});
|
||||
|
||||
it("Go: method receiver with pointer", async () => {
|
||||
const f = writeTemp("method.go", "func (s *Server) Start() {\n\treturn\n}");
|
||||
const symbols = await backend.findSymbols(f);
|
||||
expect(symbols?.[0]?.name).toBe("Start");
|
||||
});
|
||||
|
||||
it("Go: interface with embedded types", async () => {
|
||||
const f = writeTemp("iface.go", "type Reader interface {\n\tRead(p []byte) (n int, err error)\n}");
|
||||
const symbols = await backend.findSymbols(f);
|
||||
expect(symbols?.find((s) => s.name === "Reader")).toBeDefined();
|
||||
});
|
||||
|
||||
it("Rust: generic struct", async () => {
|
||||
const f = writeTemp("generic.rs", "pub struct Vec<T> {\n ptr: *mut T,\n len: usize,\n}");
|
||||
const symbols = await backend.findSymbols(f);
|
||||
expect(symbols?.find((s) => s.name === "Vec")).toBeDefined();
|
||||
});
|
||||
|
||||
it("Rust: impl block (not a symbol)", async () => {
|
||||
const f = writeTemp("impl.rs", "impl Foo {\n pub fn bar() {}\n}");
|
||||
const symbols = await backend.findSymbols(f);
|
||||
expect(symbols?.find((s) => s.name === "bar")).toBeDefined();
|
||||
});
|
||||
|
||||
it("empty file", async () => {
|
||||
const f = writeTemp("empty.ts", "");
|
||||
const symbols = await backend.findSymbols(f);
|
||||
expect(symbols === null || symbols?.length === 0).toBe(true);
|
||||
});
|
||||
|
||||
it("file with only whitespace", async () => {
|
||||
const f = writeTemp("ws.ts", " \n\n\t\t\n ");
|
||||
const symbols = await backend.findSymbols(f);
|
||||
expect(symbols).toEqual([]);
|
||||
});
|
||||
|
||||
it("file with 10000 lines", async () => {
|
||||
const lines = Array.from({ length: 10000 }, (_, i) => `const x${i} = ${i};`);
|
||||
lines[5000] = "function target() {}";
|
||||
const f = writeTemp("big.ts", lines.join("\n"));
|
||||
const start = performance.now();
|
||||
const symbols = await backend.findSymbols(f, "target");
|
||||
expect(performance.now() - start).toBeLessThan(500);
|
||||
expect(symbols).toHaveLength(1);
|
||||
expect(symbols?.[0]?.location.line).toBe(5001);
|
||||
});
|
||||
|
||||
it("binary-looking content doesn't crash", async () => {
|
||||
const f = writeTemp("binary.ts", "\x00\x01\x02function foo() {}\x00\x00");
|
||||
const symbols = await backend.findSymbols(f);
|
||||
expect(symbols).not.toBeNull();
|
||||
});
|
||||
|
||||
it("readScope with startLine beyond file length", async () => {
|
||||
const f = writeTemp("short.ts", "const x = 1;");
|
||||
const block = await backend.readScope(f, 999);
|
||||
expect(block?.content).toBe("");
|
||||
});
|
||||
|
||||
it("readScope with endLine before startLine", async () => {
|
||||
const f = writeTemp("backwards.ts", "line1\nline2\nline3");
|
||||
const block = await backend.readScope(f, 3, 1);
|
||||
expect(block).not.toBeNull();
|
||||
});
|
||||
|
||||
it("file with 1000 functions — find last one", async () => {
|
||||
const lines = Array.from({ length: 1000 }, (_, i) => `function fn${i}() { return ${i}; }`);
|
||||
const f = writeTemp("manyfns.ts", lines.join("\n"));
|
||||
const start = performance.now();
|
||||
const block = await backend.readSymbol(f, "fn999");
|
||||
expect(performance.now() - start).toBeLessThan(500);
|
||||
expect(block).not.toBeNull();
|
||||
expect(block?.content).toContain("fn999");
|
||||
});
|
||||
|
||||
it("file with extremely long lines", async () => {
|
||||
const longLine = "x".repeat(10000);
|
||||
const lines = Array.from({ length: 10 }, (_, i) =>
|
||||
i === 5 ? `function target() { const s = "${longLine}"; }` : `const v${i} = "${longLine}";`,
|
||||
);
|
||||
const f = writeTemp("longlines.ts", lines.join("\n"));
|
||||
const start = performance.now();
|
||||
const symbols = await backend.findSymbols(f, "target");
|
||||
expect(performance.now() - start).toBeLessThan(500);
|
||||
expect(symbols).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Import parser hang safety ──
|
||||
|
||||
describe("import parser — hang safety", () => {
|
||||
it("import with { but no closing } — runs to EOF without hanging", () => {
|
||||
const src = 'import { foo,\nbar,\nbaz\nfrom "./mod";\nconst x = 1;';
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it("1000 imports", () => {
|
||||
const lines = Array.from({ length: 1000 }, (_, i) => `import { x${i} } from "./mod${i}";`);
|
||||
const start = performance.now();
|
||||
const result = tsJsHandler.parse(lines.join("\n"));
|
||||
expect(performance.now() - start).toBeLessThan(500);
|
||||
expect(result).toHaveLength(1000);
|
||||
});
|
||||
|
||||
it("import with empty braces — skipped by production parser", () => {
|
||||
const src = 'import {} from "./empty";';
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("multiline export with 100 specifiers", () => {
|
||||
const specs = Array.from({ length: 100 }, (_, i) => ` Spec${i}`).join(",\n");
|
||||
const src = `export type {\n${specs}\n} from "./types";`;
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.isReExport).toBe(true);
|
||||
expect(result[0]?.isType).toBe(true);
|
||||
});
|
||||
|
||||
it("line that looks like import but isn't", () => {
|
||||
const src = 'const s = "import { foo } from bar";';
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("export without from (not a re-export)", () => {
|
||||
const src = "export { foo };";
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("brace in string on same line as import (confusing parser)", () => {
|
||||
const src = 'import { foo } from "./bar"; // {tricky}';
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.source).toBe("./bar");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Brace scope extraction hang safety ──
|
||||
|
||||
describe("extractBraceScope — hang safety via readSymbol", () => {
|
||||
it("braces inside string literals (false depth counting)", async () => {
|
||||
const src = [
|
||||
'function foo() {',
|
||||
' const s = "}}}";',
|
||||
' const t = "{{{";',
|
||||
' return s + t;',
|
||||
'}',
|
||||
].join("\n");
|
||||
const f = writeTemp("stringbraces.ts", src);
|
||||
const block = await backend.readSymbol(f, "foo");
|
||||
expect(block).not.toBeNull();
|
||||
});
|
||||
|
||||
it("100 levels of nesting", async () => {
|
||||
const open = "function foo() " + "{".repeat(100);
|
||||
const close = "}".repeat(100);
|
||||
const f = writeTemp("deepnest.ts", `${open}\n return 1;\n${close}`);
|
||||
const start = performance.now();
|
||||
const block = await backend.readSymbol(f, "foo");
|
||||
expect(performance.now() - start).toBeLessThan(500);
|
||||
expect(block).not.toBeNull();
|
||||
});
|
||||
|
||||
it("mismatched braces — more closes than opens", async () => {
|
||||
const f = writeTemp("mismatch.ts", "function foo() {\n}\n}\n}\n}");
|
||||
const block = await backend.readSymbol(f, "foo");
|
||||
expect(block).not.toBeNull();
|
||||
expect(block?.location.endLine).toBe(2);
|
||||
});
|
||||
|
||||
it("mismatched braces — more opens than closes", async () => {
|
||||
const f = writeTemp("unclosed2.ts", "function foo() {\n {\n {\n");
|
||||
const block = await backend.readSymbol(f, "foo");
|
||||
expect(block).not.toBeNull();
|
||||
});
|
||||
|
||||
it("no braces at all (arrow function one-liner)", async () => {
|
||||
const f = writeTemp("arrow.ts", "const foo = () => 42;");
|
||||
const block = await backend.readSymbol(f, "foo");
|
||||
expect(block).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { MemoryDB } from "../src/core/memory/db.js";
|
||||
|
||||
/**
|
||||
* Tests for MemoryDB using in-memory SQLite.
|
||||
* This is the persistence layer for all agent memories.
|
||||
* Bugs here = lost memories, corrupt state, search failures.
|
||||
*/
|
||||
|
||||
let db: MemoryDB;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new MemoryDB(":memory:", "project");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
describe("MemoryDB — write & read", () => {
|
||||
it("writes and reads back a record", () => {
|
||||
const record = db.write({
|
||||
title: "Test memory",
|
||||
content: "Some content",
|
||||
category: "fact",
|
||||
tags: ["test"],
|
||||
});
|
||||
expect(record.id).toBeTruthy();
|
||||
expect(record.title).toBe("Test memory");
|
||||
expect(record.tags).toEqual(["test"]);
|
||||
|
||||
const read = db.read(record.id);
|
||||
expect(read).not.toBeNull();
|
||||
expect(read!.title).toBe("Test memory");
|
||||
expect(read!.content).toBe("Some content");
|
||||
});
|
||||
|
||||
it("upserts on duplicate id", () => {
|
||||
const r1 = db.write({
|
||||
id: "fixed-id",
|
||||
title: "Version 1",
|
||||
content: "Old",
|
||||
category: "fact",
|
||||
tags: [],
|
||||
});
|
||||
const r2 = db.write({
|
||||
id: "fixed-id",
|
||||
title: "Version 2",
|
||||
content: "New",
|
||||
category: "fact",
|
||||
tags: [],
|
||||
});
|
||||
expect(r1.id).toBe("fixed-id");
|
||||
expect(r2.id).toBe("fixed-id");
|
||||
|
||||
const read = db.read("fixed-id");
|
||||
expect(read!.title).toBe("Version 2");
|
||||
expect(read!.content).toBe("New");
|
||||
});
|
||||
|
||||
it("returns null for nonexistent id", () => {
|
||||
expect(db.read("does-not-exist")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles all valid categories", () => {
|
||||
const categories = ["decision", "convention", "preference", "architecture", "pattern", "fact"] as const;
|
||||
for (const cat of categories) {
|
||||
const r = db.write({ title: cat, content: "x", category: cat, tags: [] });
|
||||
expect(r.category).toBe(cat);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid category", () => {
|
||||
expect(() =>
|
||||
db.write({ title: "Bad", content: "x", category: "invalid" as "fact", tags: [] }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("handles empty tags", () => {
|
||||
const r = db.write({ title: "No tags", content: "x", category: "fact", tags: [] });
|
||||
expect(r.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles multiple tags", () => {
|
||||
const r = db.write({
|
||||
title: "Tagged",
|
||||
content: "x",
|
||||
category: "fact",
|
||||
tags: ["a", "b", "c"],
|
||||
});
|
||||
expect(r.tags).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("handles unicode content", () => {
|
||||
const r = db.write({
|
||||
title: "日本語テスト",
|
||||
content: "内容 🎉 résumé",
|
||||
category: "fact",
|
||||
tags: ["émoji"],
|
||||
});
|
||||
const read = db.read(r.id);
|
||||
expect(read!.title).toBe("日本語テスト");
|
||||
expect(read!.content).toContain("🎉");
|
||||
});
|
||||
|
||||
it("handles very long content", () => {
|
||||
const content = "x".repeat(100_000);
|
||||
const r = db.write({ title: "Long", content, category: "fact", tags: [] });
|
||||
const read = db.read(r.id);
|
||||
expect(read!.content.length).toBe(100_000);
|
||||
});
|
||||
|
||||
it("handles content with SQL injection attempt", () => {
|
||||
const r = db.write({
|
||||
title: "'; DROP TABLE memories; --",
|
||||
content: "Robert'); DROP TABLE memories;--",
|
||||
category: "fact",
|
||||
tags: ["'; DROP TABLE"],
|
||||
});
|
||||
const read = db.read(r.id);
|
||||
expect(read!.title).toBe("'; DROP TABLE memories; --");
|
||||
// Table should still work
|
||||
const list = db.list();
|
||||
expect(list.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryDB — delete", () => {
|
||||
it("deletes existing record", () => {
|
||||
const r = db.write({ title: "To delete", content: "x", category: "fact", tags: [] });
|
||||
expect(db.delete(r.id)).toBe(true);
|
||||
expect(db.read(r.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns false for nonexistent id", () => {
|
||||
expect(db.delete("nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("removes from FTS index after delete", () => {
|
||||
const r = db.write({
|
||||
title: "Searchable unique_keyword_xyz",
|
||||
content: "content",
|
||||
category: "fact",
|
||||
tags: [],
|
||||
});
|
||||
db.delete(r.id);
|
||||
const results = db.search("unique_keyword_xyz");
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryDB — list", () => {
|
||||
it("lists all records", () => {
|
||||
db.write({ title: "A", content: "x", category: "fact", tags: [] });
|
||||
db.write({ title: "B", content: "y", category: "decision", tags: [] });
|
||||
const list = db.list();
|
||||
expect(list.length).toBe(2);
|
||||
});
|
||||
|
||||
it("filters by category", () => {
|
||||
db.write({ title: "A", content: "x", category: "fact", tags: [] });
|
||||
db.write({ title: "B", content: "y", category: "decision", tags: [] });
|
||||
const facts = db.list({ category: "fact" });
|
||||
expect(facts.length).toBe(1);
|
||||
expect(facts[0]!.title).toBe("A");
|
||||
});
|
||||
|
||||
it("filters by tag", () => {
|
||||
db.write({ title: "A", content: "x", category: "fact", tags: ["important"] });
|
||||
db.write({ title: "B", content: "y", category: "fact", tags: ["trivial"] });
|
||||
const important = db.list({ tag: "important" });
|
||||
expect(important.length).toBe(1);
|
||||
expect(important[0]!.title).toBe("A");
|
||||
});
|
||||
|
||||
it("filters by both category and tag", () => {
|
||||
db.write({ title: "A", content: "x", category: "fact", tags: ["important"] });
|
||||
db.write({ title: "B", content: "y", category: "decision", tags: ["important"] });
|
||||
db.write({ title: "C", content: "z", category: "fact", tags: ["trivial"] });
|
||||
const result = db.list({ category: "fact", tag: "important" });
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]!.title).toBe("A");
|
||||
});
|
||||
|
||||
it("returns empty for no matches", () => {
|
||||
expect(db.list({ category: "architecture" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("orders by updated_at DESC", () => {
|
||||
db.write({ id: "old", title: "Old", content: "x", category: "fact", tags: [] });
|
||||
db.write({ id: "new", title: "New", content: "y", category: "fact", tags: [] });
|
||||
const list = db.list();
|
||||
expect(list[0]!.title).toBe("New");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryDB — search (FTS)", () => {
|
||||
it("finds by title keyword", () => {
|
||||
db.write({ title: "TypeScript conventions", content: "x", category: "convention", tags: [] });
|
||||
db.write({ title: "Python patterns", content: "y", category: "pattern", tags: [] });
|
||||
const results = db.search("TypeScript");
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]!.title).toContain("TypeScript");
|
||||
});
|
||||
|
||||
it("finds by content keyword", () => {
|
||||
db.write({ title: "A", content: "Use bun instead of node", category: "convention", tags: [] });
|
||||
const results = db.search("bun");
|
||||
expect(results.length).toBe(1);
|
||||
});
|
||||
|
||||
it("finds by tag keyword", () => {
|
||||
db.write({ title: "A", content: "x", category: "fact", tags: ["performance"] });
|
||||
const results = db.search("performance");
|
||||
expect(results.length).toBe(1);
|
||||
});
|
||||
|
||||
it("handles multi-word query (OR)", () => {
|
||||
db.write({ title: "TypeScript guide", content: "x", category: "fact", tags: [] });
|
||||
db.write({ title: "Python guide", content: "y", category: "fact", tags: [] });
|
||||
db.write({ title: "Unrelated", content: "z", category: "fact", tags: [] });
|
||||
const results = db.search("TypeScript Python");
|
||||
expect(results.length).toBe(2);
|
||||
});
|
||||
|
||||
it("returns empty for no matches", () => {
|
||||
db.write({ title: "Something", content: "x", category: "fact", tags: [] });
|
||||
const results = db.search("xyznonexistent");
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
it("respects limit", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
db.write({ title: `Item keyword ${i}`, content: "x", category: "fact", tags: [] });
|
||||
}
|
||||
const results = db.search("keyword", 3);
|
||||
expect(results.length).toBe(3);
|
||||
});
|
||||
|
||||
it("falls back to list() on empty query", () => {
|
||||
db.write({ title: "A", content: "x", category: "fact", tags: [] });
|
||||
const results = db.search("");
|
||||
expect(results.length).toBe(1);
|
||||
});
|
||||
|
||||
it("handles quotes in search safely", () => {
|
||||
db.write({ title: 'Say "hello"', content: "x", category: "fact", tags: [] });
|
||||
expect(() => db.search('"hello"')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryDB — searchFull", () => {
|
||||
it("returns full records with content", () => {
|
||||
db.write({ title: "Full record", content: "detailed content here", category: "fact", tags: ["a"] });
|
||||
const results = db.searchFull("detailed");
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]!.content).toBe("detailed content here");
|
||||
expect(results[0]!.tags).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("returns empty for empty query", () => {
|
||||
db.write({ title: "A", content: "x", category: "fact", tags: [] });
|
||||
expect(db.searchFull("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryDB — getIndex", () => {
|
||||
it("returns correct totals", () => {
|
||||
db.write({ title: "A", content: "x", category: "fact", tags: [] });
|
||||
db.write({ title: "B", content: "y", category: "decision", tags: [] });
|
||||
db.write({ title: "C", content: "z", category: "fact", tags: [] });
|
||||
const idx = db.getIndex();
|
||||
expect(idx.total).toBe(3);
|
||||
expect(idx.byCategory.fact).toBe(2);
|
||||
expect(idx.byCategory.decision).toBe(1);
|
||||
expect(idx.scope).toBe("project");
|
||||
});
|
||||
|
||||
it("returns recent titles (up to 5)", () => {
|
||||
for (let i = 0; i < 7; i++) {
|
||||
db.write({ title: `Item ${i}`, content: "x", category: "fact", tags: [] });
|
||||
}
|
||||
const idx = db.getIndex();
|
||||
expect(idx.recent.length).toBe(5);
|
||||
});
|
||||
|
||||
it("handles empty database", () => {
|
||||
const idx = db.getIndex();
|
||||
expect(idx.total).toBe(0);
|
||||
expect(idx.recent).toEqual([]);
|
||||
expect(idx.byCategory).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryDB — stress", () => {
|
||||
it("handles 500 writes without error", () => {
|
||||
for (let i = 0; i < 500; i++) {
|
||||
db.write({ title: `Record ${i}`, content: `Content ${i}`, category: "fact", tags: [`tag${i % 10}`] });
|
||||
}
|
||||
expect(db.list().length).toBe(500);
|
||||
});
|
||||
|
||||
it("search after many writes still works", () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
db.write({ title: `Record ${i}`, content: `Content about topic${i}`, category: "fact", tags: [] });
|
||||
}
|
||||
const results = db.search("topic50");
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("handles rapid write-delete cycles", () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const r = db.write({ title: `Temp ${i}`, content: "x", category: "fact", tags: [] });
|
||||
db.delete(r.id);
|
||||
}
|
||||
expect(db.list().length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
tsJsHandler,
|
||||
pythonHandler,
|
||||
rustHandler,
|
||||
} from "../src/core/tools/move-symbol.js";
|
||||
|
||||
describe("tsJsHandler.parse", () => {
|
||||
it("parses single-line import", () => {
|
||||
const result = tsJsHandler.parse('import { foo } from "./bar";');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.specifiers).toEqual(["foo"]);
|
||||
expect(result[0]?.source).toBe("./bar");
|
||||
});
|
||||
|
||||
it("parses multiline import", () => {
|
||||
const src = 'import {\n foo,\n bar,\n} from "./mod";';
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.specifiers).toEqual(["foo", "bar"]);
|
||||
expect(result[0]?.startLine).toBe(0);
|
||||
expect(result[0]?.endLine).toBe(3);
|
||||
});
|
||||
|
||||
it("parses type imports", () => {
|
||||
const src = 'import type { Foo } from "./types";';
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.isType).toBe(true);
|
||||
});
|
||||
|
||||
it("parses re-exports", () => {
|
||||
const src = 'export { foo } from "./bar";';
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.isReExport).toBe(true);
|
||||
});
|
||||
|
||||
it("parses multiline re-export (export type { ... } spread across lines)", () => {
|
||||
const src = 'export type {\n Foo,\n Bar,\n} from "./types";';
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.isReExport).toBe(true);
|
||||
expect(result[0]?.isType).toBe(true);
|
||||
expect(result[0]?.specifiers).toEqual(["Foo", "Bar"]);
|
||||
});
|
||||
|
||||
it("handles import with 'as' alias", () => {
|
||||
const src = 'import { foo as bar } from "./mod";';
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result[0]?.specifiers).toEqual(["foo as bar"]);
|
||||
});
|
||||
|
||||
it("skips default imports (no braces)", () => {
|
||||
const src = 'import React from "react";';
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips namespace imports", () => {
|
||||
const src = 'import * as path from "path";';
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles mixed content (imports + code + exports)", () => {
|
||||
const src = [
|
||||
'import { a } from "./a";',
|
||||
"",
|
||||
"const x = 1;",
|
||||
"",
|
||||
'export { b } from "./b";',
|
||||
].join("\n");
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]?.specifiers).toEqual(["a"]);
|
||||
expect(result[1]?.specifiers).toEqual(["b"]);
|
||||
expect(result[1]?.isReExport).toBe(true);
|
||||
});
|
||||
|
||||
it("handles import with trailing content on same line", () => {
|
||||
const src = 'import { foo } from "./bar"; // comment';
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.source).toBe("./bar");
|
||||
});
|
||||
|
||||
it("handles empty file", () => {
|
||||
expect(tsJsHandler.parse("")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles multiline import that never closes brace", () => {
|
||||
const src = 'import {\n foo,\n bar,\nfrom "./mod";';
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles import where { is in a string on a non-import line", () => {
|
||||
const src = 'const x = "{ foo }";\nimport { bar } from "./mod";';
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.specifiers).toEqual(["bar"]);
|
||||
});
|
||||
|
||||
it("handles export without 'from' (not a re-export)", () => {
|
||||
const src = "export { foo };";
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── Edge cases ──
|
||||
|
||||
it("returns 0 results for empty braces import", () => {
|
||||
const src = 'import {} from "./foo";';
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves alias in type import specifiers", () => {
|
||||
const src = 'import type { Foo as Bar } from "./types";';
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.isType).toBe(true);
|
||||
expect(result[0]?.specifiers).toEqual(["Foo as Bar"]);
|
||||
});
|
||||
|
||||
it("parses multiple re-exports in same file", () => {
|
||||
const src = [
|
||||
'export { alpha } from "./a";',
|
||||
'export { beta } from "./b";',
|
||||
'export type { Gamma } from "./c";',
|
||||
].join("\n");
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]?.specifiers).toEqual(["alpha"]);
|
||||
expect(result[0]?.isReExport).toBe(true);
|
||||
expect(result[1]?.specifiers).toEqual(["beta"]);
|
||||
expect(result[1]?.isReExport).toBe(true);
|
||||
expect(result[2]?.specifiers).toEqual(["Gamma"]);
|
||||
expect(result[2]?.isReExport).toBe(true);
|
||||
expect(result[2]?.isType).toBe(true);
|
||||
});
|
||||
|
||||
it("skips import with no from clause", () => {
|
||||
const src = 'import "side-effects-only";';
|
||||
const result = tsJsHandler.parse(src);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not match 'import' inside a comment", () => {
|
||||
const src = [
|
||||
'// import { fake } from "./not-real";',
|
||||
'import { real } from "./actual";',
|
||||
].join("\n");
|
||||
const result = tsJsHandler.parse(src);
|
||||
// The comment line starts with "// import" which still starts with "import" after trim? No — it starts with "//"
|
||||
// So only the real import is matched
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.specifiers).toEqual(["real"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pythonHandler.parse", () => {
|
||||
it("parses from...import", () => {
|
||||
const result = pythonHandler.parse("from models import User, Post");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.specifiers).toEqual(["User", "Post"]);
|
||||
expect(result[0]?.source).toBe("models");
|
||||
});
|
||||
|
||||
it("parses relative imports", () => {
|
||||
const result = pythonHandler.parse("from .utils import helper");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.source).toBe(".utils");
|
||||
});
|
||||
|
||||
it("parses double-dot relative imports", () => {
|
||||
const result = pythonHandler.parse("from ..base import Base");
|
||||
expect(result[0]?.source).toBe("..base");
|
||||
});
|
||||
|
||||
it("skips plain 'import X' statements", () => {
|
||||
const result = pythonHandler.parse("import os");
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles empty file", () => {
|
||||
expect(pythonHandler.parse("")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles import with alias", () => {
|
||||
const result = pythonHandler.parse("from os.path import join as pjoin");
|
||||
expect(result[0]?.specifiers).toEqual(["join as pjoin"]);
|
||||
});
|
||||
|
||||
// ── Edge cases ──
|
||||
|
||||
it("parses multiple imports on different lines", () => {
|
||||
const src = [
|
||||
"from os import getcwd",
|
||||
"from sys import argv, exit",
|
||||
"from pathlib import Path",
|
||||
].join("\n");
|
||||
const result = pythonHandler.parse(src);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]?.specifiers).toEqual(["getcwd"]);
|
||||
expect(result[0]?.source).toBe("os");
|
||||
expect(result[1]?.specifiers).toEqual(["argv", "exit"]);
|
||||
expect(result[1]?.source).toBe("sys");
|
||||
expect(result[2]?.specifiers).toEqual(["Path"]);
|
||||
expect(result[2]?.source).toBe("pathlib");
|
||||
});
|
||||
|
||||
it("parses import with multiple aliases", () => {
|
||||
const result = pythonHandler.parse(
|
||||
"from os.path import join as pjoin, exists as pexists",
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.specifiers).toEqual(["join as pjoin", "exists as pexists"]);
|
||||
expect(result[0]?.source).toBe("os.path");
|
||||
});
|
||||
|
||||
it("returns empty for empty file", () => {
|
||||
expect(pythonHandler.parse("")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("parses import-like line inside a string (parser has no string awareness)", () => {
|
||||
const src = 's = "from fake import Nope"';
|
||||
const result = pythonHandler.parse(src);
|
||||
// The regex matches any line starting with "from ... import ...",
|
||||
// but this line starts with 's = "from' which doesn't match ^from
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("matches import-looking line if it literally starts at column 0 (e.g. in a docstring)", () => {
|
||||
const src = [
|
||||
'"""',
|
||||
"from example import Documented",
|
||||
'"""',
|
||||
].join("\n");
|
||||
const result = pythonHandler.parse(src);
|
||||
// Parser is line-based with no string/docstring awareness — it matches
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.specifiers).toEqual(["Documented"]);
|
||||
});
|
||||
|
||||
it("parses deeply nested relative import", () => {
|
||||
const result = pythonHandler.parse("from ...base import Thing");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.source).toBe("...base");
|
||||
expect(result[0]?.specifiers).toEqual(["Thing"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rustHandler.parse", () => {
|
||||
it("parses brace-style use", () => {
|
||||
const result = rustHandler.parse("use std::collections::{HashMap, HashSet};");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.specifiers).toEqual(["HashMap", "HashSet"]);
|
||||
expect(result[0]?.source).toBe("std::collections");
|
||||
});
|
||||
|
||||
it("parses single-item use", () => {
|
||||
const result = rustHandler.parse("use std::io::Read;");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.specifiers).toEqual(["Read"]);
|
||||
expect(result[0]?.source).toBe("std::io");
|
||||
});
|
||||
|
||||
it("handles use with self", () => {
|
||||
const result = rustHandler.parse("use std::io::{self, Read};");
|
||||
expect(result[0]?.specifiers).toEqual(["self", "Read"]);
|
||||
});
|
||||
|
||||
it("handles single-segment use", () => {
|
||||
const result = rustHandler.parse("use serde;");
|
||||
expect(result[0]?.specifiers).toEqual(["serde"]);
|
||||
expect(result[0]?.source).toBe("");
|
||||
});
|
||||
|
||||
it("skips lines without semicolons (e.g. multiline use — not supported)", () => {
|
||||
const src = "use std::{\n io,\n fs,\n};";
|
||||
const result = rustHandler.parse(src);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles empty file", () => {
|
||||
expect(rustHandler.parse("")).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── Edge cases ──
|
||||
|
||||
it("skips pub use re-exports (line must start with 'use')", () => {
|
||||
const src = "pub use crate::module::Item;";
|
||||
const result = rustHandler.parse(src);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("parses crate path use", () => {
|
||||
const result = rustHandler.parse("use crate::module::Item;");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.specifiers).toEqual(["Item"]);
|
||||
expect(result[0]?.source).toBe("crate::module");
|
||||
});
|
||||
|
||||
it("parses super path use", () => {
|
||||
const result = rustHandler.parse("use super::parent::Thing;");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.specifiers).toEqual(["Thing"]);
|
||||
expect(result[0]?.source).toBe("super::parent");
|
||||
});
|
||||
|
||||
it("parses glob import", () => {
|
||||
const result = rustHandler.parse("use std::io::*;");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.specifiers).toEqual(["*"]);
|
||||
expect(result[0]?.source).toBe("std::io");
|
||||
});
|
||||
|
||||
it("parses multiple use statements", () => {
|
||||
const src = [
|
||||
"use std::io::Read;",
|
||||
"use std::fs::File;",
|
||||
"use std::collections::{HashMap, BTreeMap};",
|
||||
].join("\n");
|
||||
const result = rustHandler.parse(src);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]?.specifiers).toEqual(["Read"]);
|
||||
expect(result[0]?.source).toBe("std::io");
|
||||
expect(result[1]?.specifiers).toEqual(["File"]);
|
||||
expect(result[1]?.source).toBe("std::fs");
|
||||
expect(result[2]?.specifiers).toEqual(["HashMap", "BTreeMap"]);
|
||||
expect(result[2]?.source).toBe("std::collections");
|
||||
});
|
||||
|
||||
it("returns empty for empty file", () => {
|
||||
expect(rustHandler.parse("")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
/**
|
||||
* Tests for buildDefinitionPattern from navigate.ts.
|
||||
* This is the rg fallback when LSP/tree-sitter aren't available —
|
||||
* it builds a regex to find symbol definitions across 14+ language families.
|
||||
* A bad pattern = navigate can't find anything = user stuck.
|
||||
*/
|
||||
|
||||
const DEFINITION_KEYWORDS = [
|
||||
String.raw`(?:export\s+)?(?:default\s+)?(?:abstract\s+)?(?:async\s+)?(?:function\*?|class|interface|type|const|let|var|enum)\s+`,
|
||||
String.raw`(?:async\s+)?(?:def|class)\s+`,
|
||||
String.raw`(?:func\s+(?:\([^)]*\)\s+)?|type\s+)`,
|
||||
String.raw`(?:pub(?:\([^)]*\)\s+)?)?(?:async\s+)?(?:fn|struct|trait|type|const|static|enum|mod|union)\s+`,
|
||||
String.raw`(?:public\s+|private\s+|protected\s+)?(?:abstract\s+|static\s+|final\s+|sealed\s+|data\s+|open\s+|internal\s+)*(?:class|interface|enum|record|object|annotation)\s+`,
|
||||
String.raw`(?:class|struct|enum(?:\s+class)?|namespace|typedef|using)\s+`,
|
||||
String.raw`(?:public\s+|private\s+|protected\s+|internal\s+)?(?:abstract\s+|static\s+|sealed\s+|partial\s+)*(?:class|struct|interface|enum|record|delegate)\s+`,
|
||||
String.raw`(?:def\s+(?:self\.)?|class\s+|module\s+)`,
|
||||
String.raw`(?:public\s+|private\s+|protected\s+)?(?:static\s+)?(?:function|class|interface|trait|enum)\s+`,
|
||||
String.raw`(?:public\s+|private\s+|internal\s+|open\s+)?(?:class|struct|protocol|enum|typealias|func)\s+`,
|
||||
String.raw`(?:def|defp|defmodule|defmacro|defstruct)\s+`,
|
||||
String.raw`(?:abstract\s+)?(?:class|mixin|extension|typedef|enum)\s+`,
|
||||
String.raw`(?:pub\s+)?(?:fn|const)\s+`,
|
||||
String.raw`(?:local\s+)?function\s+(?:\w+[.:])?`,
|
||||
];
|
||||
|
||||
function buildDefinitionPattern(symbol: string): string {
|
||||
const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const alts = DEFINITION_KEYWORDS.map((kw) => `(?:${kw})`).join("|");
|
||||
return `(?:${alts})${escaped}\\b`;
|
||||
}
|
||||
|
||||
function matches(pattern: string, line: string): boolean {
|
||||
return new RegExp(pattern).test(line);
|
||||
}
|
||||
|
||||
describe("buildDefinitionPattern — TypeScript/JavaScript", () => {
|
||||
const pat = buildDefinitionPattern("MyClass");
|
||||
|
||||
it("matches class", () => expect(matches(pat, "class MyClass {")).toBe(true));
|
||||
it("matches export class", () => expect(matches(pat, "export class MyClass {")).toBe(true));
|
||||
it("matches abstract class", () => expect(matches(pat, "export abstract class MyClass {")).toBe(true));
|
||||
it("matches interface", () => expect(matches(pat, "interface MyClass {")).toBe(true));
|
||||
it("matches type", () => expect(matches(pat, "type MyClass = {")).toBe(true));
|
||||
it("matches const", () => expect(matches(pat, "const MyClass = 1;")).toBe(true));
|
||||
it("matches enum", () => expect(matches(pat, "enum MyClass {")).toBe(true));
|
||||
|
||||
it("matches function", () => {
|
||||
const fp = buildDefinitionPattern("doStuff");
|
||||
expect(matches(fp, "function doStuff() {")).toBe(true);
|
||||
expect(matches(fp, "async function doStuff() {")).toBe(true);
|
||||
expect(matches(fp, "export async function doStuff() {")).toBe(true);
|
||||
expect(matches(fp, "export default function doStuff() {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches generator function", () => {
|
||||
const fp = buildDefinitionPattern("gen");
|
||||
expect(matches(fp, "function* gen() {")).toBe(true);
|
||||
});
|
||||
|
||||
it("doesn't match usage (no keyword)", () => {
|
||||
expect(matches(pat, "new MyClass()")).toBe(false);
|
||||
expect(matches(pat, "const x = MyClass.create()")).toBe(false);
|
||||
});
|
||||
|
||||
it("doesn't match partial name", () => {
|
||||
expect(matches(pat, "class MyClassExtended {")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDefinitionPattern — Python", () => {
|
||||
const pat = buildDefinitionPattern("my_func");
|
||||
|
||||
it("matches def", () => expect(matches(pat, "def my_func():")).toBe(true));
|
||||
it("matches async def", () => expect(matches(pat, "async def my_func():")).toBe(true));
|
||||
|
||||
it("matches class", () => {
|
||||
const cp = buildDefinitionPattern("MyModel");
|
||||
expect(matches(cp, "class MyModel:")).toBe(true);
|
||||
expect(matches(cp, "class MyModel(Base):")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDefinitionPattern — Go", () => {
|
||||
it("matches func", () => {
|
||||
const pat = buildDefinitionPattern("HandleRequest");
|
||||
expect(matches(pat, "func HandleRequest(w http.ResponseWriter) {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches method with receiver", () => {
|
||||
const pat = buildDefinitionPattern("ServeHTTP");
|
||||
expect(matches(pat, "func (s *Server) ServeHTTP(w http.ResponseWriter) {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches type struct", () => {
|
||||
const pat = buildDefinitionPattern("Config");
|
||||
expect(matches(pat, "type Config struct {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches type interface", () => {
|
||||
const pat = buildDefinitionPattern("Handler");
|
||||
expect(matches(pat, "type Handler interface {")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDefinitionPattern — Rust", () => {
|
||||
it("matches fn", () => {
|
||||
const pat = buildDefinitionPattern("process");
|
||||
expect(matches(pat, "fn process(data: &[u8]) -> Result<()> {")).toBe(true);
|
||||
expect(matches(pat, "pub fn process(data: &[u8]) {")).toBe(true);
|
||||
expect(matches(pat, "pub async fn process() {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches struct", () => {
|
||||
const pat = buildDefinitionPattern("AppState");
|
||||
expect(matches(pat, "struct AppState {")).toBe(true);
|
||||
expect(matches(pat, "pub struct AppState {")).toBe(true);
|
||||
expect(matches(pat, "pub(crate) struct AppState {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches trait", () => {
|
||||
const pat = buildDefinitionPattern("Drawable");
|
||||
expect(matches(pat, "trait Drawable {")).toBe(true);
|
||||
expect(matches(pat, "pub trait Drawable {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches enum", () => {
|
||||
const pat = buildDefinitionPattern("Color");
|
||||
expect(matches(pat, "enum Color {")).toBe(true);
|
||||
expect(matches(pat, "pub enum Color {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches mod", () => {
|
||||
const pat = buildDefinitionPattern("utils");
|
||||
expect(matches(pat, "mod utils {")).toBe(true);
|
||||
expect(matches(pat, "pub mod utils;")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDefinitionPattern — Java/Kotlin/Scala", () => {
|
||||
it("matches Java class", () => {
|
||||
const pat = buildDefinitionPattern("UserService");
|
||||
expect(matches(pat, "public class UserService {")).toBe(true);
|
||||
expect(matches(pat, "public abstract class UserService {")).toBe(true);
|
||||
expect(matches(pat, "class UserService {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches Java interface", () => {
|
||||
const pat = buildDefinitionPattern("Repository");
|
||||
expect(matches(pat, "public interface Repository {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches Java enum", () => {
|
||||
const pat = buildDefinitionPattern("Status");
|
||||
expect(matches(pat, "public enum Status {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches Java record", () => {
|
||||
const pat = buildDefinitionPattern("Point");
|
||||
expect(matches(pat, "public record Point(int x, int y) {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches Kotlin data class", () => {
|
||||
const pat = buildDefinitionPattern("User");
|
||||
expect(matches(pat, "data class User(")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches Kotlin sealed class", () => {
|
||||
const pat = buildDefinitionPattern("Result");
|
||||
expect(matches(pat, "sealed class Result {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches Kotlin object", () => {
|
||||
const pat = buildDefinitionPattern("Singleton");
|
||||
expect(matches(pat, "object Singleton {")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDefinitionPattern — C/C++", () => {
|
||||
it("matches class", () => {
|
||||
const pat = buildDefinitionPattern("Widget");
|
||||
expect(matches(pat, "class Widget {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches struct", () => {
|
||||
const pat = buildDefinitionPattern("Point");
|
||||
expect(matches(pat, "struct Point {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches namespace", () => {
|
||||
const pat = buildDefinitionPattern("utils");
|
||||
expect(matches(pat, "namespace utils {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches enum class", () => {
|
||||
const pat = buildDefinitionPattern("Color");
|
||||
expect(matches(pat, "enum class Color {")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDefinitionPattern — Ruby", () => {
|
||||
it("matches def", () => {
|
||||
const pat = buildDefinitionPattern("process");
|
||||
expect(matches(pat, "def process")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches def self.method", () => {
|
||||
const pat = buildDefinitionPattern("create");
|
||||
expect(matches(pat, "def self.create")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches class", () => {
|
||||
const pat = buildDefinitionPattern("User");
|
||||
expect(matches(pat, "class User")).toBe(true);
|
||||
expect(matches(pat, "class User < Base")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches module", () => {
|
||||
const pat = buildDefinitionPattern("Auth");
|
||||
expect(matches(pat, "module Auth")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDefinitionPattern — Swift", () => {
|
||||
it("matches func", () => {
|
||||
const pat = buildDefinitionPattern("viewDidLoad");
|
||||
expect(matches(pat, "func viewDidLoad() {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches class", () => {
|
||||
const pat = buildDefinitionPattern("ViewController");
|
||||
expect(matches(pat, "class ViewController: UIViewController {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches struct", () => {
|
||||
const pat = buildDefinitionPattern("ContentView");
|
||||
expect(matches(pat, "struct ContentView: View {")).toBe(true);
|
||||
expect(matches(pat, "public struct ContentView {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches protocol", () => {
|
||||
const pat = buildDefinitionPattern("Drawable");
|
||||
expect(matches(pat, "protocol Drawable {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches enum", () => {
|
||||
const pat = buildDefinitionPattern("Direction");
|
||||
expect(matches(pat, "enum Direction {")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDefinitionPattern — Elixir", () => {
|
||||
it("matches def", () => {
|
||||
const pat = buildDefinitionPattern("handle_call");
|
||||
expect(matches(pat, "def handle_call(msg, _from, state) do")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches defp", () => {
|
||||
const pat = buildDefinitionPattern("do_work");
|
||||
expect(matches(pat, "defp do_work(data) do")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches defmodule", () => {
|
||||
const pat = buildDefinitionPattern("MyApp");
|
||||
expect(matches(pat, "defmodule MyApp do")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDefinitionPattern — special characters in symbol", () => {
|
||||
it("escapes regex special chars", () => {
|
||||
const pat = buildDefinitionPattern("Map.Entry");
|
||||
expect(() => new RegExp(pat)).not.toThrow();
|
||||
// The dot is escaped, so "MapXEntry" shouldn't match
|
||||
expect(matches(pat, "class MapXEntry {")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles symbols with $", () => {
|
||||
const pat = buildDefinitionPattern("$scope");
|
||||
expect(() => new RegExp(pat)).not.toThrow();
|
||||
});
|
||||
|
||||
it("handles symbols with underscores", () => {
|
||||
const pat = buildDefinitionPattern("__init__");
|
||||
expect(matches(pat, "def __init__(self):")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDefinitionPattern — word boundary", () => {
|
||||
it("doesn't match prefix", () => {
|
||||
const pat = buildDefinitionPattern("User");
|
||||
expect(matches(pat, "class UserManager {")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches exact word", () => {
|
||||
const pat = buildDefinitionPattern("User");
|
||||
expect(matches(pat, "class User {")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches at end of line", () => {
|
||||
const pat = buildDefinitionPattern("User");
|
||||
expect(matches(pat, "class User")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findEnclosingSymbol", () => {
|
||||
type SymbolInfo = {
|
||||
name: string;
|
||||
kind: string;
|
||||
location: { file: string; line: number; endLine?: number };
|
||||
containerName?: string;
|
||||
};
|
||||
|
||||
function findEnclosingSymbol(symbols: SymbolInfo[], line: number): SymbolInfo | null {
|
||||
let best: SymbolInfo | null = null;
|
||||
for (const sym of symbols) {
|
||||
const end = sym.location.endLine ?? sym.location.line;
|
||||
if (sym.location.line <= line && end >= line) {
|
||||
if (!best || sym.location.line > best.location.line) best = sym;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
const symbols: SymbolInfo[] = [
|
||||
{ name: "App", kind: "class", location: { file: "a.ts", line: 1, endLine: 50 } },
|
||||
{ name: "render", kind: "function", location: { file: "a.ts", line: 10, endLine: 30 } },
|
||||
{ name: "helper", kind: "function", location: { file: "a.ts", line: 15, endLine: 20 } },
|
||||
{ name: "standalone", kind: "function", location: { file: "a.ts", line: 60, endLine: 70 } },
|
||||
];
|
||||
|
||||
it("finds innermost enclosing symbol", () => {
|
||||
expect(findEnclosingSymbol(symbols, 17)?.name).toBe("helper");
|
||||
});
|
||||
|
||||
it("finds middle-level when not in innermost", () => {
|
||||
expect(findEnclosingSymbol(symbols, 25)?.name).toBe("render");
|
||||
});
|
||||
|
||||
it("finds outermost when only top-level matches", () => {
|
||||
expect(findEnclosingSymbol(symbols, 5)?.name).toBe("App");
|
||||
});
|
||||
|
||||
it("returns null when line is outside all symbols", () => {
|
||||
expect(findEnclosingSymbol(symbols, 55)).toBeNull();
|
||||
});
|
||||
|
||||
it("handles boundary lines (start)", () => {
|
||||
expect(findEnclosingSymbol(symbols, 10)?.name).toBe("render");
|
||||
});
|
||||
|
||||
it("handles boundary lines (end)", () => {
|
||||
expect(findEnclosingSymbol(symbols, 20)?.name).toBe("helper");
|
||||
});
|
||||
|
||||
it("handles single-line symbols (no endLine)", () => {
|
||||
const syms: SymbolInfo[] = [
|
||||
{ name: "x", kind: "variable", location: { file: "a.ts", line: 5 } },
|
||||
];
|
||||
expect(findEnclosingSymbol(syms, 5)?.name).toBe("x");
|
||||
expect(findEnclosingSymbol(syms, 6)).toBeNull();
|
||||
});
|
||||
|
||||
it("handles empty symbol list", () => {
|
||||
expect(findEnclosingSymbol([], 10)).toBeNull();
|
||||
});
|
||||
|
||||
it("handles standalone symbol", () => {
|
||||
expect(findEnclosingSymbol(symbols, 65)?.name).toBe("standalone");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { looksLikeFilePath } from "../src/hooks/useChat.js";
|
||||
import { tsJsHandler, pythonHandler } from "../src/core/tools/move-symbol.js";
|
||||
|
||||
const BACKTICK_PATH_RE = /`([^`\s]+)`/g;
|
||||
|
||||
function extractPathsFromText(text: string): string[] {
|
||||
if (text.length > 500_000) return [];
|
||||
const paths: string[] = [];
|
||||
for (const match of text.matchAll(BACKTICK_PATH_RE)) {
|
||||
if (match[1] && looksLikeFilePath(match[1])) {
|
||||
paths.push(match[1]);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
describe("looksLikeFilePath", () => {
|
||||
it("accepts typical file paths", () => {
|
||||
expect(looksLikeFilePath("src/core/diff.ts")).toBe(true);
|
||||
expect(looksLikeFilePath("./components/App.tsx")).toBe(true);
|
||||
expect(looksLikeFilePath("../utils/helpers.js")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects too short strings", () => {
|
||||
expect(looksLikeFilePath("a")).toBe(false);
|
||||
expect(looksLikeFilePath("ab")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects strings over 300 chars", () => {
|
||||
expect(looksLikeFilePath("a/b." + "x".repeat(300))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects URLs", () => {
|
||||
expect(looksLikeFilePath("http://example.com/file.js")).toBe(false);
|
||||
expect(looksLikeFilePath("https://example.com/file.js")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects strings with special characters", () => {
|
||||
expect(looksLikeFilePath("src/$(cmd).ts")).toBe(false);
|
||||
expect(looksLikeFilePath("src/<T>.ts")).toBe(false);
|
||||
expect(looksLikeFilePath("src/{a,b}.ts")).toBe(false);
|
||||
expect(looksLikeFilePath("src/file.ts;rm -rf /")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects strings with whitespace", () => {
|
||||
expect(looksLikeFilePath("src/my file.ts")).toBe(false);
|
||||
expect(looksLikeFilePath("src/file\t.ts")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects strings without slashes", () => {
|
||||
expect(looksLikeFilePath("file.ts")).toBe(false);
|
||||
expect(looksLikeFilePath("README.md")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects strings without extension", () => {
|
||||
expect(looksLikeFilePath("src/Makefile")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects extensions with special chars", () => {
|
||||
expect(looksLikeFilePath("src/file.t-s")).toBe(false);
|
||||
expect(looksLikeFilePath("src/file.t_s")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts long but valid extensions", () => {
|
||||
expect(looksLikeFilePath("src/file.typescript")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects extensions longer than 10 chars", () => {
|
||||
expect(looksLikeFilePath("src/file.verylongextension")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles dot at start of filename", () => {
|
||||
expect(looksLikeFilePath(".gitignore")).toBe(false);
|
||||
expect(looksLikeFilePath("src/.env.local")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles path ending with dot", () => {
|
||||
expect(looksLikeFilePath("src/file.")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles path with multiple dots", () => {
|
||||
expect(looksLikeFilePath("src/file.test.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts absolute Unix paths", () => {
|
||||
expect(looksLikeFilePath("/usr/local/src/file.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts double-slash paths", () => {
|
||||
expect(looksLikeFilePath("src//file.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts paths with ..", () => {
|
||||
expect(looksLikeFilePath("../../file.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts paths with tilde", () => {
|
||||
expect(looksLikeFilePath("~/project/file.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts very short valid path", () => {
|
||||
expect(looksLikeFilePath("a/b.c")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects path at min length without extension", () => {
|
||||
expect(looksLikeFilePath("a/b")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts path with unicode characters", () => {
|
||||
expect(looksLikeFilePath("src/café.ts")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractPathsFromText", () => {
|
||||
it("extracts backtick-wrapped paths from text", () => {
|
||||
const text = "Modified `src/core/diff.ts` and `src/hooks/useChat.ts`";
|
||||
const paths = extractPathsFromText(text);
|
||||
expect(paths).toEqual(["src/core/diff.ts", "src/hooks/useChat.ts"]);
|
||||
});
|
||||
|
||||
it("ignores non-path backtick content", () => {
|
||||
const text = "Use the `forEach` method on `Array` class";
|
||||
expect(extractPathsFromText(text)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores code blocks with spaces inside backticks", () => {
|
||||
const text = "Run `bun test src/core/diff.ts` to test";
|
||||
expect(extractPathsFromText(text)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty for extremely long text", () => {
|
||||
const text = "`src/foo.ts`" + "x".repeat(600_000);
|
||||
expect(extractPathsFromText(text)).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles empty text", () => {
|
||||
expect(extractPathsFromText("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles adjacent backtick paths", () => {
|
||||
const text = "`src/a.ts``src/b.ts`";
|
||||
const paths = extractPathsFromText(text);
|
||||
expect(paths).toEqual(["src/a.ts", "src/b.ts"]);
|
||||
});
|
||||
|
||||
it("returns empty when text has no backticks", () => {
|
||||
const text = "This is plain text with no backticks at all, not even src/foo.ts paths.";
|
||||
expect(extractPathsFromText(text)).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters out URLs inside backticks", () => {
|
||||
const text = "See `https://example.com/api/file.js` for reference";
|
||||
expect(extractPathsFromText(text)).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts paths mixed with non-path backtick content", () => {
|
||||
const text =
|
||||
"Updated `src/core/diff.ts` using the `map` function, then modified `lib/utils/parse.js` accordingly";
|
||||
const paths = extractPathsFromText(text);
|
||||
expect(paths).toEqual(["src/core/diff.ts", "lib/utils/parse.js"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeTsPath (via tsJsHandler.computePath)", () => {
|
||||
it("computes relative path for same directory", () => {
|
||||
expect(tsJsHandler.computePath("/project/src/a.ts", "/project/src/b.ts")).toBe("./b.js");
|
||||
});
|
||||
|
||||
it("computes relative path going up", () => {
|
||||
expect(tsJsHandler.computePath("/project/src/deep/a.ts", "/project/src/b.ts")).toBe(
|
||||
"../b.js",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips .tsx extension too", () => {
|
||||
expect(tsJsHandler.computePath("/project/src/a.ts", "/project/src/Component.tsx")).toBe(
|
||||
"./Component.js",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds ./ prefix for same-level paths", () => {
|
||||
const result = tsJsHandler.computePath("/a/b.ts", "/a/c.ts");
|
||||
expect(result.startsWith("./")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles .jsx files", () => {
|
||||
expect(tsJsHandler.computePath("/project/src/a.ts", "/project/src/Button.jsx")).toBe(
|
||||
"./Button.jsx",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles same directory without going up", () => {
|
||||
expect(tsJsHandler.computePath("/project/a.ts", "/project/b.ts")).toBe("./b.js");
|
||||
});
|
||||
|
||||
it("handles going up multiple levels", () => {
|
||||
expect(
|
||||
tsJsHandler.computePath("/project/src/deep/nested/a.ts", "/project/lib/b.ts"),
|
||||
).toBe("../../../lib/b.js");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computePyPath (via pythonHandler.computePath)", () => {
|
||||
it("computes dotted path for same directory", () => {
|
||||
expect(pythonHandler.computePath("/project/src/a.py", "/project/src/b.py")).toBe("b");
|
||||
});
|
||||
|
||||
it("computes relative path going up", () => {
|
||||
const result = pythonHandler.computePath("/project/src/deep/a.py", "/project/src/b.py");
|
||||
expect(result).toBe("..b");
|
||||
});
|
||||
|
||||
it("computes path for deeper target", () => {
|
||||
expect(pythonHandler.computePath("/project/a.py", "/project/sub/b.py")).toBe("sub.b");
|
||||
});
|
||||
|
||||
it("handles going up 2+ levels", () => {
|
||||
const result = pythonHandler.computePath(
|
||||
"/project/src/deep/nested/a.py",
|
||||
"/project/src/b.py",
|
||||
);
|
||||
expect(result).toBe("...b");
|
||||
});
|
||||
|
||||
it("handles deeply nested target", () => {
|
||||
expect(
|
||||
pythonHandler.computePath("/project/a.py", "/project/pkg/sub/deep/module.py"),
|
||||
).toBe("pkg.sub.deep.module");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,506 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
/**
|
||||
* Tests for detectProfile from project.ts.
|
||||
* This auto-detects toolchain across 20+ ecosystems.
|
||||
* Wrong detection = wrong test/build/lint commands = user confusion.
|
||||
* Uses real temp directories with marker files.
|
||||
*/
|
||||
|
||||
// Mirror the production code
|
||||
function detectProfile(cwd: string) {
|
||||
const { existsSync, readdirSync, readFileSync } = require("node:fs");
|
||||
|
||||
const profile: Record<string, string | null> = {
|
||||
test: null,
|
||||
build: null,
|
||||
lint: null,
|
||||
typecheck: null,
|
||||
run: null,
|
||||
};
|
||||
|
||||
const has = (f: string) => existsSync(join(cwd, f));
|
||||
const hasExt = (ext: string) => {
|
||||
try {
|
||||
return readdirSync(cwd).some((f: string) => f.endsWith(ext));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const readPackageScripts = (): Record<string, string> => {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"));
|
||||
return pkg.scripts ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
const detectJsLinter = (): string | null => {
|
||||
if (has("biome.json") || has("biome.jsonc")) return "biome lint .";
|
||||
if (
|
||||
has(".eslintrc") ||
|
||||
has(".eslintrc.js") ||
|
||||
has(".eslintrc.json") ||
|
||||
has("eslint.config.js") ||
|
||||
has("eslint.config.mjs")
|
||||
)
|
||||
return "eslint .";
|
||||
return null;
|
||||
};
|
||||
const scripts = readPackageScripts();
|
||||
|
||||
if (has("bun.lock") || has("bun.lockb")) {
|
||||
profile.test = scripts.test ?? "bun test";
|
||||
profile.build = scripts.build ? "bun run build" : null;
|
||||
profile.lint = scripts.lint ? "bun run lint" : detectJsLinter();
|
||||
profile.typecheck = has("tsconfig.json")
|
||||
? scripts.typecheck ? "bun run typecheck" : "bunx tsc --noEmit"
|
||||
: null;
|
||||
profile.run = scripts.dev ? "bun run dev" : scripts.start ? "bun run start" : null;
|
||||
return profile;
|
||||
}
|
||||
|
||||
if (has("deno.json") || has("deno.lock")) {
|
||||
profile.test = "deno test";
|
||||
profile.build = null;
|
||||
profile.lint = "deno lint";
|
||||
profile.typecheck = "deno check .";
|
||||
profile.run = scripts.dev ? "deno task dev" : "deno run main.ts";
|
||||
return profile;
|
||||
}
|
||||
|
||||
if (has("package.json")) {
|
||||
const pm = has("pnpm-lock.yaml") ? "pnpm" : has("yarn.lock") ? "yarn" : "npm";
|
||||
const run = pm === "npm" ? "npm run" : pm;
|
||||
profile.test = scripts.test ? `${run} test` : null;
|
||||
profile.build = scripts.build ? `${run} build` : null;
|
||||
profile.lint = scripts.lint ? `${run} lint` : detectJsLinter();
|
||||
profile.typecheck = has("tsconfig.json")
|
||||
? scripts.typecheck ? `${run} typecheck` : "npx tsc --noEmit"
|
||||
: null;
|
||||
profile.run = scripts.dev ? `${run} dev` : scripts.start ? `${run} start` : null;
|
||||
return profile;
|
||||
}
|
||||
|
||||
if (has("Cargo.toml")) {
|
||||
profile.test = "cargo test";
|
||||
profile.build = "cargo build";
|
||||
profile.lint = "cargo clippy";
|
||||
profile.typecheck = "cargo check";
|
||||
profile.run = "cargo run";
|
||||
return profile;
|
||||
}
|
||||
|
||||
if (has("go.mod")) {
|
||||
profile.test = "go test ./...";
|
||||
profile.build = "go build ./...";
|
||||
profile.lint = has(".golangci.yml") || has(".golangci.yaml") ? "golangci-lint run" : "go vet ./...";
|
||||
profile.typecheck = "go build ./...";
|
||||
profile.run = "go run .";
|
||||
return profile;
|
||||
}
|
||||
|
||||
if (has("pyproject.toml") || has("setup.py") || has("requirements.txt")) {
|
||||
const pm = has("uv.lock") ? "uv run" : has("poetry.lock") ? "poetry run" : has("Pipfile.lock") ? "pipenv run" : "";
|
||||
const prefix = pm ? `${pm} ` : "";
|
||||
profile.test = `${prefix}pytest`;
|
||||
profile.lint = has("ruff.toml") || has(".ruff.toml") ? `${prefix}ruff check` : `${prefix}flake8`;
|
||||
profile.typecheck = `${prefix}mypy .`;
|
||||
if (has("manage.py")) profile.run = `${prefix}python manage.py runserver`;
|
||||
else if (has("app.py") || has("main.py")) profile.run = `${prefix}uvicorn main:app --reload`;
|
||||
return profile;
|
||||
}
|
||||
|
||||
if (has("pubspec.yaml")) {
|
||||
profile.test = "flutter test";
|
||||
profile.build = "flutter build";
|
||||
profile.lint = "dart analyze";
|
||||
profile.typecheck = "dart analyze";
|
||||
profile.run = "flutter run";
|
||||
return profile;
|
||||
}
|
||||
|
||||
if (has("Package.swift")) {
|
||||
profile.test = "swift test";
|
||||
profile.build = "swift build";
|
||||
profile.lint = has(".swiftlint.yml") ? "swiftlint" : null;
|
||||
profile.typecheck = "swift build";
|
||||
profile.run = "swift run";
|
||||
return profile;
|
||||
}
|
||||
|
||||
if (has("mix.exs")) {
|
||||
profile.test = "mix test";
|
||||
profile.build = "mix compile";
|
||||
profile.lint = "mix credo";
|
||||
profile.typecheck = "mix dialyzer";
|
||||
profile.run = "mix phx.server";
|
||||
return profile;
|
||||
}
|
||||
|
||||
if (has("Gemfile")) {
|
||||
profile.test = has("spec") ? "bundle exec rspec" : "bundle exec rails test";
|
||||
profile.build = null;
|
||||
profile.lint = "bundle exec rubocop";
|
||||
profile.run = has("config.ru") ? "bundle exec rails server" : null;
|
||||
return profile;
|
||||
}
|
||||
|
||||
if (has("gradlew") || has("build.gradle") || has("build.gradle.kts")) {
|
||||
const gw = has("gradlew") ? "./gradlew" : "gradle";
|
||||
profile.test = `${gw} test`;
|
||||
profile.build = `${gw} build`;
|
||||
profile.lint = `${gw} check`;
|
||||
profile.typecheck = `${gw} compileJava`;
|
||||
profile.run = `${gw} run`;
|
||||
return profile;
|
||||
}
|
||||
|
||||
if (has("pom.xml") || has("mvnw")) {
|
||||
const mvn = has("mvnw") ? "./mvnw" : "mvn";
|
||||
profile.test = `${mvn} test`;
|
||||
profile.build = `${mvn} package`;
|
||||
profile.lint = `${mvn} checkstyle:check`;
|
||||
profile.typecheck = `${mvn} compile`;
|
||||
profile.run = `${mvn} exec:java`;
|
||||
return profile;
|
||||
}
|
||||
|
||||
if (has("CMakeLists.txt")) {
|
||||
profile.test = "ctest --test-dir build";
|
||||
profile.build = "cmake --build build";
|
||||
profile.lint = has(".clang-tidy") ? "clang-tidy" : null;
|
||||
profile.typecheck = "cmake --build build";
|
||||
return profile;
|
||||
}
|
||||
|
||||
if (has("Makefile")) {
|
||||
profile.test = "make test";
|
||||
profile.build = "make";
|
||||
profile.run = "make run";
|
||||
return profile;
|
||||
}
|
||||
|
||||
if (has("build.zig") || has("build.zig.zon")) {
|
||||
profile.test = "zig build test";
|
||||
profile.build = "zig build";
|
||||
profile.typecheck = "zig build";
|
||||
profile.run = "zig build run";
|
||||
return profile;
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeAll(() => {
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), "project-detect-"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeProject(name: string, files: Record<string, string>): string {
|
||||
const dir = join(tmpRoot, name);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
for (const [file, content] of Object.entries(files)) {
|
||||
const filePath = join(dir, file);
|
||||
const fileDir = filePath.substring(0, filePath.lastIndexOf("/"));
|
||||
if (fileDir !== dir) mkdirSync(fileDir, { recursive: true });
|
||||
writeFileSync(filePath, content);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("detectProfile — Bun", () => {
|
||||
it("detects bun project with lock file", () => {
|
||||
const dir = makeProject("bun1", {
|
||||
"bun.lock": "",
|
||||
"package.json": JSON.stringify({ scripts: { test: "bun test", build: "bun build" } }),
|
||||
"tsconfig.json": "{}",
|
||||
});
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("bun test");
|
||||
expect(p.build).toBe("bun run build");
|
||||
expect(p.typecheck).toBe("bunx tsc --noEmit");
|
||||
});
|
||||
|
||||
it("falls back to bun test when no test script", () => {
|
||||
const dir = makeProject("bun2", {
|
||||
"bun.lockb": "",
|
||||
"package.json": JSON.stringify({ scripts: {} }),
|
||||
});
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("bun test");
|
||||
});
|
||||
|
||||
it("detects biome linter for bun project", () => {
|
||||
const dir = makeProject("bun3", {
|
||||
"bun.lock": "",
|
||||
"package.json": JSON.stringify({ scripts: {} }),
|
||||
"biome.json": "{}",
|
||||
});
|
||||
const p = detectProfile(dir);
|
||||
expect(p.lint).toBe("biome lint .");
|
||||
});
|
||||
|
||||
it("detects eslint linter for bun project", () => {
|
||||
const dir = makeProject("bun4", {
|
||||
"bun.lock": "",
|
||||
"package.json": JSON.stringify({ scripts: {} }),
|
||||
"eslint.config.js": "",
|
||||
});
|
||||
const p = detectProfile(dir);
|
||||
expect(p.lint).toBe("eslint .");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectProfile — npm/yarn/pnpm", () => {
|
||||
it("detects pnpm", () => {
|
||||
const dir = makeProject("pnpm1", {
|
||||
"package.json": JSON.stringify({ scripts: { test: "vitest", build: "vite build" } }),
|
||||
"pnpm-lock.yaml": "",
|
||||
});
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("pnpm test");
|
||||
expect(p.build).toBe("pnpm build");
|
||||
});
|
||||
|
||||
it("detects yarn", () => {
|
||||
const dir = makeProject("yarn1", {
|
||||
"package.json": JSON.stringify({ scripts: { test: "jest" } }),
|
||||
"yarn.lock": "",
|
||||
});
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("yarn test");
|
||||
});
|
||||
|
||||
it("detects npm (no lock file)", () => {
|
||||
const dir = makeProject("npm1", {
|
||||
"package.json": JSON.stringify({ scripts: { test: "jest", dev: "next dev" } }),
|
||||
});
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("npm run test");
|
||||
expect(p.run).toBe("npm run dev");
|
||||
});
|
||||
|
||||
it("detects typecheck with tsconfig", () => {
|
||||
const dir = makeProject("ts1", {
|
||||
"package.json": JSON.stringify({ scripts: { test: "jest" } }),
|
||||
"tsconfig.json": "{}",
|
||||
});
|
||||
const p = detectProfile(dir);
|
||||
expect(p.typecheck).toBe("npx tsc --noEmit");
|
||||
});
|
||||
|
||||
it("no typecheck without tsconfig", () => {
|
||||
const dir = makeProject("js1", {
|
||||
"package.json": JSON.stringify({ scripts: { test: "jest" } }),
|
||||
});
|
||||
const p = detectProfile(dir);
|
||||
expect(p.typecheck).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectProfile — Rust", () => {
|
||||
it("detects Cargo project", () => {
|
||||
const dir = makeProject("rust1", { "Cargo.toml": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("cargo test");
|
||||
expect(p.build).toBe("cargo build");
|
||||
expect(p.lint).toBe("cargo clippy");
|
||||
expect(p.typecheck).toBe("cargo check");
|
||||
expect(p.run).toBe("cargo run");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectProfile — Go", () => {
|
||||
it("detects Go module", () => {
|
||||
const dir = makeProject("go1", { "go.mod": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("go test ./...");
|
||||
expect(p.lint).toBe("go vet ./...");
|
||||
});
|
||||
|
||||
it("detects golangci-lint", () => {
|
||||
const dir = makeProject("go2", { "go.mod": "", ".golangci.yml": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.lint).toBe("golangci-lint run");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectProfile — Python", () => {
|
||||
it("detects Python with pyproject.toml", () => {
|
||||
const dir = makeProject("py1", { "pyproject.toml": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("pytest");
|
||||
expect(p.lint).toBe("flake8");
|
||||
});
|
||||
|
||||
it("detects uv", () => {
|
||||
const dir = makeProject("py2", { "pyproject.toml": "", "uv.lock": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("uv run pytest");
|
||||
expect(p.typecheck).toBe("uv run mypy .");
|
||||
});
|
||||
|
||||
it("detects poetry", () => {
|
||||
const dir = makeProject("py3", { "pyproject.toml": "", "poetry.lock": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("poetry run pytest");
|
||||
});
|
||||
|
||||
it("detects ruff linter", () => {
|
||||
const dir = makeProject("py4", { "pyproject.toml": "", "ruff.toml": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.lint).toBe("ruff check");
|
||||
});
|
||||
|
||||
it("detects Django project", () => {
|
||||
const dir = makeProject("py5", { "pyproject.toml": "", "manage.py": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.run).toContain("manage.py runserver");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectProfile — mobile/game", () => {
|
||||
it("detects Flutter/Dart", () => {
|
||||
const dir = makeProject("flutter1", { "pubspec.yaml": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("flutter test");
|
||||
expect(p.lint).toBe("dart analyze");
|
||||
expect(p.run).toBe("flutter run");
|
||||
});
|
||||
|
||||
it("detects Swift package", () => {
|
||||
const dir = makeProject("swift1", { "Package.swift": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("swift test");
|
||||
expect(p.build).toBe("swift build");
|
||||
});
|
||||
|
||||
it("detects Swift with swiftlint", () => {
|
||||
const dir = makeProject("swift2", { "Package.swift": "", ".swiftlint.yml": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.lint).toBe("swiftlint");
|
||||
});
|
||||
|
||||
it("detects Gradle (Kotlin/Java)", () => {
|
||||
const dir = makeProject("gradle1", { "build.gradle.kts": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("gradle test");
|
||||
expect(p.build).toBe("gradle build");
|
||||
});
|
||||
|
||||
it("detects Gradle with wrapper", () => {
|
||||
const dir = makeProject("gradle2", { "gradlew": "", "build.gradle": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("./gradlew test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectProfile — systems", () => {
|
||||
it("detects CMake", () => {
|
||||
const dir = makeProject("cmake1", { "CMakeLists.txt": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.build).toBe("cmake --build build");
|
||||
expect(p.test).toBe("ctest --test-dir build");
|
||||
});
|
||||
|
||||
it("detects CMake with clang-tidy", () => {
|
||||
const dir = makeProject("cmake2", { "CMakeLists.txt": "", ".clang-tidy": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.lint).toBe("clang-tidy");
|
||||
});
|
||||
|
||||
it("detects Makefile", () => {
|
||||
const dir = makeProject("make1", { "Makefile": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.build).toBe("make");
|
||||
expect(p.test).toBe("make test");
|
||||
});
|
||||
|
||||
it("detects Zig", () => {
|
||||
const dir = makeProject("zig1", { "build.zig": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("zig build test");
|
||||
expect(p.build).toBe("zig build");
|
||||
});
|
||||
|
||||
it("detects Elixir", () => {
|
||||
const dir = makeProject("elixir1", { "mix.exs": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("mix test");
|
||||
expect(p.lint).toBe("mix credo");
|
||||
});
|
||||
|
||||
it("detects Ruby", () => {
|
||||
const dir = makeProject("ruby1", { "Gemfile": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.lint).toBe("bundle exec rubocop");
|
||||
});
|
||||
|
||||
it("detects Ruby with rspec", () => {
|
||||
const dir = makeProject("ruby2", { "Gemfile": "" });
|
||||
mkdirSync(join(dir, "spec"), { recursive: true });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("bundle exec rspec");
|
||||
});
|
||||
|
||||
it("detects Maven", () => {
|
||||
const dir = makeProject("maven1", { "pom.xml": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("mvn test");
|
||||
expect(p.build).toBe("mvn package");
|
||||
});
|
||||
|
||||
it("detects Maven with wrapper", () => {
|
||||
const dir = makeProject("maven2", { "mvnw": "", "pom.xml": "" });
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("./mvnw test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectProfile — edge cases", () => {
|
||||
it("returns all nulls for empty directory", () => {
|
||||
const dir = makeProject("empty", {});
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBeNull();
|
||||
expect(p.build).toBeNull();
|
||||
expect(p.lint).toBeNull();
|
||||
expect(p.typecheck).toBeNull();
|
||||
expect(p.run).toBeNull();
|
||||
});
|
||||
|
||||
it("bun takes priority over npm when both exist", () => {
|
||||
const dir = makeProject("priority1", {
|
||||
"bun.lock": "",
|
||||
"package.json": JSON.stringify({ scripts: { test: "vitest" } }),
|
||||
});
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("vitest");
|
||||
});
|
||||
|
||||
it("handles malformed package.json", () => {
|
||||
const dir = makeProject("bad-pkg", {
|
||||
"package.json": "not json",
|
||||
});
|
||||
// package.json exists but can't be parsed — falls through to npm detection with empty scripts
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBeNull();
|
||||
});
|
||||
|
||||
it("deno takes priority over npm", () => {
|
||||
const dir = makeProject("deno1", {
|
||||
"deno.json": "{}",
|
||||
"package.json": JSON.stringify({ scripts: { test: "jest" } }),
|
||||
});
|
||||
const p = detectProfile(dir);
|
||||
expect(p.test).toBe("deno test");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { writeFileSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { RegexBackend } from "../src/core/intelligence/backends/regex.js";
|
||||
|
||||
const TMP = join(tmpdir(), `regex-backend-test-${Date.now()}`);
|
||||
const backend = new RegexBackend();
|
||||
|
||||
function writeTemp(name: string, content: string): string {
|
||||
const path = join(TMP, name);
|
||||
writeFileSync(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
mkdirSync(TMP, { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("findSymbols", () => {
|
||||
it("finds TypeScript functions", async () => {
|
||||
const f = writeTemp("test.ts", "export function myFunc() {}\nfunction helper() {}");
|
||||
const symbols = await backend.findSymbols(f);
|
||||
expect(symbols).toHaveLength(2);
|
||||
expect(symbols?.[0]?.name).toBe("myFunc");
|
||||
expect(symbols?.[1]?.name).toBe("helper");
|
||||
});
|
||||
|
||||
it("finds TypeScript classes and interfaces", async () => {
|
||||
const f = writeTemp("types.ts", "export interface Foo {}\nexport class Bar {}");
|
||||
const symbols = await backend.findSymbols(f);
|
||||
expect(symbols).toHaveLength(2);
|
||||
expect(symbols?.find((s) => s.name === "Foo")?.kind).toBe("interface");
|
||||
expect(symbols?.find((s) => s.name === "Bar")?.kind).toBe("class");
|
||||
});
|
||||
|
||||
it("finds Python functions and classes", async () => {
|
||||
const f = writeTemp("mod.py", "def my_func():\n pass\n\nclass MyClass:\n pass");
|
||||
const symbols = await backend.findSymbols(f);
|
||||
expect(symbols).toHaveLength(2);
|
||||
expect(symbols?.[0]?.name).toBe("my_func");
|
||||
expect(symbols?.[1]?.name).toBe("MyClass");
|
||||
});
|
||||
|
||||
it("finds Go functions and structs", async () => {
|
||||
const f = writeTemp(
|
||||
"main.go",
|
||||
"func main() {}\ntype Config struct {\n\tName string\n}",
|
||||
);
|
||||
const symbols = await backend.findSymbols(f);
|
||||
expect(symbols).toHaveLength(2);
|
||||
expect(symbols?.[0]?.name).toBe("main");
|
||||
expect(symbols?.[1]?.name).toBe("Config");
|
||||
});
|
||||
|
||||
it("finds Rust functions, structs, and traits", async () => {
|
||||
const f = writeTemp(
|
||||
"lib.rs",
|
||||
"pub fn process() {}\nstruct Data {}\npub trait Handler {}",
|
||||
);
|
||||
const symbols = await backend.findSymbols(f);
|
||||
expect(symbols).toHaveLength(3);
|
||||
expect(symbols?.[0]?.name).toBe("process");
|
||||
expect(symbols?.[1]?.name).toBe("Data");
|
||||
expect(symbols?.[2]?.name).toBe("Handler");
|
||||
});
|
||||
|
||||
it("filters by query", async () => {
|
||||
const f = writeTemp("search.ts", "function foo() {}\nfunction fooBar() {}\nfunction baz() {}");
|
||||
const symbols = await backend.findSymbols(f, "foo");
|
||||
expect(symbols).toHaveLength(2);
|
||||
expect(symbols?.every((s) => s.name.toLowerCase().includes("foo"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns null for non-existent file", async () => {
|
||||
const result = await backend.findSymbols("/nonexistent/file.ts");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns empty array for file with no symbols", async () => {
|
||||
const f = writeTemp("empty.ts", "// just a comment\n");
|
||||
const symbols = await backend.findSymbols(f);
|
||||
expect(symbols).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles unknown file extension (falls back to TS patterns)", async () => {
|
||||
const f = writeTemp("file.xyz", "function foo() {}");
|
||||
const symbols = await backend.findSymbols(f);
|
||||
// Falls back to TS_PATTERNS, which can match function
|
||||
expect(symbols).toHaveLength(1);
|
||||
expect(symbols?.[0]?.name).toBe("foo");
|
||||
});
|
||||
|
||||
it("handles async functions", async () => {
|
||||
const f = writeTemp("async.ts", "export async function fetchData() {}");
|
||||
const symbols = await backend.findSymbols(f);
|
||||
expect(symbols).toHaveLength(1);
|
||||
expect(symbols?.[0]?.name).toBe("fetchData");
|
||||
});
|
||||
|
||||
it("handles abstract classes", async () => {
|
||||
const f = writeTemp("abstract.ts", "export abstract class BaseService {}");
|
||||
const symbols = await backend.findSymbols(f);
|
||||
expect(symbols).toHaveLength(1);
|
||||
expect(symbols?.[0]?.name).toBe("BaseService");
|
||||
});
|
||||
|
||||
it("doesn't match indented lines (method definitions inside a class)", async () => {
|
||||
const f = writeTemp(
|
||||
"class.ts",
|
||||
"export class Foo {\n function bar() {}\n}",
|
||||
);
|
||||
const symbols = await backend.findSymbols(f);
|
||||
// The regex uses trimStart(), so " function bar() {}" → "function bar() {}" WILL match
|
||||
// This is actually a known behavior — regex backend reports nested functions
|
||||
expect(symbols?.find((s) => s.name === "Foo")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findImports", () => {
|
||||
it("parses TypeScript named imports", async () => {
|
||||
const f = writeTemp("imports.ts", 'import { foo, bar } from "./mod";');
|
||||
const imports = await backend.findImports(f);
|
||||
expect(imports).toHaveLength(1);
|
||||
expect(imports?.[0]?.specifiers).toContain("foo");
|
||||
expect(imports?.[0]?.specifiers).toContain("bar");
|
||||
expect(imports?.[0]?.source).toBe("./mod");
|
||||
});
|
||||
|
||||
it("parses default import", async () => {
|
||||
const f = writeTemp("default.ts", 'import React from "react";');
|
||||
const imports = await backend.findImports(f);
|
||||
expect(imports).toHaveLength(1);
|
||||
expect(imports?.[0]?.isDefault).toBe(true);
|
||||
expect(imports?.[0]?.specifiers).toContain("React");
|
||||
});
|
||||
|
||||
it("parses namespace import", async () => {
|
||||
const f = writeTemp("ns.ts", 'import * as path from "path";');
|
||||
const imports = await backend.findImports(f);
|
||||
expect(imports).toHaveLength(1);
|
||||
expect(imports?.[0]?.isNamespace).toBe(true);
|
||||
});
|
||||
|
||||
it("parses Python imports", async () => {
|
||||
const f = writeTemp("imports.py", "from os.path import join, exists");
|
||||
const imports = await backend.findImports(f);
|
||||
expect(imports).toHaveLength(1);
|
||||
expect(imports?.[0]?.source).toBe("os.path");
|
||||
});
|
||||
|
||||
it("parses Go imports", async () => {
|
||||
const f = writeTemp("imports.go", 'import (\n\t"fmt"\n\t"os"\n)');
|
||||
const imports = await backend.findImports(f);
|
||||
// The regex expects import on a single line — block imports may not fully work
|
||||
// Actually: the pattern is /^import\s+(?:\(\s*)?(?:"([^"]+)")?/
|
||||
// Line 'import (' matches but captures nothing for source
|
||||
// Lines '"fmt"' and '"os"' don't start with 'import' so they're missed
|
||||
expect(imports).toHaveLength(1); // Only the 'import (' line
|
||||
});
|
||||
|
||||
it("parses Rust use statements", async () => {
|
||||
const f = writeTemp("imports.rs", "use std::io::Read;");
|
||||
const imports = await backend.findImports(f);
|
||||
expect(imports).toHaveLength(1);
|
||||
expect(imports?.[0]?.source).toBe("std::io::Read");
|
||||
});
|
||||
|
||||
it("handles multiline TypeScript imports (only captures single line)", async () => {
|
||||
const f = writeTemp("multi.ts", 'import {\n foo,\n bar\n} from "./mod";');
|
||||
const imports = await backend.findImports(f);
|
||||
// The regex only matches single-line imports, so this will miss multiline
|
||||
expect(imports).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractBraceScope", () => {
|
||||
it("finds end of simple function", async () => {
|
||||
const f = writeTemp("scope.ts", "function foo() {\n return 1;\n}");
|
||||
const block = await backend.readSymbol(f, "foo");
|
||||
expect(block?.content).toContain("return 1");
|
||||
expect(block?.location.endLine).toBe(3);
|
||||
});
|
||||
|
||||
it("handles nested braces", async () => {
|
||||
const f = writeTemp(
|
||||
"nested.ts",
|
||||
"function foo() {\n if (true) {\n return 1;\n }\n}",
|
||||
);
|
||||
const block = await backend.readSymbol(f, "foo");
|
||||
expect(block?.location.line).toBe(1);
|
||||
expect(block?.location.endLine).toBe(5);
|
||||
});
|
||||
|
||||
it("handles unclosed brace (falls back to +50 lines)", async () => {
|
||||
const lines = ["function foo() {"];
|
||||
for (let i = 0; i < 60; i++) lines.push(` line${i};`);
|
||||
const f = writeTemp("unclosed.ts", lines.join("\n"));
|
||||
const block = await backend.readSymbol(f, "foo");
|
||||
// Unclosed brace → endIdx = min(startIdx + 50, lines.length - 1)
|
||||
expect(block?.location.endLine).toBeLessThanOrEqual(51);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractIndentScope (Python)", () => {
|
||||
it("finds end of Python function by indentation", async () => {
|
||||
const src = "def foo():\n return 1\n\ndef bar():\n pass";
|
||||
const f = writeTemp("scope.py", src);
|
||||
const block = await backend.readSymbol(f, "foo");
|
||||
expect(block?.content).toContain("return 1");
|
||||
expect(block?.content).not.toContain("bar");
|
||||
});
|
||||
|
||||
it("handles Python function with blank lines inside", async () => {
|
||||
const src = "def foo():\n x = 1\n\n y = 2\n\ndef bar():\n pass";
|
||||
const f = writeTemp("blanks.py", src);
|
||||
const block = await backend.readSymbol(f, "foo");
|
||||
expect(block?.content).toContain("y = 2");
|
||||
});
|
||||
|
||||
it("handles Python function at end of file with no trailing newline", async () => {
|
||||
const src = "def foo():\n return 1";
|
||||
const f = writeTemp("eof.py", src);
|
||||
const block = await backend.readSymbol(f, "foo");
|
||||
expect(block?.content).toBe("def foo():\n return 1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectLang (via readSymbol language field)", () => {
|
||||
it("detects .mts as typescript", async () => {
|
||||
const f = writeTemp("mod.mts", "export function foo() {}");
|
||||
const block = await backend.readSymbol(f, "foo");
|
||||
expect(block?.language).toBe("typescript");
|
||||
});
|
||||
|
||||
it("detects .cjs as javascript", async () => {
|
||||
const f = writeTemp("mod.cjs", "function foo() {}");
|
||||
const block = await backend.readSymbol(f, "foo");
|
||||
expect(block?.language).toBe("javascript");
|
||||
});
|
||||
|
||||
it("returns unknown for extensionless files", async () => {
|
||||
const f = writeTemp("Makefile", "function foo() {}");
|
||||
const block = await backend.readSymbol(f, "foo");
|
||||
// Falls back to TS_PATTERNS, language will be "unknown"
|
||||
expect(block?.language).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RegexBackend — findExports", () => {
|
||||
it("finds exported function", async () => {
|
||||
const f = writeTemp(
|
||||
"exports1.ts",
|
||||
"export function foo() {}\nexport default function bar() {}",
|
||||
);
|
||||
const exports = await backend.findExports(f);
|
||||
expect(exports).not.toBeNull();
|
||||
expect(exports?.find((e) => e.name === "foo")).toBeDefined();
|
||||
expect(exports?.find((e) => e.name === "bar" && e.isDefault)).toBeDefined();
|
||||
});
|
||||
|
||||
it("finds exported class", async () => {
|
||||
const f = writeTemp("exports2.ts", "export class MyService {}");
|
||||
const exports = await backend.findExports(f);
|
||||
expect(exports?.find((e) => e.name === "MyService")).toBeDefined();
|
||||
});
|
||||
|
||||
it("finds exported const", async () => {
|
||||
const f = writeTemp(
|
||||
"exports3.ts",
|
||||
"export const MAX = 100;\nexport let count = 0;",
|
||||
);
|
||||
const exports = await backend.findExports(f);
|
||||
expect(exports?.find((e) => e.name === "MAX")).toBeDefined();
|
||||
});
|
||||
|
||||
it("finds exported interface and type", async () => {
|
||||
const f = writeTemp(
|
||||
"exports4.ts",
|
||||
"export interface Config {}\nexport type Result = string;",
|
||||
);
|
||||
const exports = await backend.findExports(f);
|
||||
expect(exports?.find((e) => e.name === "Config")).toBeDefined();
|
||||
expect(exports?.find((e) => e.name === "Result")).toBeDefined();
|
||||
});
|
||||
|
||||
it("finds exported enum", async () => {
|
||||
const f = writeTemp("exports5.ts", "export enum Status { Active, Inactive }");
|
||||
const exports = await backend.findExports(f);
|
||||
expect(exports?.find((e) => e.name === "Status")).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns empty for file with no exports", async () => {
|
||||
const f = writeTemp("noexports.ts", "function internal() {}\nconst x = 1;");
|
||||
const exports = await backend.findExports(f);
|
||||
expect(exports?.length ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it("empty file", async () => {
|
||||
const f = writeTemp("empty-exports.ts", "");
|
||||
const exports = await backend.findExports(f);
|
||||
expect(exports === null || exports?.length === 0).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RegexBackend — getFileOutline", () => {
|
||||
it("returns complete outline", async () => {
|
||||
const src =
|
||||
'import { foo } from "./foo";\nexport function bar() {}\nconst baz = 1;';
|
||||
const f = writeTemp("outline.ts", src);
|
||||
const outline = await backend.getFileOutline(f);
|
||||
expect(outline).not.toBeNull();
|
||||
expect(outline?.symbols?.length).toBeGreaterThan(0);
|
||||
expect(outline?.imports?.length).toBeGreaterThan(0);
|
||||
expect(outline?.exports?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns outline for file with only symbols", async () => {
|
||||
const f = writeTemp("symbols-only.ts", "function foo() {}\nclass Bar {}");
|
||||
const outline = await backend.getFileOutline(f);
|
||||
expect(outline).not.toBeNull();
|
||||
expect(outline?.symbols?.length).toBe(2);
|
||||
});
|
||||
|
||||
it("returns null for empty file", async () => {
|
||||
const f = writeTemp("empty-outline.ts", "");
|
||||
const outline = await backend.getFileOutline(f);
|
||||
expect(outline).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RegexBackend — findDefinition", () => {
|
||||
it("finds function definition", async () => {
|
||||
const f = writeTemp(
|
||||
"def1.ts",
|
||||
"const x = 1;\nfunction target() {}\nconst y = 2;",
|
||||
);
|
||||
const locs = await backend.findDefinition(f, "target");
|
||||
expect(locs).not.toBeNull();
|
||||
expect(locs?.length).toBeGreaterThan(0);
|
||||
expect(locs?.[0]?.line).toBe(2);
|
||||
});
|
||||
|
||||
it("finds class definition", async () => {
|
||||
const f = writeTemp("def2.ts", "class MyClass {\n method() {}\n}");
|
||||
const locs = await backend.findDefinition(f, "MyClass");
|
||||
expect(locs?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns empty for missing symbol", async () => {
|
||||
const f = writeTemp("def3.ts", "function foo() {}");
|
||||
const locs = await backend.findDefinition(f, "nonexistent");
|
||||
expect(locs === null || locs?.length === 0).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RegexBackend — cache", () => {
|
||||
it("works with cache set", async () => {
|
||||
const { resolve } = await import("node:path");
|
||||
const cacheMap = new Map<string, string>();
|
||||
const content = "function foo() {}";
|
||||
const cache = {
|
||||
get(file: string) {
|
||||
return cacheMap.get(file) ?? null;
|
||||
},
|
||||
set(file: string, val: string) {
|
||||
cacheMap.set(file, val);
|
||||
},
|
||||
};
|
||||
const f = writeTemp("cached.ts", content);
|
||||
cacheMap.set(resolve(f), content);
|
||||
const cachedBackend = new RegexBackend();
|
||||
cachedBackend.setCache(cache as Parameters<typeof cachedBackend.setCache>[0]);
|
||||
const symbols = await cachedBackend.findSymbols(f);
|
||||
expect(symbols?.find((s) => s.name === "foo")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RegexBackend — readScope normal", () => {
|
||||
it("reads explicit range", async () => {
|
||||
const f = writeTemp("scope.ts", "line1\nline2\nline3\nline4\nline5");
|
||||
const block = await backend.readScope(f, 2, 4);
|
||||
expect(block?.content).toContain("line2");
|
||||
expect(block?.content).toContain("line4");
|
||||
expect(block?.content).not.toContain("line5");
|
||||
});
|
||||
|
||||
it("reads single line", async () => {
|
||||
const f = writeTemp("scope1.ts", "aaa\nbbb\nccc");
|
||||
const block = await backend.readScope(f, 2, 2);
|
||||
expect(block?.content.trim()).toBe("bbb");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { replaceInCode } from "../src/core/tools/rename-symbol.js";
|
||||
|
||||
describe("replaceInCode", () => {
|
||||
it("replaces symbol in plain code", () => {
|
||||
expect(replaceInCode("const foo = bar(foo);", "foo", "baz")).toBe("const baz = bar(baz);");
|
||||
});
|
||||
|
||||
it("does not replace inside double-quoted strings", () => {
|
||||
const src = 'const x = "foo is cool"; foo();';
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe('const x = "foo is cool"; bar();');
|
||||
});
|
||||
|
||||
it("does not replace inside single-quoted strings", () => {
|
||||
const src = "const x = 'foo'; foo();";
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe("const x = 'foo'; bar();");
|
||||
});
|
||||
|
||||
it("does not replace inside single-line comments", () => {
|
||||
const src = "// foo is here\nfoo();";
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe("// foo is here\nbar();");
|
||||
});
|
||||
|
||||
it("does not replace inside multi-line comments", () => {
|
||||
const src = "/* foo */ foo();";
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe("/* foo */ bar();");
|
||||
});
|
||||
|
||||
it("does not replace inside Python hash comments", () => {
|
||||
const src = "# foo comment\nfoo()";
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe("# foo comment\nbar()");
|
||||
});
|
||||
|
||||
it("handles hash comment after whitespace", () => {
|
||||
const src = "x = 1 # foo\nfoo()";
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe("x = 1 # foo\nbar()");
|
||||
});
|
||||
|
||||
it("handles division operator (lone / is not a comment)", () => {
|
||||
const src = "foo / foo";
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe("bar / bar");
|
||||
});
|
||||
|
||||
it("handles CSS color hash (# mid-word is not a comment)", () => {
|
||||
const src = "color:#foo;";
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe("color:#bar;");
|
||||
});
|
||||
|
||||
it("handles unterminated multi-line comment", () => {
|
||||
const src = "/* foo forever\nfoo foo foo";
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe("/* foo forever\nfoo foo foo");
|
||||
});
|
||||
|
||||
it("handles escaped quotes inside strings", () => {
|
||||
const src = 'const s = "foo \\"foo\\" end"; foo();';
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe('const s = "foo \\"foo\\" end"; bar();');
|
||||
});
|
||||
|
||||
it("handles backslash at end of string (escaped backslash before closing quote)", () => {
|
||||
const src = '"foo\\\\";\nfoo();';
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe('"foo\\\\";\nbar();');
|
||||
});
|
||||
|
||||
it("handles empty source", () => {
|
||||
expect(replaceInCode("", "foo", "bar")).toBe("");
|
||||
});
|
||||
|
||||
it("handles source with only a comment", () => {
|
||||
expect(replaceInCode("// foo", "foo", "bar")).toBe("// foo");
|
||||
});
|
||||
|
||||
it("handles source with only a string", () => {
|
||||
expect(replaceInCode('"foo"', "foo", "bar")).toBe('"foo"');
|
||||
});
|
||||
|
||||
it("handles adjacent strings and comments", () => {
|
||||
const src = '"a"/*b*/\'c\'//d\nfoo';
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe('"a"/*b*/\'c\'//d\nbar');
|
||||
});
|
||||
|
||||
it("preserves content between comment end and next code", () => {
|
||||
const src = "/* comment */ foo /* comment */";
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe("/* comment */ bar /* comment */");
|
||||
});
|
||||
|
||||
it("replaces symbols inside template literal interpolations", () => {
|
||||
expect(replaceInCode("`${foo}`", "foo", "bar")).toBe("`${bar}`");
|
||||
});
|
||||
|
||||
it("preserves static text in template literals while replacing interpolations", () => {
|
||||
expect(replaceInCode("`hello ${foo} world`", "foo", "bar")).toBe("`hello ${bar} world`");
|
||||
});
|
||||
|
||||
it("handles nested braces inside template interpolation", () => {
|
||||
expect(replaceInCode("`${foo({ a: 1 })}`", "foo", "bar")).toBe("`${bar({ a: 1 })}`");
|
||||
});
|
||||
|
||||
it("handles single-char source that is a quote", () => {
|
||||
expect(replaceInCode('"', "foo", "bar")).toBe('"');
|
||||
});
|
||||
|
||||
it("handles regex-like content after division", () => {
|
||||
const src = "a = b / c; foo();";
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe("a = b / c; bar();");
|
||||
});
|
||||
|
||||
it("handles multiple slashes", () => {
|
||||
const src = "a / b / foo";
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe("a / b / bar");
|
||||
});
|
||||
|
||||
// --- Edge cases ---
|
||||
|
||||
it("replaces symbol at start of source", () => {
|
||||
expect(replaceInCode("foo = 1", "foo", "bar")).toBe("bar = 1");
|
||||
});
|
||||
|
||||
it("replaces symbol at end of source", () => {
|
||||
expect(replaceInCode("x = foo", "foo", "bar")).toBe("x = bar");
|
||||
});
|
||||
|
||||
it("respects word boundaries — substring of another identifier is untouched", () => {
|
||||
expect(replaceInCode("foobar = foo", "foo", "baz")).toBe("foobar = baz");
|
||||
});
|
||||
|
||||
it("treats regex literal as code (no regex literal detection — / is division)", () => {
|
||||
const src = "const re = /foo/g; foo()";
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe("const re = /bar/g; bar()");
|
||||
});
|
||||
|
||||
it("handles Python triple-quoted string — each quote pair is a separate string", () => {
|
||||
// The parser sees: "" (empty string), then "foo" (string), then "" (empty string)
|
||||
// So foo is inside a string and protected
|
||||
expect(replaceInCode('"""foo"""', "foo", "bar")).toBe('"""foo"""');
|
||||
});
|
||||
|
||||
it("handles Rust raw string r#\"foo\"# — r is code, content is string", () => {
|
||||
// The parser sees: r (code), then #"foo"# where "foo" is a string and trailing # is code
|
||||
expect(replaceInCode('r#"foo"#', "foo", "bar")).toBe('r#"foo"#');
|
||||
});
|
||||
|
||||
it("protects JSX string attribute", () => {
|
||||
const src = '<div className="foo">';
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe('<div className="foo">');
|
||||
});
|
||||
|
||||
it("only replaces code foo when multiple adjacent comments precede it", () => {
|
||||
const src = "// foo\n/* foo */\nfoo()";
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe("// foo\n/* foo */\nbar()");
|
||||
});
|
||||
|
||||
it("does not treat # after colon as a comment (CSS color mid-line)", () => {
|
||||
const src = "color:#fff";
|
||||
expect(replaceInCode(src, "fff", "000")).toBe("color:#000");
|
||||
});
|
||||
|
||||
it("handles very long source (1000 lines)", () => {
|
||||
const line = "foo = foo + 1;\n";
|
||||
const src = line.repeat(1000);
|
||||
const expected = "bar = bar + 1;\n".repeat(1000);
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe(expected);
|
||||
});
|
||||
|
||||
it("replaces single-char symbol", () => {
|
||||
expect(replaceInCode("a = b", "a", "x")).toBe("x = b");
|
||||
});
|
||||
|
||||
it("handles empty symbol name without crashing", () => {
|
||||
// Empty symbol creates regex /\b\b/g which matches zero-width word boundaries
|
||||
// This inserts "bar" at every word boundary position
|
||||
const result = replaceInCode("foo", "", "bar");
|
||||
expect(result).toBe("barfoobar");
|
||||
});
|
||||
|
||||
it("replaces symbol with trailing newline preserved", () => {
|
||||
expect(replaceInCode("foo\n", "foo", "bar")).toBe("bar\n");
|
||||
});
|
||||
|
||||
it("returns whitespace-only source unchanged", () => {
|
||||
expect(replaceInCode(" ", "foo", "bar")).toBe(" ");
|
||||
});
|
||||
|
||||
it("replaces tab-indented code but not tab-indented comment", () => {
|
||||
const src = "\tfoo();\n\t// foo\n";
|
||||
expect(replaceInCode(src, "foo", "bar")).toBe("\tbar();\n\t// foo\n");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Tests for actual shell execution edge cases.
|
||||
* These test the patterns used by shell.ts with real processes.
|
||||
* Bugs here = hung processes, lost output, corrupted state.
|
||||
*/
|
||||
|
||||
function runShell(
|
||||
command: string,
|
||||
opts: { timeout?: number; maxOutput?: number } = {},
|
||||
): Promise<{ stdout: string; stderr: string; code: number | null; timedOut: boolean }> {
|
||||
const timeout = opts.timeout ?? 5000;
|
||||
const maxOutput = opts.maxOutput ?? 16_384;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("sh", ["-c", command], { timeout });
|
||||
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
let stdoutLen = 0;
|
||||
let stderrLen = 0;
|
||||
let timedOut = false;
|
||||
|
||||
proc.stdout.on("data", (data: Buffer) => {
|
||||
if (stdoutLen < maxOutput) {
|
||||
stdoutChunks.push(data);
|
||||
stdoutLen += data.length;
|
||||
}
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data: Buffer) => {
|
||||
if (stderrLen < maxOutput) {
|
||||
stderrChunks.push(data);
|
||||
stderrLen += data.length;
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err: Error & { code?: string }) => {
|
||||
if (err.code === "ERR_CHILD_PROCESS_TIMEOUT" || err.message.includes("timed out")) {
|
||||
timedOut = true;
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve({
|
||||
stdout: Buffer.concat(stdoutChunks).toString("utf-8").slice(0, maxOutput),
|
||||
stderr: Buffer.concat(stderrChunks).toString("utf-8").slice(0, maxOutput),
|
||||
code,
|
||||
timedOut,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("shell execution — basic", () => {
|
||||
it("runs simple command", async () => {
|
||||
const r = await runShell("echo hello");
|
||||
expect(r.stdout.trim()).toBe("hello");
|
||||
expect(r.code).toBe(0);
|
||||
});
|
||||
|
||||
it("captures exit code", async () => {
|
||||
const r = await runShell("exit 42");
|
||||
expect(r.code).toBe(42);
|
||||
});
|
||||
|
||||
it("captures stderr", async () => {
|
||||
const r = await runShell("echo error >&2");
|
||||
expect(r.stderr.trim()).toBe("error");
|
||||
});
|
||||
|
||||
it("handles empty output", async () => {
|
||||
const r = await runShell("true");
|
||||
expect(r.stdout).toBe("");
|
||||
expect(r.code).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shell execution — timeout", () => {
|
||||
it("kills process on timeout", async () => {
|
||||
const r = await runShell("sleep 10", { timeout: 500 });
|
||||
expect(r.code).not.toBe(0);
|
||||
}, 3000);
|
||||
|
||||
it("returns non-zero code on timeout", async () => {
|
||||
const r = await runShell("sleep 10", { timeout: 500 });
|
||||
// Process killed by timeout should not return success
|
||||
expect(r.code === null || r.code !== 0).toBe(true);
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
describe("shell execution — output limits", () => {
|
||||
it("truncates large stdout", async () => {
|
||||
const r = await runShell("yes | head -n 100000", { maxOutput: 1024 });
|
||||
expect(r.stdout.length).toBeLessThanOrEqual(1024);
|
||||
});
|
||||
|
||||
it("handles multiline output", async () => {
|
||||
const r = await runShell("for i in $(seq 1 100); do echo line$i; done");
|
||||
const lines = r.stdout.trim().split("\n");
|
||||
expect(lines.length).toBe(100);
|
||||
expect(lines[0]).toBe("line1");
|
||||
expect(lines[99]).toBe("line100");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shell execution — special characters", () => {
|
||||
it("handles single quotes in output", async () => {
|
||||
const r = await runShell("echo \"it's working\"");
|
||||
expect(r.stdout.trim()).toBe("it's working");
|
||||
});
|
||||
|
||||
it("handles unicode output", async () => {
|
||||
const r = await runShell("echo '日本語 🎉'");
|
||||
expect(r.stdout.trim()).toBe("日本語 🎉");
|
||||
});
|
||||
|
||||
it("handles tab characters", async () => {
|
||||
const r = await runShell("printf 'a\\tb\\tc'");
|
||||
expect(r.stdout).toBe("a\tb\tc");
|
||||
});
|
||||
|
||||
it("handles null bytes in output (binary-like)", async () => {
|
||||
const r = await runShell("printf 'before\\x00after'");
|
||||
// null byte splits the string in most parsers
|
||||
expect(r.stdout).toContain("before");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shell execution — pipe and redirect", () => {
|
||||
it("handles piped commands", async () => {
|
||||
const r = await runShell("echo 'hello world' | wc -w");
|
||||
expect(r.stdout.trim()).toBe("2");
|
||||
});
|
||||
|
||||
it("handles command substitution", async () => {
|
||||
const r = await runShell("echo $(echo nested)");
|
||||
expect(r.stdout.trim()).toBe("nested");
|
||||
});
|
||||
|
||||
it("handles multiple commands with &&", async () => {
|
||||
const r = await runShell("echo a && echo b");
|
||||
expect(r.stdout.trim()).toBe("a\nb");
|
||||
});
|
||||
|
||||
it("handles || fallback", async () => {
|
||||
const r = await runShell("false || echo fallback");
|
||||
expect(r.stdout.trim()).toBe("fallback");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shell execution — error cases", () => {
|
||||
it("handles command not found", async () => {
|
||||
const r = await runShell("nonexistent_command_xyz 2>&1");
|
||||
expect(r.code).not.toBe(0);
|
||||
});
|
||||
|
||||
it("handles permission denied", async () => {
|
||||
const r = await runShell("cat /etc/shadow 2>&1");
|
||||
expect(r.code).not.toBe(0);
|
||||
});
|
||||
|
||||
it("handles broken pipe gracefully", async () => {
|
||||
const r = await runShell("yes | head -n 1");
|
||||
expect(r.stdout.trim()).toBe("y");
|
||||
expect(r.code).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shell execution — environment", () => {
|
||||
it("inherits PATH", async () => {
|
||||
const r = await runShell("which sh");
|
||||
expect(r.stdout.trim()).toBeTruthy();
|
||||
expect(r.code).toBe(0);
|
||||
});
|
||||
|
||||
it("handles inline env vars", async () => {
|
||||
const r = await runShell("MY_VAR=hello sh -c 'echo $MY_VAR'");
|
||||
expect(r.stdout.trim()).toBe("hello");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
/**
|
||||
* Tests for shell.ts security functions.
|
||||
* These guard against reading/writing forbidden files via shell commands.
|
||||
* A bypass here = attacker reads .env, SSH keys, credentials.
|
||||
*/
|
||||
|
||||
function extractPathArgs(argsStr: string): string[] {
|
||||
return argsStr
|
||||
.split(/\s+/)
|
||||
.filter((a) => !a.startsWith("-"))
|
||||
.map((a) => a.replace(/['"]/g, ""));
|
||||
}
|
||||
|
||||
function extractAllPathLikeArgs(command: string): string[] {
|
||||
const paths: string[] = [];
|
||||
const words = command.match(/(?:[^\s'"]+|'[^']*'|"[^"]*")+/g) ?? [];
|
||||
for (const w of words) {
|
||||
const cleaned = w.replace(/^['"]|['"]$/g, "");
|
||||
if (cleaned.startsWith("-") || cleaned.includes("=")) continue;
|
||||
if (/^[a-z_/~.][\w./~*?-]*$/i.test(cleaned)) {
|
||||
paths.push(cleaned);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function shellQuote(s: string): string {
|
||||
return `'${s.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
describe("extractPathArgs", () => {
|
||||
it("splits space-separated args", () => {
|
||||
expect(extractPathArgs("file1.ts file2.ts")).toEqual(["file1.ts", "file2.ts"]);
|
||||
});
|
||||
|
||||
it("filters flags", () => {
|
||||
expect(extractPathArgs("-n --color=never file.ts")).toEqual(["file.ts"]);
|
||||
});
|
||||
|
||||
it("strips quotes", () => {
|
||||
expect(extractPathArgs("'my file.ts' \"other.ts\"")).toEqual(["my", "file.ts", "other.ts"]);
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
expect(extractPathArgs("")).toEqual([""]);
|
||||
});
|
||||
|
||||
it("handles only flags", () => {
|
||||
expect(extractPathArgs("-a -b --verbose")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractAllPathLikeArgs", () => {
|
||||
it("extracts paths from simple command", () => {
|
||||
const paths = extractAllPathLikeArgs("cat src/main.ts");
|
||||
expect(paths).toContain("src/main.ts");
|
||||
});
|
||||
|
||||
it("extracts quoted paths", () => {
|
||||
const paths = extractAllPathLikeArgs("cat 'src/main.ts'");
|
||||
expect(paths).toContain("src/main.ts");
|
||||
});
|
||||
|
||||
it("filters flags", () => {
|
||||
const paths = extractAllPathLikeArgs("grep -rn pattern src/");
|
||||
expect(paths).not.toContain("-rn");
|
||||
expect(paths).toContain("src/");
|
||||
});
|
||||
|
||||
it("filters key=value args", () => {
|
||||
const paths = extractAllPathLikeArgs("CMD=true ./run.sh");
|
||||
expect(paths).not.toContain("CMD=true");
|
||||
expect(paths).toContain("./run.sh");
|
||||
});
|
||||
|
||||
it("handles complex command", () => {
|
||||
const paths = extractAllPathLikeArgs("grep -rn 'pattern' --include='*.ts' src/ lib/");
|
||||
expect(paths).toContain("src/");
|
||||
expect(paths).toContain("lib/");
|
||||
});
|
||||
|
||||
it("rejects non-path-like tokens", () => {
|
||||
const paths = extractAllPathLikeArgs("echo 'hello world'");
|
||||
// "echo" matches path-like regex, but "hello world" cleaned to "hello world" doesn't match
|
||||
expect(paths).toContain("echo");
|
||||
});
|
||||
|
||||
it("handles empty command", () => {
|
||||
expect(extractAllPathLikeArgs("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles command with pipes", () => {
|
||||
const paths = extractAllPathLikeArgs("cat file.txt | grep pattern");
|
||||
expect(paths).toContain("file.txt");
|
||||
});
|
||||
|
||||
it("extracts absolute paths", () => {
|
||||
const paths = extractAllPathLikeArgs("cat /etc/hostname");
|
||||
expect(paths).toContain("/etc/hostname");
|
||||
});
|
||||
|
||||
it("extracts tilde paths", () => {
|
||||
const paths = extractAllPathLikeArgs("cat ~/.bashrc");
|
||||
expect(paths).toContain("~/.bashrc");
|
||||
});
|
||||
|
||||
it("extracts glob patterns", () => {
|
||||
const paths = extractAllPathLikeArgs("ls src/**/*.ts");
|
||||
expect(paths).toContain("src/**/*.ts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shellQuote", () => {
|
||||
it("quotes simple string", () => {
|
||||
expect(shellQuote("hello")).toBe("'hello'");
|
||||
});
|
||||
|
||||
it("escapes single quotes", () => {
|
||||
expect(shellQuote("it's")).toBe("'it'\\''s'");
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
expect(shellQuote("")).toBe("''");
|
||||
});
|
||||
|
||||
it("handles multiple single quotes", () => {
|
||||
expect(shellQuote("a'b'c")).toBe("'a'\\''b'\\''c'");
|
||||
});
|
||||
|
||||
it("doesn't double-escape backslashes", () => {
|
||||
expect(shellQuote("path\\to\\file")).toBe("'path\\to\\file'");
|
||||
});
|
||||
|
||||
it("handles special shell chars safely", () => {
|
||||
const quoted = shellQuote("$(rm -rf /)");
|
||||
expect(quoted).toBe("'$(rm -rf /)'");
|
||||
});
|
||||
|
||||
it("handles backticks safely", () => {
|
||||
const quoted = shellQuote("`rm -rf /`");
|
||||
expect(quoted).toBe("'`rm -rf /`'");
|
||||
});
|
||||
|
||||
it("handles newlines", () => {
|
||||
const quoted = shellQuote("line1\nline2");
|
||||
expect(quoted).toBe("'line1\nline2'");
|
||||
});
|
||||
|
||||
it("handles semicolons", () => {
|
||||
const quoted = shellQuote("cmd; evil");
|
||||
expect(quoted).toBe("'cmd; evil'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shell security — SUBSHELL_RE detection", () => {
|
||||
const SUBSHELL_RE = /\$\(|`[^`]*`|\$\{/;
|
||||
|
||||
it("detects $() subshell", () => {
|
||||
expect(SUBSHELL_RE.test("echo $(cat /etc/passwd)")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects backtick subshell", () => {
|
||||
expect(SUBSHELL_RE.test("echo `cat /etc/passwd`")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects ${} expansion", () => {
|
||||
expect(SUBSHELL_RE.test("echo ${HOME}")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows plain commands", () => {
|
||||
expect(SUBSHELL_RE.test("ls -la src/")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows dollar sign in normal usage", () => {
|
||||
expect(SUBSHELL_RE.test("echo $PATH")).toBe(false);
|
||||
});
|
||||
|
||||
it("detects nested subshell", () => {
|
||||
expect(SUBSHELL_RE.test("cat $(echo /etc/$(whoami))")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shell security — redirect regex", () => {
|
||||
const OUTPUT_REDIR_RE = />{1,2}\s*([^\s|&;]+)/g;
|
||||
const INPUT_REDIR_RE = /<\s*([^\s|&;]+)/g;
|
||||
|
||||
it("captures output redirect target", () => {
|
||||
const m = "echo hi > /tmp/out.txt".matchAll(OUTPUT_REDIR_RE);
|
||||
const matches = [...m];
|
||||
expect(matches.length).toBe(1);
|
||||
expect(matches[0]![1]).toBe("/tmp/out.txt");
|
||||
});
|
||||
|
||||
it("captures append redirect target", () => {
|
||||
const m = "echo hi >> /tmp/out.txt".matchAll(OUTPUT_REDIR_RE);
|
||||
const matches = [...m];
|
||||
expect(matches.length).toBe(1);
|
||||
expect(matches[0]![1]).toBe("/tmp/out.txt");
|
||||
});
|
||||
|
||||
it("captures input redirect target", () => {
|
||||
const m = "cmd < /etc/passwd".matchAll(INPUT_REDIR_RE);
|
||||
const matches = [...m];
|
||||
expect(matches.length).toBe(1);
|
||||
expect(matches[0]![1]).toBe("/etc/passwd");
|
||||
});
|
||||
|
||||
it("captures redirect with space", () => {
|
||||
const m = "echo hi > /tmp/out.txt".matchAll(OUTPUT_REDIR_RE);
|
||||
const matches = [...m];
|
||||
expect(matches[0]![1]).toBe("/tmp/out.txt");
|
||||
});
|
||||
|
||||
it("captures multiple redirects", () => {
|
||||
const m = "cmd > /tmp/a >> /tmp/b".matchAll(OUTPUT_REDIR_RE);
|
||||
const matches = [...m];
|
||||
expect(matches.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shell security — FILE_READ_RE patterns", () => {
|
||||
const FILE_READ_RE =
|
||||
/\b(cat|head|tail|less|more|bat|xxd|hexdump|strings|base64|tac|nl|od|file)\s+(.+)/;
|
||||
|
||||
it("matches cat command", () => {
|
||||
const m = "cat /etc/passwd".match(FILE_READ_RE);
|
||||
expect(m).not.toBeNull();
|
||||
expect(m![1]).toBe("cat");
|
||||
expect(m![2]).toBe("/etc/passwd");
|
||||
});
|
||||
|
||||
it("matches base64 command", () => {
|
||||
const m = "base64 secret.key".match(FILE_READ_RE);
|
||||
expect(m).not.toBeNull();
|
||||
expect(m![1]).toBe("base64");
|
||||
});
|
||||
|
||||
it("matches head with flags", () => {
|
||||
const m = "head -n 10 file.txt".match(FILE_READ_RE);
|
||||
expect(m).not.toBeNull();
|
||||
expect(m![2]).toBe("-n 10 file.txt");
|
||||
});
|
||||
|
||||
it("doesn't match non-file commands", () => {
|
||||
expect("echo hello".match(FILE_READ_RE)).toBeNull();
|
||||
expect("ls -la".match(FILE_READ_RE)).toBeNull();
|
||||
expect("git status".match(FILE_READ_RE)).toBeNull();
|
||||
});
|
||||
|
||||
it("matches file command embedded in pipeline", () => {
|
||||
const m = "cat secret.pem | base64".match(FILE_READ_RE);
|
||||
expect(m).not.toBeNull();
|
||||
expect(m![1]).toBe("cat");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
repairToolCall,
|
||||
sanitizeMessages,
|
||||
sanitizeToolInputsStep,
|
||||
} from "../src/core/agents/stream-options.js";
|
||||
|
||||
function mockToolCall(input: string) {
|
||||
return {
|
||||
type: "tool-call" as const,
|
||||
toolCallId: "test-id",
|
||||
toolName: "test-tool",
|
||||
input,
|
||||
};
|
||||
}
|
||||
|
||||
describe("repairToolCall — truncated output", () => {
|
||||
it("closes truncated object", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('{"file": "test.ts"') });
|
||||
expect(result).not.toBeNull();
|
||||
expect(JSON.parse(result!.input)).toEqual({ file: "test.ts" });
|
||||
});
|
||||
|
||||
it("closes truncated nested object", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('{"a": {"b": 1') });
|
||||
expect(result).not.toBeNull();
|
||||
expect(JSON.parse(result!.input).a.b).toBe(1);
|
||||
});
|
||||
|
||||
it("closes truncated array in object", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('{"items": [1, 2, 3') });
|
||||
expect(result).not.toBeNull();
|
||||
expect(JSON.parse(result!.input).items).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("cannot recover truncation mid-string value", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('{"file": "src/core/too') });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("cannot recover truncation mid-key", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('{"file": "test.ts", "con') });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("handles deeply nested truncation", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('{"a": {"b": {"c": {"d": 1') });
|
||||
expect(result).not.toBeNull();
|
||||
expect(JSON.parse(result!.input).a.b.c.d).toBe(1);
|
||||
});
|
||||
|
||||
it("closes deeply nested truncation with arrays", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('{"items": [{"a": [1, 2') });
|
||||
expect(result).not.toBeNull();
|
||||
const parsed = JSON.parse(result!.input);
|
||||
expect(parsed.items[0].a).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("repairs input with unicode values", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('{"name": "café"') });
|
||||
expect(result).not.toBeNull();
|
||||
expect(JSON.parse(result!.input).name).toBe("café");
|
||||
});
|
||||
|
||||
it("repairs input with newlines", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('{\n "a": 1\n') });
|
||||
expect(result).not.toBeNull();
|
||||
expect(JSON.parse(result!.input).a).toBe(1);
|
||||
});
|
||||
|
||||
it("repairs trailing comma combined with truncation", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('{"a": 1, "b": [2,') });
|
||||
expect(result).not.toBeNull();
|
||||
const parsed = JSON.parse(result!.input);
|
||||
expect(parsed.a).toBe(1);
|
||||
expect(parsed.b).toEqual([2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("repairToolCall — trailing commas", () => {
|
||||
it("fixes trailing comma in object", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('{"a": 1,}') });
|
||||
expect(result).not.toBeNull();
|
||||
expect(JSON.parse(result!.input)).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it("fixes trailing comma in array", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('{"items": [1, 2,]}') });
|
||||
expect(result).not.toBeNull();
|
||||
expect(JSON.parse(result!.input).items).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("fixes multiple trailing commas", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('{"a": {"b": 1,},}') });
|
||||
expect(result).not.toBeNull();
|
||||
expect(JSON.parse(result!.input)).toEqual({ a: { b: 1 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("repairToolCall — string edge cases", () => {
|
||||
it("returns null for valid JSON with escaped quotes (no repair needed)", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('{"msg": "say \\"hello\\""}') });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for valid JSON with escaped backslashes (no repair needed)", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('{"path": "C:\\\\Users\\\\"}') });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for valid JSON with brackets in strings (no repair needed)", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('{"code": "if (x) { return [1]; }"}') });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("cannot recover truncation after escaped quote", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('{"msg": "hello \\"world') });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("handles empty string value", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('{"file": ""') });
|
||||
expect(result).not.toBeNull();
|
||||
expect(JSON.parse(result!.input).file).toBe("");
|
||||
});
|
||||
|
||||
it("cannot recover string ending with backslash (truncated escape)", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('{"path": "C:\\') });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("repairToolCall — invalid / no-op input", () => {
|
||||
it("returns null for empty string", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall("") });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for whitespace only", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall(" ") });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for array (not object)", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall("[1, 2, 3]") });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for string primitive", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('"hello"') });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for completely garbled input", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall("not json at all") });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for already-valid JSON (no repair needed)", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall('{"a": 1}') });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for valid JSON with leading/trailing whitespace", async () => {
|
||||
const result = await repairToolCall({ toolCall: mockToolCall(' {"a": 1} ') });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("100 levels of nested brackets — doesn't hang", async () => {
|
||||
const input = "{".repeat(100) + '"a": 1';
|
||||
await expect(repairToolCall({ toolCall: mockToolCall(input) })).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("handles mismatched brackets without hanging", async () => {
|
||||
await expect(repairToolCall({ toolCall: mockToolCall('{"a": [1}') })).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeMessages", () => {
|
||||
it("returns same reference when no tool calls present", () => {
|
||||
const messages = [
|
||||
{ role: "user" as const, content: "hello" },
|
||||
{ role: "assistant" as const, content: "hi" },
|
||||
];
|
||||
const result = sanitizeMessages(messages);
|
||||
expect(result).toBe(messages);
|
||||
});
|
||||
|
||||
it("returns same reference for empty messages array", () => {
|
||||
const messages: Parameters<typeof sanitizeMessages>[0] = [];
|
||||
const result = sanitizeMessages(messages);
|
||||
expect(result).toBe(messages);
|
||||
});
|
||||
|
||||
it("leaves valid object tool call inputs unchanged", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: [
|
||||
{ type: "tool-call" as const, toolCallId: "t1", toolName: "test", input: { a: 1 } },
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = sanitizeMessages(messages);
|
||||
expect(result).toBe(messages);
|
||||
});
|
||||
|
||||
it("replaces string tool call input with {}", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: [
|
||||
{ type: "tool-call" as const, toolCallId: "t1", toolName: "test", input: "bad" as unknown as Record<string, unknown> },
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = sanitizeMessages(messages);
|
||||
expect(result).not.toBe(messages);
|
||||
const part = (result[0] as { content: Array<{ input: unknown }> }).content[0];
|
||||
expect(part.input).toEqual({});
|
||||
});
|
||||
|
||||
it("replaces array tool call input with {}", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: [
|
||||
{ type: "tool-call" as const, toolCallId: "t1", toolName: "test", input: [1, 2] as unknown as Record<string, unknown> },
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = sanitizeMessages(messages);
|
||||
expect(result).not.toBe(messages);
|
||||
const part = (result[0] as { content: Array<{ input: unknown }> }).content[0];
|
||||
expect(part.input).toEqual({});
|
||||
});
|
||||
|
||||
it("replaces null tool call input with {}", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: [
|
||||
{ type: "tool-call" as const, toolCallId: "t1", toolName: "test", input: null as unknown as Record<string, unknown> },
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = sanitizeMessages(messages);
|
||||
expect(result).not.toBe(messages);
|
||||
const part = (result[0] as { content: Array<{ input: unknown }> }).content[0];
|
||||
expect(part.input).toEqual({});
|
||||
});
|
||||
|
||||
it("replaces number tool call input with {}", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: [
|
||||
{ type: "tool-call" as const, toolCallId: "t1", toolName: "test", input: 42 as unknown as Record<string, unknown> },
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = sanitizeMessages(messages);
|
||||
expect(result).not.toBe(messages);
|
||||
const part = (result[0] as { content: Array<{ input: unknown }> }).content[0];
|
||||
expect(part.input).toEqual({});
|
||||
});
|
||||
|
||||
it("handles mix of valid and invalid tool calls in same message", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: [
|
||||
{ type: "tool-call" as const, toolCallId: "t1", toolName: "good", input: { file: "a.ts" } },
|
||||
{ type: "tool-call" as const, toolCallId: "t2", toolName: "bad", input: "oops" as unknown as Record<string, unknown> },
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = sanitizeMessages(messages);
|
||||
expect(result).not.toBe(messages);
|
||||
const content = (result[0] as { content: Array<{ toolCallId: string; input: unknown }> }).content;
|
||||
expect(content[0].input).toEqual({ file: "a.ts" });
|
||||
expect(content[1].input).toEqual({});
|
||||
});
|
||||
|
||||
it("non-assistant messages pass through unchanged", () => {
|
||||
const messages = [
|
||||
{ role: "user" as const, content: "do something" },
|
||||
{ role: "system" as const, content: "you are helpful" },
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: [
|
||||
{ type: "tool-call" as const, toolCallId: "t1", toolName: "test", input: "bad" as unknown as Record<string, unknown> },
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = sanitizeMessages(messages);
|
||||
expect(result[0]).toBe(messages[0]);
|
||||
expect(result[1]).toBe(messages[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeToolInputsStep", () => {
|
||||
it("returns undefined when messages are clean", () => {
|
||||
const messages = [
|
||||
{ role: "user" as const, content: "hello" },
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: [
|
||||
{ type: "tool-call" as const, toolCallId: "t1", toolName: "test", input: { a: 1 } },
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = sanitizeToolInputsStep({ messages });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns { messages } when messages need sanitization", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: [
|
||||
{ type: "tool-call" as const, toolCallId: "t1", toolName: "test", input: "bad" as unknown as Record<string, unknown> },
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = sanitizeToolInputsStep({ messages });
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.messages).not.toBe(messages);
|
||||
const part = (result!.messages[0] as { content: Array<{ input: unknown }> }).content[0];
|
||||
expect(part.input).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { findSymbolRange, findCommentStart, line as getLine } from "../src/core/tools/move-symbol.js";
|
||||
|
||||
describe("findSymbolRange", () => {
|
||||
it("finds a simple function", () => {
|
||||
const lines = ["function foo() {", " return 1;", "}"];
|
||||
const result = findSymbolRange(lines, "foo");
|
||||
expect(result).toEqual({ start: 0, end: 2 });
|
||||
});
|
||||
|
||||
it("finds an exported function", () => {
|
||||
const lines = ["export function foo() {", " return 1;", "}"];
|
||||
const result = findSymbolRange(lines, "foo");
|
||||
expect(result).toEqual({ start: 0, end: 2 });
|
||||
});
|
||||
|
||||
it("finds export default function", () => {
|
||||
const lines = ["export default function foo() {", "}"];
|
||||
const result = findSymbolRange(lines, "foo");
|
||||
expect(result).toEqual({ start: 0, end: 1 });
|
||||
});
|
||||
|
||||
it("finds type alias (no braces, ends with ;)", () => {
|
||||
const lines = ["type Foo = string;"];
|
||||
const result = findSymbolRange(lines, "Foo");
|
||||
expect(result).toEqual({ start: 0, end: 0 });
|
||||
});
|
||||
|
||||
it("finds interface with body", () => {
|
||||
const lines = ["interface Foo {", " name: string;", "}"];
|
||||
const result = findSymbolRange(lines, "Foo");
|
||||
expect(result).toEqual({ start: 0, end: 2 });
|
||||
});
|
||||
|
||||
it("finds Rust pub(crate) struct", () => {
|
||||
const lines = ["pub(crate) struct Config {", " pub name: String,", "}"];
|
||||
const result = findSymbolRange(lines, "Config");
|
||||
expect(result).toEqual({ start: 0, end: 2 });
|
||||
});
|
||||
|
||||
it("finds Rust fn", () => {
|
||||
const lines = ["pub fn process() {", " todo!()", "}"];
|
||||
const result = findSymbolRange(lines, "process");
|
||||
expect(result).toEqual({ start: 0, end: 2 });
|
||||
});
|
||||
|
||||
it("finds Python def", () => {
|
||||
const lines = ["def my_func():", " return 1"];
|
||||
const result = findSymbolRange(lines, "my_func");
|
||||
expect(result?.start).toBe(0);
|
||||
});
|
||||
|
||||
it("finds Go func", () => {
|
||||
const lines = ["func main() {", " fmt.Println()", "}"];
|
||||
const result = findSymbolRange(lines, "main");
|
||||
expect(result).toEqual({ start: 0, end: 2 });
|
||||
});
|
||||
|
||||
it("returns null for missing symbol", () => {
|
||||
const lines = ["function bar() {", "}"];
|
||||
expect(findSymbolRange(lines, "foo")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles nested braces correctly", () => {
|
||||
const lines = [
|
||||
"function foo() {",
|
||||
" if (true) {",
|
||||
" while (x) {",
|
||||
" break;",
|
||||
" }",
|
||||
" }",
|
||||
"}",
|
||||
];
|
||||
const result = findSymbolRange(lines, "foo");
|
||||
expect(result).toEqual({ start: 0, end: 6 });
|
||||
});
|
||||
|
||||
it("handles symbol name that is a prefix of another (word boundary)", () => {
|
||||
const lines = ["function fooBar() {", "}", "function foo() {", "}"];
|
||||
const result = findSymbolRange(lines, "foo");
|
||||
expect(result?.start).toBe(2);
|
||||
});
|
||||
|
||||
it("handles const with no braces and semicolon", () => {
|
||||
const lines = ["export const MAX_SIZE = 100;"];
|
||||
const result = findSymbolRange(lines, "MAX_SIZE");
|
||||
expect(result).toEqual({ start: 0, end: 0 });
|
||||
});
|
||||
|
||||
it("handles abstract class", () => {
|
||||
const lines = ["export abstract class Base {", " abstract run(): void;", "}"];
|
||||
const result = findSymbolRange(lines, "Base");
|
||||
expect(result).toEqual({ start: 0, end: 2 });
|
||||
});
|
||||
|
||||
it("handles enum", () => {
|
||||
const lines = ["enum Color {", " Red,", " Blue,", "}"];
|
||||
const result = findSymbolRange(lines, "Color");
|
||||
expect(result).toEqual({ start: 0, end: 3 });
|
||||
});
|
||||
|
||||
it("handles braces in string literals (false brace counting)", () => {
|
||||
const lines = ['function foo() {', ' const s = "}";', ' return s;', '}'];
|
||||
const result = findSymbolRange(lines, "foo");
|
||||
expect(result?.end).toBe(1);
|
||||
});
|
||||
|
||||
it("handles unclosed function (no closing brace)", () => {
|
||||
const lines = ["function foo() {", " return 1;"];
|
||||
const result = findSymbolRange(lines, "foo");
|
||||
expect(result?.start).toBe(0);
|
||||
});
|
||||
|
||||
// ─── Edge Cases ──────────────────────────────────────────
|
||||
|
||||
it("returns null for empty lines array", () => {
|
||||
expect(findSymbolRange([], "foo")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles symbol on last line with no closing brace", () => {
|
||||
const lines = ["const x = 1;", "function foo() {"];
|
||||
const result = findSymbolRange(lines, "foo");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.start).toBe(1);
|
||||
});
|
||||
|
||||
it("finds multiline type alias ending with semicolon", () => {
|
||||
const lines = ["type Foo =", " string | number;"];
|
||||
const result = findSymbolRange(lines, "Foo");
|
||||
expect(result).toEqual({ start: 0, end: 1 });
|
||||
});
|
||||
|
||||
it("finds Rust impl block", () => {
|
||||
const lines = ["impl Foo {", " fn bar(&self) {}", "}"];
|
||||
const result = findSymbolRange(lines, "Foo");
|
||||
expect(result).toEqual({ start: 0, end: 2 });
|
||||
});
|
||||
|
||||
it("finds Rust trait", () => {
|
||||
const lines = ["trait MyTrait {", " fn required(&self);", "}"];
|
||||
const result = findSymbolRange(lines, "MyTrait");
|
||||
expect(result).toEqual({ start: 0, end: 2 });
|
||||
});
|
||||
|
||||
it("handles const with object value (brace counting)", () => {
|
||||
const lines = ["const config = {", " port: 3000,", "};"];
|
||||
const result = findSymbolRange(lines, "config");
|
||||
expect(result).toEqual({ start: 0, end: 2 });
|
||||
});
|
||||
|
||||
it("returns first occurrence for duplicate symbol name", () => {
|
||||
const lines = [
|
||||
"function handler() {",
|
||||
" return 1;",
|
||||
"}",
|
||||
"function handler() {",
|
||||
" return 2;",
|
||||
"}",
|
||||
];
|
||||
const result = findSymbolRange(lines, "handler");
|
||||
expect(result).toEqual({ start: 0, end: 2 });
|
||||
});
|
||||
|
||||
it("matches indented keyword (leading whitespace)", () => {
|
||||
const lines = [" export function foo() {", " return 1;", " }"];
|
||||
const result = findSymbolRange(lines, "foo");
|
||||
expect(result).toEqual({ start: 0, end: 2 });
|
||||
});
|
||||
|
||||
it("handles very long symbol name (50 chars)", () => {
|
||||
const name = "a".repeat(50);
|
||||
const lines = [`function ${name}() {`, " return 1;", "}"];
|
||||
const result = findSymbolRange(lines, name);
|
||||
expect(result).toEqual({ start: 0, end: 2 });
|
||||
});
|
||||
|
||||
it("handles symbol name with underscore prefix", () => {
|
||||
const lines = ["function _privateHelper() {", " return 1;", "}"];
|
||||
const result = findSymbolRange(lines, "_privateHelper");
|
||||
expect(result).toEqual({ start: 0, end: 2 });
|
||||
});
|
||||
|
||||
it("handles symbol name with numbers", () => {
|
||||
const lines = ["function handler2() {", " return 2;", "}"];
|
||||
const result = findSymbolRange(lines, "handler2");
|
||||
expect(result).toEqual({ start: 0, end: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("findCommentStart", () => {
|
||||
it("finds JSDoc comment above definition", () => {
|
||||
const lines = ["/**", " * Description", " */", "function foo() {}"];
|
||||
expect(findCommentStart(lines, 3)).toBe(0);
|
||||
});
|
||||
|
||||
it("finds single-line comments above definition", () => {
|
||||
const lines = ["// comment 1", "// comment 2", "function foo() {}"];
|
||||
expect(findCommentStart(lines, 2)).toBe(0);
|
||||
});
|
||||
|
||||
it("finds Rust doc comments (///)", () => {
|
||||
const lines = ["/// Documentation", "pub fn foo() {}"];
|
||||
expect(findCommentStart(lines, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it("includes Rust attributes (#[derive])", () => {
|
||||
const lines = ["#[derive(Debug)]", '#[serde(rename_all = "camelCase")]', "pub struct Foo {}"];
|
||||
expect(findCommentStart(lines, 2)).toBe(0);
|
||||
});
|
||||
|
||||
it("includes attributes after doc comment", () => {
|
||||
const lines = ["/// Documentation", "#[derive(Clone)]", "pub struct Foo {}"];
|
||||
expect(findCommentStart(lines, 2)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns defStart when no comment above", () => {
|
||||
const lines = ["", "function foo() {}"];
|
||||
expect(findCommentStart(lines, 1)).toBe(1);
|
||||
});
|
||||
|
||||
it("stops at non-comment line", () => {
|
||||
const lines = ["const x = 1;", "// comment", "function foo() {}"];
|
||||
expect(findCommentStart(lines, 2)).toBe(1);
|
||||
});
|
||||
|
||||
it("handles defStart at line 0", () => {
|
||||
const lines = ["function foo() {}"];
|
||||
expect(findCommentStart(lines, 0)).toBe(0);
|
||||
});
|
||||
|
||||
// ─── Edge Cases ──────────────────────────────────────────
|
||||
|
||||
it("finds block comment (/* ... */) above definition", () => {
|
||||
const lines = ["/*", " * Block comment", " */", "function foo() {}"];
|
||||
// "/*" alone doesn't match any comment marker (needs "/**"), so scanning
|
||||
// stops at " * Block comment" (line 1). Documents that bare /* is missed.
|
||||
expect(findCommentStart(lines, 3)).toBe(1);
|
||||
});
|
||||
|
||||
it("does not include blank lines between comment and definition", () => {
|
||||
const lines = ["// orphaned comment", "", "function foo() {}"];
|
||||
expect(findCommentStart(lines, 2)).toBe(2);
|
||||
});
|
||||
|
||||
it("does not detect Python decorators (documents limitation)", () => {
|
||||
const lines = ["@decorator", "def foo():", " pass"];
|
||||
expect(findCommentStart(lines, 1)).toBe(1);
|
||||
});
|
||||
|
||||
it("finds Rust #[cfg(test)] attribute", () => {
|
||||
const lines = ["#[cfg(test)]", "mod tests {", "}"];
|
||||
expect(findCommentStart(lines, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it("finds multiple stacked Rust attributes", () => {
|
||||
const lines = [
|
||||
"#[derive(Debug, Clone)]",
|
||||
"#[serde(rename_all = \"camelCase\")]",
|
||||
"#[cfg(feature = \"full\")]",
|
||||
"pub struct Config {}",
|
||||
];
|
||||
expect(findCommentStart(lines, 3)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 when defStart is 0 (no lines above)", () => {
|
||||
const lines = ["pub fn foo() {}"];
|
||||
expect(findCommentStart(lines, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("line helper", () => {
|
||||
it("returns line content for valid index", () => {
|
||||
expect(getLine(["a", "b", "c"], 1)).toBe("b");
|
||||
});
|
||||
|
||||
it("returns empty string for out-of-bounds index", () => {
|
||||
expect(getLine(["a"], 5)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { createThinkingParser, type ParsedChunk } from "../src/core/thinking-parser.js";
|
||||
|
||||
function feedAll(deltas: string[]): ParsedChunk[] {
|
||||
const parser = createThinkingParser();
|
||||
const chunks: ParsedChunk[] = [];
|
||||
for (const d of deltas) chunks.push(...parser.feed(d));
|
||||
chunks.push(...parser.flush());
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function textContent(chunks: ParsedChunk[]): string {
|
||||
return chunks.filter((c) => c.type === "text").map((c) => c.content).join("");
|
||||
}
|
||||
|
||||
function reasoningContent(chunks: ParsedChunk[]): string {
|
||||
return chunks.filter((c) => c.type === "reasoning-content").map((c) => c.content).join("");
|
||||
}
|
||||
|
||||
describe("thinking-parser — basic", () => {
|
||||
it("passes through plain text", () => {
|
||||
const chunks = feedAll(["hello world"]);
|
||||
expect(textContent(chunks)).toBe("hello world");
|
||||
expect(chunks.every((c) => c.type === "text")).toBe(true);
|
||||
});
|
||||
|
||||
it("extracts <thinking> block", () => {
|
||||
const chunks = feedAll(["<thinking>deep thought</thinking>answer"]);
|
||||
expect(reasoningContent(chunks)).toBe("deep thought");
|
||||
expect(textContent(chunks)).toBe("answer");
|
||||
});
|
||||
|
||||
it("extracts <think> block", () => {
|
||||
const chunks = feedAll(["<think>hmm</think>result"]);
|
||||
expect(reasoningContent(chunks)).toBe("hmm");
|
||||
expect(textContent(chunks)).toBe("result");
|
||||
});
|
||||
|
||||
it("extracts <reasoning> block", () => {
|
||||
const chunks = feedAll(["<reasoning>logic</reasoning>answer"]);
|
||||
expect(reasoningContent(chunks)).toBe("logic");
|
||||
});
|
||||
|
||||
it("extracts <reason> block", () => {
|
||||
const chunks = feedAll(["<reason>why</reason>answer"]);
|
||||
expect(reasoningContent(chunks)).toBe("why");
|
||||
});
|
||||
});
|
||||
|
||||
describe("thinking-parser — streaming (split across deltas)", () => {
|
||||
it("tag split across two deltas", () => {
|
||||
const chunks = feedAll(["<thin", "king>thought</thinking>done"]);
|
||||
expect(reasoningContent(chunks)).toBe("thought");
|
||||
expect(textContent(chunks)).toBe("done");
|
||||
});
|
||||
|
||||
it("closing tag split across deltas", () => {
|
||||
const chunks = feedAll(["<thinking>thought</thin", "king>done"]);
|
||||
expect(reasoningContent(chunks)).toBe("thought");
|
||||
expect(textContent(chunks)).toBe("done");
|
||||
});
|
||||
|
||||
it("content arrives in single-char deltas", () => {
|
||||
const input = "<thinking>ab</thinking>cd";
|
||||
const deltas = input.split("");
|
||||
const chunks = feedAll(deltas);
|
||||
expect(reasoningContent(chunks)).toBe("ab");
|
||||
expect(textContent(chunks)).toBe("cd");
|
||||
});
|
||||
|
||||
it("tag split at every character boundary", () => {
|
||||
const input = "<think>x</think>y";
|
||||
for (let split = 1; split < input.length; split++) {
|
||||
const chunks = feedAll([input.slice(0, split), input.slice(split)]);
|
||||
expect(reasoningContent(chunks)).toBe("x");
|
||||
expect(textContent(chunks)).toBe("y");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("thinking-parser — edge cases", () => {
|
||||
it("< that is not a tag (plain text)", () => {
|
||||
const chunks = feedAll(["1 < 2 and 3 > 2"]);
|
||||
expect(textContent(chunks)).toBe("1 < 2 and 3 > 2");
|
||||
});
|
||||
|
||||
it("multiple < characters without tag match", () => {
|
||||
const chunks = feedAll(["<<<< not a tag >>>>"]);
|
||||
expect(textContent(chunks)).toBe("<<<< not a tag >>>>");
|
||||
});
|
||||
|
||||
it("< followed by partial but non-matching prefix", () => {
|
||||
const chunks = feedAll(["<div>hello</div>"]);
|
||||
expect(textContent(chunks)).toBe("<div>hello</div>");
|
||||
});
|
||||
|
||||
it("unclosed thinking block — flush closes it", () => {
|
||||
const parser = createThinkingParser();
|
||||
const chunks: ParsedChunk[] = [];
|
||||
chunks.push(...parser.feed("<thinking>still thinking..."));
|
||||
chunks.push(...parser.flush());
|
||||
expect(reasoningContent(chunks)).toBe("still thinking...");
|
||||
const hasEnd = chunks.some((c) => c.type === "reasoning-end");
|
||||
expect(hasEnd).toBe(true);
|
||||
});
|
||||
|
||||
it("empty thinking block", () => {
|
||||
const chunks = feedAll(["<thinking></thinking>done"]);
|
||||
const hasStart = chunks.some((c) => c.type === "reasoning-start");
|
||||
const hasEnd = chunks.some((c) => c.type === "reasoning-end");
|
||||
expect(hasStart).toBe(true);
|
||||
expect(hasEnd).toBe(true);
|
||||
expect(textContent(chunks)).toBe("done");
|
||||
});
|
||||
|
||||
it("nested < inside thinking content", () => {
|
||||
const chunks = feedAll(["<thinking>if x < 10 then y</thinking>done"]);
|
||||
expect(reasoningContent(chunks)).toBe("if x < 10 then y");
|
||||
});
|
||||
|
||||
it("thinking block with HTML-like content inside", () => {
|
||||
const chunks = feedAll(["<thinking>use <div> for layout</thinking>result"]);
|
||||
expect(reasoningContent(chunks)).toBe("use <div> for layout");
|
||||
expect(textContent(chunks)).toBe("result");
|
||||
});
|
||||
|
||||
it("multiple thinking blocks", () => {
|
||||
const chunks = feedAll(["<think>a</think>b<think>c</think>d"]);
|
||||
expect(reasoningContent(chunks)).toBe("ac");
|
||||
expect(textContent(chunks)).toBe("bd");
|
||||
});
|
||||
|
||||
it("empty input", () => {
|
||||
const chunks = feedAll([""]);
|
||||
expect(chunks).toEqual([]);
|
||||
});
|
||||
|
||||
it("only whitespace", () => {
|
||||
const chunks = feedAll([" "]);
|
||||
expect(textContent(chunks)).toBe(" ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("thinking-parser — hang safety", () => {
|
||||
it("1000 deltas of plain text", () => {
|
||||
const deltas = Array.from({ length: 1000 }, (_, i) => `word${i} `);
|
||||
const chunks = feedAll(deltas);
|
||||
expect(textContent(chunks).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("1000 deltas of '<' characters (no tag match)", () => {
|
||||
const deltas = Array.from({ length: 1000 }, () => "<");
|
||||
const chunks = feedAll(deltas);
|
||||
expect(textContent(chunks)).toBe("<".repeat(1000));
|
||||
});
|
||||
|
||||
it("rapid open/close cycles", () => {
|
||||
const deltas = Array.from({ length: 100 }, () => "<think>x</think>");
|
||||
const chunks = feedAll(deltas);
|
||||
expect(reasoningContent(chunks)).toBe("x".repeat(100));
|
||||
});
|
||||
|
||||
it("very long thinking content", () => {
|
||||
const content = "a".repeat(100000);
|
||||
const chunks = feedAll([`<thinking>${content}</thinking>`]);
|
||||
expect(reasoningContent(chunks)).toBe(content);
|
||||
});
|
||||
|
||||
it("partial tag at EOF (flush handles it)", () => {
|
||||
const parser = createThinkingParser();
|
||||
const chunks: ParsedChunk[] = [];
|
||||
chunks.push(...parser.feed("hello <thin"));
|
||||
chunks.push(...parser.flush());
|
||||
// "<thin" is a partial tag prefix — flush should emit it as text
|
||||
expect(textContent(chunks)).toBe("hello <thin");
|
||||
});
|
||||
|
||||
it("partial close tag at EOF", () => {
|
||||
const parser = createThinkingParser();
|
||||
const chunks: ParsedChunk[] = [];
|
||||
chunks.push(...parser.feed("<thinking>content</thin"));
|
||||
chunks.push(...parser.flush());
|
||||
expect(reasoningContent(chunks)).toContain("content");
|
||||
// flush should close the reasoning block
|
||||
const hasEnd = chunks.some((c) => c.type === "reasoning-end");
|
||||
expect(hasEnd).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("thinking-parser — missing edge cases", () => {
|
||||
it("mismatched open/close tags — <think> opened, </thinking> close", () => {
|
||||
// <think> sets activeClose to "</think>". "</thinking>" does NOT start with "</think>"
|
||||
// because char 7 is 'i' vs '>'. The close tag never matches, so flush closes the block.
|
||||
const chunks = feedAll(["<think>content</thinking>done"]);
|
||||
expect(reasoningContent(chunks)).toBe("content</thinking>done");
|
||||
expect(textContent(chunks)).toBe("");
|
||||
});
|
||||
|
||||
it("nested thinking tags — <thinking><thinking>inner</thinking>after</thinking>", () => {
|
||||
const chunks = feedAll(["<thinking>outer<thinking>inner</thinking>after</thinking>done"]);
|
||||
// Parser doesn't track nesting. Inside state, <thinking> is not a close tag,
|
||||
// so "<" is emitted as reasoning-content char-by-char until close tag found.
|
||||
// First </thinking> closes the block.
|
||||
expect(reasoningContent(chunks)).toContain("inner");
|
||||
expect(reasoningContent(chunks)).toContain("outer");
|
||||
// "after</thinking>done" becomes text — </thinking> outside is just angle brackets
|
||||
const text = textContent(chunks);
|
||||
expect(text).toContain("after");
|
||||
expect(text).toContain("done");
|
||||
});
|
||||
|
||||
it("tag with attributes — <thinking lang='en'> should not match", () => {
|
||||
const chunks = feedAll(["<thinking lang='en'>content</thinking>done"]);
|
||||
// tryMatchOpen checks startsWith("<thinking>") — attribute breaks the match.
|
||||
// The "<" gets emitted as text, then everything else is text too.
|
||||
// </thinking> outside doesn't match any open tag either.
|
||||
const text = textContent(chunks);
|
||||
expect(text).toContain("content");
|
||||
expect(text).toContain("done");
|
||||
});
|
||||
|
||||
it("mixed tag types in sequence", () => {
|
||||
const chunks = feedAll(["<think>a</think>b<reasoning>c</reasoning>d"]);
|
||||
expect(reasoningContent(chunks)).toBe("ac");
|
||||
expect(textContent(chunks)).toBe("bd");
|
||||
});
|
||||
|
||||
it("text between back-to-back thinking blocks (space separator)", () => {
|
||||
const chunks = feedAll(["<think>a</think> <think>b</think>"]);
|
||||
expect(reasoningContent(chunks)).toBe("ab");
|
||||
expect(textContent(chunks)).toBe(" ");
|
||||
});
|
||||
|
||||
it("thinking block at very end with no trailing text", () => {
|
||||
const chunks = feedAll(["hello<think>thought</think>"]);
|
||||
expect(textContent(chunks)).toBe("hello");
|
||||
expect(reasoningContent(chunks)).toBe("thought");
|
||||
});
|
||||
|
||||
it("only whitespace inside thinking block", () => {
|
||||
const chunks = feedAll(["<thinking> </thinking>done"]);
|
||||
expect(reasoningContent(chunks)).toBe(" ");
|
||||
expect(textContent(chunks)).toBe("done");
|
||||
});
|
||||
|
||||
it("newlines inside thinking block", () => {
|
||||
const chunks = feedAll(["<thinking>line1\nline2\nline3</thinking>done"]);
|
||||
expect(reasoningContent(chunks)).toBe("line1\nline2\nline3");
|
||||
});
|
||||
|
||||
it("< inside text that partially matches tag prefix then fails", () => {
|
||||
const chunks = feedAll(["hello <thinki world"]);
|
||||
expect(textContent(chunks)).toBe("hello <thinki world");
|
||||
});
|
||||
|
||||
it("closing tag without opening — </thinking> as plain text", () => {
|
||||
const chunks = feedAll(["hello</thinking>world"]);
|
||||
const text = textContent(chunks);
|
||||
expect(text).toContain("hello");
|
||||
expect(text).toContain("world");
|
||||
});
|
||||
|
||||
it("unicode content inside thinking block", () => {
|
||||
const chunks = feedAll(["<think>日本語テスト 🤔</think>結果"]);
|
||||
expect(reasoningContent(chunks)).toBe("日本語テスト 🤔");
|
||||
expect(textContent(chunks)).toBe("結果");
|
||||
});
|
||||
|
||||
it("very long tag-like prefix that doesn't match", () => {
|
||||
// "<thinkingaboutlife>" does NOT match "<thinking>" — char 9 is 'a' vs '>'.
|
||||
// The "<" is emitted as text, then everything else is plain text.
|
||||
const chunks = feedAll(["<thinkingaboutlife>not a tag</thinkingaboutlife>"]);
|
||||
const text = textContent(chunks);
|
||||
expect(text).toContain("<thinkingaboutlife>");
|
||||
expect(reasoningContent(chunks)).toBe("");
|
||||
});
|
||||
|
||||
it("three deltas splitting mid-tag and mid-content", () => {
|
||||
const chunks = feedAll(["<thi", "nking>con", "tent</thinking>done"]);
|
||||
expect(reasoningContent(chunks)).toBe("content");
|
||||
expect(textContent(chunks)).toBe("done");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user