mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 957f5639ba | |||
| 75adad8018 | |||
| ab3075feb9 | |||
| 5588000e93 | |||
| 68fef8745e | |||
| e432f7c009 |
@@ -33,16 +33,35 @@ evaluation.
|
||||
- **Warning**: Do not lose test fidelity by making prompts too direct/easy.
|
||||
- **Primary Fix Trigger**: Adjust tool descriptions, system prompts
|
||||
(`snippets.ts`), or **modules that contribute to the prompt template**.
|
||||
- Fixes should generally try to improve the prompt `@packages/core/src/prompts/snippets.ts` first.
|
||||
- **Instructional Generality**: Changes to the system prompt should aim to be as general as possible while still accomplishing the goal. Specificity should be added only as needed.
|
||||
- **Principle**: Instead of creating "forbidden lists" for specific syntax (e.g., "Don't use `Object.create()`"), formulate a broader engineering principle that covers the underlying issue (e.g., "Prioritize explicit composition over hidden prototype manipulation"). This improves steerability across a wider range of similar scenarios.
|
||||
- *Low Specificity*: "Follow ecosystem best practices"
|
||||
- *Medium Specificity*: "Utilize OOP and functional best practices, as applicable"
|
||||
- *High Specificity*: Provide ecosystem-specific hints as examples of a broader principle rather than direct instructions. e.g., "NEVER use hacks like bypassing the type system or employing 'hidden' logic (e.g.: reflection, prototype manipulation). Instead, use explicit and idiomatic language features (e.g.: type guards, explicit class instantiation, or object spread) that maintain structural integrity."
|
||||
- **Prompt Simplification**: Once the test is passing, use `ask_user` to determine if prompt simplification is desired.
|
||||
- **Criteria**: Simplification should be attempted only if there are related clauses that can be de-duplicated or reparented under a single heading.
|
||||
- **Verification**: As part of simplification, you MUST identify and run any behavioral eval tests that might be affected by the changes to ensure no regressions are introduced.
|
||||
- Test fixes should not "cheat" by changing a test's `GEMINI.md` file or by updating the test's prompt to instruct it to not repro the bug.
|
||||
- Fixes should generally try to improve the prompt
|
||||
`@packages/core/src/prompts/snippets.ts` first.
|
||||
- **Instructional Generality**: Changes to the system prompt should aim to
|
||||
be as general as possible while still accomplishing the goal. Specificity
|
||||
should be added only as needed.
|
||||
- **Principle**: Instead of creating "forbidden lists" for specific syntax
|
||||
(e.g., "Don't use `Object.create()`"), formulate a broader engineering
|
||||
principle that covers the underlying issue (e.g., "Prioritize explicit
|
||||
composition over hidden prototype manipulation"). This improves
|
||||
steerability across a wider range of similar scenarios.
|
||||
- _Low Specificity_: "Follow ecosystem best practices"
|
||||
- _Medium Specificity_: "Utilize OOP and functional best practices, as
|
||||
applicable"
|
||||
- _High Specificity_: Provide ecosystem-specific hints as examples of a
|
||||
broader principle rather than direct instructions. e.g., "NEVER use
|
||||
hacks like bypassing the type system or employing 'hidden' logic (e.g.:
|
||||
reflection, prototype manipulation). Instead, use explicit and idiomatic
|
||||
language features (e.g.: type guards, explicit class instantiation, or
|
||||
object spread) that maintain structural integrity."
|
||||
- **Prompt Simplification**: Once the test is passing, use `ask_user` to
|
||||
determine if prompt simplification is desired.
|
||||
- **Criteria**: Simplification should be attempted only if there are
|
||||
related clauses that can be de-duplicated or reparented under a single
|
||||
heading.
|
||||
- **Verification**: As part of simplification, you MUST identify and run
|
||||
any behavioral eval tests that might be affected by the changes to
|
||||
ensure no regressions are introduced.
|
||||
- Test fixes should not "cheat" by changing a test's `GEMINI.md` file or by
|
||||
updating the test's prompt to instruct it to not repro the bug.
|
||||
- **Warning**: Prompts have multiple configurations; ensure your fix targets
|
||||
the correct config for the model in question.
|
||||
4. **Architecture Options**: If prompt or instruction tuning triggers no
|
||||
|
||||
@@ -36,9 +36,11 @@ import {
|
||||
type ConfirmationRequest,
|
||||
type PermissionConfirmationRequest,
|
||||
type QuotaStats,
|
||||
MessageType,
|
||||
StreamingState,
|
||||
type HistoryItemInfo,
|
||||
} from './types.js';
|
||||
import { checkPermissions } from './hooks/atCommandProcessor.js';
|
||||
import { MessageType, StreamingState } from './types.js';
|
||||
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
|
||||
import { MouseProvider } from './contexts/MouseContext.js';
|
||||
import { ScrollProvider } from './contexts/ScrollProvider.js';
|
||||
@@ -51,6 +53,7 @@ import {
|
||||
type UserTierId,
|
||||
type GeminiUserTier,
|
||||
type UserFeedbackPayload,
|
||||
type HookSystemMessagePayload,
|
||||
type AgentDefinition,
|
||||
type ApprovalMode,
|
||||
IdeClient,
|
||||
@@ -2111,7 +2114,19 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
};
|
||||
|
||||
const handleHookSystemMessage = (payload: HookSystemMessagePayload) => {
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: payload.message,
|
||||
source: payload.hookName,
|
||||
} as HistoryItemInfo,
|
||||
Date.now(),
|
||||
);
|
||||
};
|
||||
|
||||
coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback);
|
||||
coreEvents.on(CoreEvent.HookSystemMessage, handleHookSystemMessage);
|
||||
|
||||
// Flush any messages that happened during startup before this component
|
||||
// mounted.
|
||||
@@ -2119,6 +2134,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
|
||||
coreEvents.off(CoreEvent.HookSystemMessage, handleHookSystemMessage);
|
||||
};
|
||||
}, [historyManager]);
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
<InfoMessage
|
||||
text={itemForDisplay.text}
|
||||
secondaryText={itemForDisplay.secondaryText}
|
||||
source={itemForDisplay.source}
|
||||
icon={itemForDisplay.icon}
|
||||
color={itemForDisplay.color}
|
||||
marginBottom={itemForDisplay.marginBottom}
|
||||
|
||||
@@ -66,7 +66,7 @@ vi.mock('../hooks/useAlternateBuffer.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useConfirmingTool.js', () => ({
|
||||
useConfirmingTool: vi.fn(),
|
||||
useConfirmingTool: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock('./AppHeader.js', () => ({
|
||||
@@ -882,6 +882,82 @@ describe('MainContent', () => {
|
||||
expect(output).toContain('Here is your answer.');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('bypasses suppression for the current turn during confirmation, but keeps old turns suppressed', async () => {
|
||||
const { useConfirmingTool } = await import(
|
||||
'../hooks/useConfirmingTool.js'
|
||||
);
|
||||
vi.mocked(useConfirmingTool).mockReturnValue({
|
||||
tool: {
|
||||
callId: 'call-1',
|
||||
name: 'some_tool',
|
||||
status: CoreToolCallStatus.AwaitingApproval,
|
||||
confirmationDetails: {
|
||||
type: 'confirm_shell_command' as const,
|
||||
command: 'echo "hello"',
|
||||
},
|
||||
},
|
||||
index: 0,
|
||||
total: 1,
|
||||
} as unknown as ConfirmingToolState);
|
||||
|
||||
mockUseSettings.mockReturnValue(settingsWithNarration);
|
||||
const uiState = {
|
||||
...defaultMockUiState,
|
||||
history: [
|
||||
// Previous turn: should remain suppressed
|
||||
{ id: 10, type: 'user' as const, text: 'Old Turn' },
|
||||
{ id: 11, type: 'gemini' as const, text: 'Old narration' },
|
||||
{
|
||||
id: 12,
|
||||
type: 'tool_group' as const,
|
||||
tools: [
|
||||
{
|
||||
callId: 'old',
|
||||
name: 'ls',
|
||||
status: CoreToolCallStatus.Success,
|
||||
description: '',
|
||||
resultDisplay: '',
|
||||
confirmationDetails: undefined,
|
||||
} as IndividualToolCallDisplay,
|
||||
],
|
||||
},
|
||||
// Current turn: should be bypassed (shown)
|
||||
{ id: 20, type: 'user' as const, text: 'Current Turn' },
|
||||
{ id: 21, type: 'gemini' as const, text: 'Current narration' },
|
||||
],
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
type: 'tool_group' as const,
|
||||
tools: [
|
||||
{
|
||||
callId: 'call-1',
|
||||
name: 'some_tool',
|
||||
status: CoreToolCallStatus.AwaitingApproval,
|
||||
description: '',
|
||||
resultDisplay: '',
|
||||
confirmationDetails: undefined,
|
||||
} as IndividualToolCallDisplay,
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<MainContent />,
|
||||
{
|
||||
uiState: uiState as Partial<UIState>,
|
||||
settings: settingsWithNarration,
|
||||
},
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
// Old narration should STILL be suppressed
|
||||
expect(output).not.toContain('Old narration');
|
||||
// Current narration should be VISIBLE because of the bypass
|
||||
expect(output).toContain('Current narration');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders multiple thinking messages sequentially correctly', async () => {
|
||||
|
||||
@@ -123,20 +123,32 @@ export const MainContent = () => {
|
||||
|
||||
// Rule 2: Suppress text in intermediate turns (turns containing non-topic
|
||||
// tools) to hide mechanical narration.
|
||||
if (turnIsIntermediate) {
|
||||
|
||||
const isCurrentTurn = i > lastUserPromptIndex;
|
||||
// Scoping the bypass to the current turn prevents old narration from reappearing.
|
||||
// This logic affects the rendered height of the history list by conditionally hiding turns.
|
||||
const bypassSuppression = isCurrentTurn && showConfirmationQueue;
|
||||
|
||||
if (turnIsIntermediate && !bypassSuppression) {
|
||||
flags[i] = true;
|
||||
}
|
||||
|
||||
// Rule 3: Suppress text that precedes a topic tool in the same turn,
|
||||
// as the topic tool "replaces" it.
|
||||
if (hasTopicToolInTurn) {
|
||||
if (hasTopicToolInTurn && !bypassSuppression) {
|
||||
flags[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}, [uiState.history, pendingHistoryItems, topicUpdateNarrationEnabled]);
|
||||
}, [
|
||||
uiState.history,
|
||||
pendingHistoryItems,
|
||||
topicUpdateNarrationEnabled,
|
||||
showConfirmationQueue,
|
||||
lastUserPromptIndex,
|
||||
]);
|
||||
|
||||
const augmentedHistory = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
|
||||
interface InfoMessageProps {
|
||||
text: string;
|
||||
secondaryText?: string;
|
||||
source?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
marginBottom?: number;
|
||||
@@ -20,6 +21,7 @@ interface InfoMessageProps {
|
||||
export const InfoMessage: React.FC<InfoMessageProps> = ({
|
||||
text,
|
||||
secondaryText,
|
||||
source,
|
||||
icon,
|
||||
color,
|
||||
marginBottom,
|
||||
@@ -40,6 +42,9 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({
|
||||
{index === text.split('\n').length - 1 && secondaryText && (
|
||||
<Text color={theme.text.secondary}> {secondaryText}</Text>
|
||||
)}
|
||||
{index === text.split('\n').length - 1 && source && (
|
||||
<Text color={theme.text.secondary}> [{source}]</Text>
|
||||
)}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -174,6 +174,7 @@ export type HistoryItemInfo = HistoryItemBase & {
|
||||
type: 'info';
|
||||
text: string;
|
||||
secondaryText?: string;
|
||||
source?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
marginBottom?: number;
|
||||
|
||||
@@ -26,66 +26,49 @@ describe('skillUtils', () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
const itif = (condition: boolean) => (condition ? it : it.skip);
|
||||
|
||||
describe('linkSkill', () => {
|
||||
// TODO: issue 19388 - Enable linkSkill tests on Windows
|
||||
itif(process.platform !== 'win32')(
|
||||
'should successfully link from a local directory',
|
||||
async () => {
|
||||
// Create a mock skill directory
|
||||
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
|
||||
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
|
||||
await fs.mkdir(skillSubDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillSubDir, 'SKILL.md'),
|
||||
'---\nname: test-skill\ndescription: test\n---\nbody',
|
||||
);
|
||||
it('should successfully link from a local directory', async () => {
|
||||
// Create a mock skill directory
|
||||
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
|
||||
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
|
||||
await fs.mkdir(skillSubDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillSubDir, 'SKILL.md'),
|
||||
'---\nname: test-skill\ndescription: test\n---\nbody',
|
||||
);
|
||||
|
||||
const skills = await linkSkill(
|
||||
mockSkillSourceDir,
|
||||
'workspace',
|
||||
() => {},
|
||||
);
|
||||
expect(skills.length).toBe(1);
|
||||
expect(skills[0].name).toBe('test-skill');
|
||||
const skills = await linkSkill(mockSkillSourceDir, 'workspace', () => {});
|
||||
expect(skills.length).toBe(1);
|
||||
expect(skills[0].name).toBe('test-skill');
|
||||
|
||||
const linkedPath = path.join(tempDir, '.gemini/skills', 'test-skill');
|
||||
const stats = await fs.lstat(linkedPath);
|
||||
expect(stats.isSymbolicLink()).toBe(true);
|
||||
const linkedPath = path.join(tempDir, '.gemini/skills', 'test-skill');
|
||||
const stats = await fs.lstat(linkedPath);
|
||||
expect(stats.isSymbolicLink()).toBe(true);
|
||||
|
||||
const linkTarget = await fs.readlink(linkedPath);
|
||||
expect(path.resolve(linkTarget)).toBe(path.resolve(skillSubDir));
|
||||
},
|
||||
);
|
||||
const linkTarget = await fs.readlink(linkedPath);
|
||||
expect(path.resolve(linkTarget)).toBe(path.resolve(skillSubDir));
|
||||
});
|
||||
|
||||
itif(process.platform !== 'win32')(
|
||||
'should overwrite existing skill at destination',
|
||||
async () => {
|
||||
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
|
||||
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
|
||||
await fs.mkdir(skillSubDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillSubDir, 'SKILL.md'),
|
||||
'---\nname: test-skill\ndescription: test\n---\nbody',
|
||||
);
|
||||
it('should overwrite existing skill at destination', async () => {
|
||||
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
|
||||
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
|
||||
await fs.mkdir(skillSubDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillSubDir, 'SKILL.md'),
|
||||
'---\nname: test-skill\ndescription: test\n---\nbody',
|
||||
);
|
||||
|
||||
const targetDir = path.join(tempDir, '.gemini/skills');
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
const existingPath = path.join(targetDir, 'test-skill');
|
||||
await fs.mkdir(existingPath);
|
||||
const targetDir = path.join(tempDir, '.gemini/skills');
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
const existingPath = path.join(targetDir, 'test-skill');
|
||||
await fs.mkdir(existingPath);
|
||||
|
||||
const skills = await linkSkill(
|
||||
mockSkillSourceDir,
|
||||
'workspace',
|
||||
() => {},
|
||||
);
|
||||
expect(skills.length).toBe(1);
|
||||
const skills = await linkSkill(mockSkillSourceDir, 'workspace', () => {});
|
||||
expect(skills.length).toBe(1);
|
||||
|
||||
const stats = await fs.lstat(existingPath);
|
||||
expect(stats.isSymbolicLink()).toBe(true);
|
||||
},
|
||||
);
|
||||
const stats = await fs.lstat(existingPath);
|
||||
expect(stats.isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('should abort linking if consent is rejected', async () => {
|
||||
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
|
||||
@@ -237,39 +220,40 @@ describe('skillUtils', () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
itif(process.platform !== 'win32')(
|
||||
'should successfully uninstall a skill even if its name was updated after linking',
|
||||
async () => {
|
||||
// 1. Create source skill
|
||||
const sourceDir = path.join(tempDir, 'source-skill');
|
||||
await fs.mkdir(sourceDir, { recursive: true });
|
||||
const skillMdPath = path.join(sourceDir, 'SKILL.md');
|
||||
await fs.writeFile(
|
||||
skillMdPath,
|
||||
'---\nname: original-name\ndescription: test\n---\nbody',
|
||||
);
|
||||
it('should successfully uninstall a skill even if its name was updated after linking', async () => {
|
||||
// 1. Create source skill
|
||||
const sourceDir = path.join(tempDir, 'source-skill');
|
||||
await fs.mkdir(sourceDir, { recursive: true });
|
||||
const skillMdPath = path.join(sourceDir, 'SKILL.md');
|
||||
await fs.writeFile(
|
||||
skillMdPath,
|
||||
'---\nname: original-name\ndescription: test\n---\nbody',
|
||||
);
|
||||
|
||||
// 2. Link it
|
||||
const skillsDir = path.join(tempDir, '.gemini/skills');
|
||||
await fs.mkdir(skillsDir, { recursive: true });
|
||||
const destPath = path.join(skillsDir, 'original-name');
|
||||
await fs.symlink(sourceDir, destPath, 'dir');
|
||||
// 2. Link it
|
||||
const skillsDir = path.join(tempDir, '.gemini/skills');
|
||||
await fs.mkdir(skillsDir, { recursive: true });
|
||||
const destPath = path.join(skillsDir, 'original-name');
|
||||
await fs.symlink(
|
||||
sourceDir,
|
||||
destPath,
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
|
||||
// 3. Update name in source
|
||||
await fs.writeFile(
|
||||
skillMdPath,
|
||||
'---\nname: updated-name\ndescription: test\n---\nbody',
|
||||
);
|
||||
// 3. Update name in source
|
||||
await fs.writeFile(
|
||||
skillMdPath,
|
||||
'---\nname: updated-name\ndescription: test\n---\nbody',
|
||||
);
|
||||
|
||||
// 4. Uninstall by NEW name (this is the bug fix)
|
||||
const result = await uninstallSkill('updated-name', 'user');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.location).toBe(destPath);
|
||||
// 4. Uninstall by NEW name (this is the bug fix)
|
||||
const result = await uninstallSkill('updated-name', 'user');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.location).toBe(destPath);
|
||||
|
||||
const exists = await fs.lstat(destPath).catch(() => null);
|
||||
expect(exists).toBeNull();
|
||||
},
|
||||
);
|
||||
const exists = await fs.lstat(destPath).catch(() => null);
|
||||
expect(exists).toBeNull();
|
||||
});
|
||||
|
||||
it('should successfully uninstall a skill by directory name if metadata is missing (fallback)', async () => {
|
||||
const skillsDir = path.join(tempDir, '.gemini/skills');
|
||||
|
||||
@@ -248,7 +248,13 @@ export async function linkSkill(
|
||||
await fs.rm(destPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
await fs.symlink(skillSourceDir, destPath, 'dir');
|
||||
// Use 'junction' on Windows to avoid EPERM errors — junctions don't
|
||||
// require elevated privileges or Developer Mode (fixes #24816)
|
||||
await fs.symlink(
|
||||
skillSourceDir,
|
||||
destPath,
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
linkedSkills.push({ name: skillName, location: destPath });
|
||||
}
|
||||
|
||||
|
||||
@@ -908,8 +908,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly acceptRawOutputRisk: boolean;
|
||||
private readonly dynamicModelConfiguration: boolean;
|
||||
private pendingIncludeDirectories: string[];
|
||||
private readonly enableHooks: boolean;
|
||||
private readonly enableHooksUI: boolean;
|
||||
private readonly enableHooks: boolean;
|
||||
|
||||
private hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
|
||||
private projectHooks:
|
||||
|
||||
@@ -597,6 +597,21 @@ export class GeminiChat {
|
||||
);
|
||||
}
|
||||
|
||||
if (beforeModelResult.modifiedModel) {
|
||||
modelToUse = resolveModel(
|
||||
beforeModelResult.modifiedModel,
|
||||
useGemini3_1,
|
||||
useGemini3_1FlashLite,
|
||||
false,
|
||||
hasAccessToPreview,
|
||||
this.context.config,
|
||||
);
|
||||
lastModelToUse = modelToUse;
|
||||
// Re-evaluate contentsToUse based on the new model's feature support
|
||||
contentsToUse = supportsModernFeatures(modelToUse)
|
||||
? [...contentsForPreviewModel]
|
||||
: [...requestContents];
|
||||
}
|
||||
if (beforeModelResult.modifiedConfig) {
|
||||
Object.assign(config, beforeModelResult.modifiedConfig);
|
||||
}
|
||||
|
||||
@@ -458,6 +458,15 @@ export class HookEventHandler {
|
||||
);
|
||||
|
||||
logHookCall(this.context.config, hookCallEvent);
|
||||
|
||||
// Emit structured system message event for UI display
|
||||
if (result.output?.systemMessage && result.outputFormat === 'json') {
|
||||
coreEvents.emitHookSystemMessage({
|
||||
hookName,
|
||||
eventName,
|
||||
message: result.output.systemMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Log individual errors
|
||||
|
||||
@@ -204,7 +204,11 @@ describe('HookRunner', () => {
|
||||
};
|
||||
|
||||
it('should execute command hook successfully', async () => {
|
||||
const mockOutput = { decision: 'allow', reason: 'All good' };
|
||||
const mockOutput = {
|
||||
decision: 'allow',
|
||||
reason: 'All good',
|
||||
format: 'json',
|
||||
};
|
||||
|
||||
// Mock successful execution
|
||||
mockSpawn.mockStdoutOn.mockImplementation(
|
||||
@@ -623,6 +627,7 @@ describe('HookRunner', () => {
|
||||
hookSpecificOutput: {
|
||||
additionalContext: 'Context from hook 1',
|
||||
},
|
||||
format: 'json',
|
||||
};
|
||||
|
||||
let hookCallCount = 0;
|
||||
@@ -803,6 +808,7 @@ describe('HookRunner', () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.exitCode).toBe(0);
|
||||
// Should convert plain text to structured output
|
||||
expect(result.outputFormat).toBe('text');
|
||||
expect(result.output).toEqual({
|
||||
decision: 'allow',
|
||||
systemMessage: invalidJson,
|
||||
@@ -835,6 +841,7 @@ describe('HookRunner', () => {
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.outputFormat).toBe('text');
|
||||
expect(result.output).toEqual({
|
||||
decision: 'allow',
|
||||
systemMessage: malformedJson,
|
||||
@@ -868,6 +875,7 @@ describe('HookRunner', () => {
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.outputFormat).toBe('text');
|
||||
expect(result.output).toEqual({
|
||||
decision: 'allow',
|
||||
systemMessage: `Warning: ${invalidJson}`,
|
||||
@@ -901,6 +909,7 @@ describe('HookRunner', () => {
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.exitCode).toBe(2);
|
||||
expect(result.outputFormat).toBe('text');
|
||||
expect(result.output).toEqual({
|
||||
decision: 'deny',
|
||||
reason: invalidJson,
|
||||
@@ -936,7 +945,11 @@ describe('HookRunner', () => {
|
||||
});
|
||||
|
||||
it('should handle double-encoded JSON string', async () => {
|
||||
const mockOutput = { decision: 'allow', reason: 'All good' };
|
||||
const mockOutput = {
|
||||
decision: 'allow',
|
||||
reason: 'All good',
|
||||
format: 'json',
|
||||
};
|
||||
const doubleEncodedJson = JSON.stringify(JSON.stringify(mockOutput));
|
||||
|
||||
mockSpawn.mockStdoutOn.mockImplementation(
|
||||
|
||||
@@ -447,6 +447,7 @@ export class HookRunner {
|
||||
|
||||
// Parse output
|
||||
let output: HookOutput | undefined;
|
||||
let outputFormat: 'json' | 'text' | undefined;
|
||||
|
||||
const textToParse = stdout.trim() || stderr.trim();
|
||||
if (textToParse) {
|
||||
@@ -460,6 +461,7 @@ export class HookRunner {
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
output = parsed as HookOutput;
|
||||
outputFormat = 'json';
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, convert plain text to structured output
|
||||
@@ -467,6 +469,7 @@ export class HookRunner {
|
||||
textToParse,
|
||||
exitCode || EXIT_CODE_SUCCESS,
|
||||
);
|
||||
outputFormat = 'text';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,6 +478,7 @@ export class HookRunner {
|
||||
eventName,
|
||||
success: exitCode === EXIT_CODE_SUCCESS,
|
||||
output,
|
||||
outputFormat,
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode: exitCode || EXIT_CODE_SUCCESS,
|
||||
@@ -523,7 +527,7 @@ export class HookRunner {
|
||||
exitCode: number,
|
||||
): HookOutput {
|
||||
if (exitCode === EXIT_CODE_SUCCESS) {
|
||||
// Success - treat as system message or additional context
|
||||
// Success
|
||||
return {
|
||||
decision: 'allow',
|
||||
systemMessage: text,
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface BeforeModelHookResult {
|
||||
reason?: string;
|
||||
/** Synthetic response to return instead of calling the model (if blocked) */
|
||||
syntheticResponse?: GenerateContentResponse;
|
||||
/** Modified model override (if not blocked) */
|
||||
modifiedModel?: string;
|
||||
/** Modified config (if not blocked) */
|
||||
modifiedConfig?: GenerateContentConfig;
|
||||
/** Modified contents (if not blocked) */
|
||||
@@ -292,6 +294,7 @@ export class HookSystem {
|
||||
beforeModelOutput.applyLLMRequestModifications(llmRequest);
|
||||
return {
|
||||
blocked: false,
|
||||
modifiedModel: modifiedRequest?.model,
|
||||
modifiedConfig: modifiedRequest?.config,
|
||||
modifiedContents: modifiedRequest?.contents,
|
||||
};
|
||||
|
||||
@@ -734,6 +734,8 @@ export interface HookExecutionResult {
|
||||
exitCode?: number;
|
||||
duration: number;
|
||||
error?: Error;
|
||||
/** The format of the output provided by the hook */
|
||||
outputFormat?: 'json' | 'text';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -109,6 +109,13 @@ export interface HookEndPayload extends HookPayload {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for the 'hook-system-message' event.
|
||||
*/
|
||||
export interface HookSystemMessagePayload extends HookPayload {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for the 'retry-attempt' event.
|
||||
*/
|
||||
@@ -183,6 +190,7 @@ export enum CoreEvent {
|
||||
SettingsChanged = 'settings-changed',
|
||||
HookStart = 'hook-start',
|
||||
HookEnd = 'hook-end',
|
||||
HookSystemMessage = 'hook-system-message',
|
||||
AgentsRefreshed = 'agents-refreshed',
|
||||
AdminSettingsChanged = 'admin-settings-changed',
|
||||
RetryAttempt = 'retry-attempt',
|
||||
@@ -217,6 +225,7 @@ export interface CoreEvents extends ExtensionEvents {
|
||||
[CoreEvent.SettingsChanged]: never[];
|
||||
[CoreEvent.HookStart]: [HookStartPayload];
|
||||
[CoreEvent.HookEnd]: [HookEndPayload];
|
||||
[CoreEvent.HookSystemMessage]: [HookSystemMessagePayload];
|
||||
[CoreEvent.AgentsRefreshed]: never[];
|
||||
[CoreEvent.AdminSettingsChanged]: never[];
|
||||
[CoreEvent.RetryAttempt]: [RetryAttemptPayload];
|
||||
@@ -339,6 +348,13 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
|
||||
this.emit(CoreEvent.HookEnd, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies subscribers that a hook has provided a system message.
|
||||
*/
|
||||
emitHookSystemMessage(payload: HookSystemMessagePayload): void {
|
||||
this.emit(CoreEvent.HookSystemMessage, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies subscribers that agents have been refreshed.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user