mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-30 11:41:00 -07:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9e2c87131 | |||
| 3b0906f319 | |||
| cd7d91f35e | |||
| 6020551d1f | |||
| 8e9961a791 | |||
| e046cbd8e0 | |||
| b810e57ef1 | |||
| ecc9e50a1f | |||
| fd321abd3d | |||
| 2bb161b36f | |||
| 565851f2d5 | |||
| 593c33f927 | |||
| f85299717a | |||
| 0afe5117a4 | |||
| f853d2f9da | |||
| 0f37dd1d78 | |||
| 87e9491e78 | |||
| 2a8918c72f | |||
| 6975224e45 | |||
| d3a7fc39e3 | |||
| 3b317f9198 | |||
| 6a80311a37 | |||
| 6fb7bcf868 | |||
| 56656dfbc9 | |||
| 93322430a0 | |||
| 25c5d44678 | |||
| 2dfe237738 | |||
| f77e4716fa | |||
| 3e0b8aa958 | |||
| b268e93a1d | |||
| 218adcd6c3 |
@@ -38,7 +38,6 @@ These commands are available within the interactive REPL.
|
||||
| `/mcp reload` | Restart and reload MCP servers |
|
||||
| `/extensions reload` | Reload all active extensions |
|
||||
| `/help` | Show help for all commands |
|
||||
| `/loop` | Schedule a recurring task |
|
||||
| `/quit` | Exit the interactive session |
|
||||
|
||||
## CLI Options
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
# Scheduled tasks
|
||||
|
||||
Scheduled tasks let you run prompts repeatedly, poll for status, or set one-time
|
||||
reminders within a Gemini CLI session. You can use them to check the status of a
|
||||
deployment, monitor a long-running build, or remind yourself to perform a task
|
||||
later in your workflow.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
> Scheduled tasks are session-scoped. They only run while your Gemini CLI
|
||||
> session is open and are cleared when you exit the CLI. For persistent
|
||||
> scheduling, use your operating system's native scheduling tools (like `cron`
|
||||
> or Task Scheduler).
|
||||
|
||||
## Schedule a recurring prompt with /loop
|
||||
|
||||
The `/loop` command is the fastest way to schedule a recurring prompt. You can
|
||||
provide an optional interval and a prompt, and Gemini CLI schedules the task to
|
||||
run in the background.
|
||||
|
||||
```text
|
||||
/loop 5m check if the deployment finished
|
||||
```
|
||||
|
||||
If you don't provide an interval, the command defaults to 10 minutes.
|
||||
|
||||
### Interval syntax
|
||||
|
||||
Intervals use a simple numeric value followed by a unit character. Supported
|
||||
units are `s` for seconds, `m` for minutes, `h` for hours, and `d` for days.
|
||||
|
||||
| Form | Example | Interval |
|
||||
| :------------ | :-------------------------- | :--------------- |
|
||||
| Leading token | `/loop 30m check the build` | Every 30 minutes |
|
||||
| No interval | `/loop check the build` | Every 10 minutes |
|
||||
|
||||
### Loop over another command
|
||||
|
||||
A scheduled prompt can be a slash command or a skill invocation. This lets you
|
||||
automate existing workflows.
|
||||
|
||||
```text
|
||||
/loop 20m /git:status
|
||||
```
|
||||
|
||||
Gemini CLI executes the command as if you had typed it yourself.
|
||||
|
||||
## Set a one-time reminder
|
||||
|
||||
To set a single reminder, describe what you want in natural language. Gemini CLI
|
||||
uses its internal scheduling tools to set a one-time task that removes itself
|
||||
after firing.
|
||||
|
||||
```text
|
||||
In 15 minutes, remind me to push my changes
|
||||
```
|
||||
|
||||
```text
|
||||
Remind me in 1h to check the integration tests
|
||||
```
|
||||
|
||||
## Manage scheduled tasks
|
||||
|
||||
You can ask Gemini CLI to list or cancel your active tasks using natural
|
||||
language.
|
||||
|
||||
```text
|
||||
What scheduled tasks do I have?
|
||||
```
|
||||
|
||||
```text
|
||||
Cancel the deployment check task
|
||||
```
|
||||
|
||||
Under the hood, the agent uses these tools to manage your schedule:
|
||||
|
||||
| Tool | Purpose |
|
||||
| :---------------------- | :------------------------------------------------- |
|
||||
| `schedule_task` | Schedules a new recurring or one-time task. |
|
||||
| `list_scheduled_tasks` | Lists all active tasks with their IDs and prompts. |
|
||||
| `cancel_scheduled_task` | Cancels a specific task using its ID. |
|
||||
|
||||
## How scheduled tasks run
|
||||
|
||||
Scheduled tasks trigger only when Gemini CLI is idle.
|
||||
|
||||
- **Non-disruptive:** If a task becomes due while the agent is busy generating a
|
||||
response or executing a tool, the prompt is queued.
|
||||
- **Sequential:** The queued prompt executes immediately after the current turn
|
||||
finishes.
|
||||
- **Shared context:** Scheduled tasks run within your active session and have
|
||||
access to the full conversation history and any files you have added to the
|
||||
context.
|
||||
|
||||
## Limitations
|
||||
|
||||
Session-scoped scheduling has the following constraints:
|
||||
|
||||
- **Active session required:** Tasks only fire while Gemini CLI is running.
|
||||
Closing your terminal or exiting the session cancels all tasks.
|
||||
- **Shared context window:** Every task execution adds to your session's history
|
||||
and consumes tokens in the context window. High-frequency loops may trigger
|
||||
[context compression](./token-caching.md) sooner.
|
||||
- **No persistence:** Restarting Gemini CLI clears all tasks.
|
||||
- **Simple intervals:** Only simple time intervals are supported (e.g., `5m`).
|
||||
Complex cron expressions (e.g., `0 9 * * 1-5`) are not supported.
|
||||
@@ -98,7 +98,6 @@
|
||||
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
|
||||
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
|
||||
{ "label": "Headless mode", "slug": "docs/cli/headless" },
|
||||
{ "label": "Scheduled tasks", "slug": "docs/cli/scheduled-tasks" },
|
||||
{
|
||||
"label": "Git worktrees",
|
||||
"badge": "🔬",
|
||||
|
||||
@@ -988,6 +988,7 @@ export async function loadCliConfig(
|
||||
modelSteering: settings.experimental?.modelSteering,
|
||||
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
|
||||
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
||||
useAgentProtocol: settings.experimental?.useAgentProtocol,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
ideMode,
|
||||
|
||||
@@ -2108,6 +2108,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable dynamic model configuration (definitions, resolutions, and chains) via settings.',
|
||||
showInDialog: false,
|
||||
},
|
||||
useAgentProtocol: {
|
||||
type: 'boolean',
|
||||
label: 'Use Agent Protocol',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable the experimental unified agent for interactive mode.',
|
||||
showInDialog: false,
|
||||
},
|
||||
gemmaModelRouter: {
|
||||
type: 'object',
|
||||
label: 'Gemma Model Router',
|
||||
|
||||
@@ -77,6 +77,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
uiTelemetryService: {
|
||||
getMetrics: vi.fn(),
|
||||
},
|
||||
LegacyAgentSession: original.LegacyAgentSession,
|
||||
geminiPartsToContentParts: original.geminiPartsToContentParts,
|
||||
coreEvents: mockCoreEvents,
|
||||
createWorkingStdio: vi.fn(() => ({
|
||||
stdout: process.stdout,
|
||||
@@ -108,6 +110,8 @@ describe('runNonInteractive', () => {
|
||||
sendMessageStream: Mock;
|
||||
resumeChat: Mock;
|
||||
getChatRecordingService: Mock;
|
||||
getChat: Mock;
|
||||
getCurrentSequenceModel: Mock;
|
||||
};
|
||||
const MOCK_SESSION_METRICS: SessionMetrics = {
|
||||
models: {},
|
||||
@@ -163,6 +167,8 @@ describe('runNonInteractive', () => {
|
||||
recordMessageTokens: vi.fn(),
|
||||
recordToolCalls: vi.fn(),
|
||||
})),
|
||||
getChat: vi.fn(() => ({ recordCompletedToolCalls: vi.fn() })),
|
||||
getCurrentSequenceModel: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
|
||||
mockConfig = {
|
||||
@@ -268,6 +274,54 @@ describe('runNonInteractive', () => {
|
||||
// so we no longer expect shutdownTelemetry to be called directly here
|
||||
});
|
||||
|
||||
it('should stream the specific stream started by send', async () => {
|
||||
const { LegacyAgentSession } = await import('@google/gemini-cli-core');
|
||||
const streamSpy = vi.spyOn(LegacyAgentSession.prototype, 'stream');
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Hello again' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Test input',
|
||||
prompt_id: 'prompt-id-stream',
|
||||
});
|
||||
|
||||
expect(streamSpy).toHaveBeenCalledWith({ streamId: expect.any(String) });
|
||||
});
|
||||
|
||||
it('fails fast if the session acknowledges a message send without a stream', async () => {
|
||||
const { LegacyAgentSession } = await import('@google/gemini-cli-core');
|
||||
const sendSpy = vi
|
||||
.spyOn(LegacyAgentSession.prototype, 'send')
|
||||
.mockResolvedValue({ streamId: null });
|
||||
const streamSpy = vi.spyOn(LegacyAgentSession.prototype, 'stream');
|
||||
|
||||
await expect(
|
||||
runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Test input',
|
||||
prompt_id: 'prompt-id-null-stream',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'LegacyAgentSession.send() unexpectedly returned no stream for a message send.',
|
||||
);
|
||||
|
||||
expect(streamSpy).not.toHaveBeenCalled();
|
||||
|
||||
sendSpy.mockRestore();
|
||||
streamSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is set', async () => {
|
||||
vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', '/tmp/test.jsonl');
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
@@ -558,7 +612,7 @@ describe('runNonInteractive', () => {
|
||||
input: 'Initial fail',
|
||||
prompt_id: 'prompt-id-4',
|
||||
}),
|
||||
).rejects.toThrow(apiError);
|
||||
).rejects.toThrow('API connection failed');
|
||||
});
|
||||
|
||||
it('should not exit if a tool is not found, and should send error back to model', async () => {
|
||||
@@ -822,6 +876,79 @@ describe('runNonInteractive', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep only the final post-tool assistant text in JSON output', async () => {
|
||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-1',
|
||||
name: 'testTool',
|
||||
args: { arg1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-json-tool-text',
|
||||
},
|
||||
};
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: CoreToolCallStatus.Success,
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: [{ text: 'Tool executed successfully' }],
|
||||
callId: 'tool-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockGeminiClient.sendMessageStream
|
||||
.mockReturnValueOnce(
|
||||
createStreamFromEvents([
|
||||
{ type: GeminiEventType.Content, value: 'Let me check that...' },
|
||||
toolCallEvent,
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
|
||||
},
|
||||
]),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
createStreamFromEvents([
|
||||
{ type: GeminiEventType.Content, value: 'Final answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Use a tool',
|
||||
prompt_id: 'prompt-id-json-tool-text',
|
||||
});
|
||||
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
{
|
||||
session_id: 'test-session-id',
|
||||
response: 'Final answer',
|
||||
stats: MOCK_SESSION_METRICS,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should write JSON output with stats for empty response commands', async () => {
|
||||
// Test the scenario where a command completes but produces no content at all
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
@@ -1068,10 +1195,11 @@ describe('runNonInteractive', () => {
|
||||
|
||||
// Spy on handleCancellationError to verify it's called
|
||||
const errors = await import('./utils/errors.js');
|
||||
const cancellationSentinel = new Error('Cancelled');
|
||||
const handleCancellationErrorSpy = vi
|
||||
.spyOn(errors, 'handleCancellationError')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('Cancelled');
|
||||
throw cancellationSentinel;
|
||||
});
|
||||
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
@@ -1087,7 +1215,7 @@ describe('runNonInteractive', () => {
|
||||
signal.addEventListener('abort', () => {
|
||||
clearTimeout(timeout);
|
||||
setTimeout(() => {
|
||||
reject(new Error('Aborted')); // This will be caught by nonInteractiveCli and passed to handleError
|
||||
reject(new Error('Aborted'));
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
@@ -1120,20 +1248,10 @@ describe('runNonInteractive', () => {
|
||||
keypressHandler('\u0003', { ctrl: true, name: 'c' });
|
||||
}
|
||||
|
||||
// The promise should reject with 'Aborted' because our mock stream throws it,
|
||||
// and nonInteractiveCli catches it and calls handleError, which doesn't necessarily throw.
|
||||
// Wait, if handleError is called, we should check that.
|
||||
// But here we want to check if Ctrl+C works.
|
||||
|
||||
// In our current setup, Ctrl+C aborts the signal. The stream throws 'Aborted'.
|
||||
// nonInteractiveCli catches 'Aborted' and calls handleError.
|
||||
|
||||
// If we want to test that handleCancellationError is called, we need the loop to detect abortion.
|
||||
// But our stream throws before the loop can detect it.
|
||||
|
||||
// Let's just check that the promise rejects with 'Aborted' for now,
|
||||
// which proves the abortion signal reached the stream.
|
||||
await expect(runPromise).rejects.toThrow('Aborted');
|
||||
// The Ctrl+C path should route through handleCancellationError rather than
|
||||
// surfacing the raw stream abort.
|
||||
await expect(runPromise).rejects.toBe(cancellationSentinel);
|
||||
expect(handleCancellationErrorSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(
|
||||
processStderrSpy.mock.calls.some(
|
||||
@@ -1160,6 +1278,78 @@ describe('runNonInteractive', () => {
|
||||
// but we can also do it manually if needed.
|
||||
});
|
||||
|
||||
it('should honor cancellation that happens before session.send()', async () => {
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const originalSetRawMode = (process.stdin as any).setRawMode;
|
||||
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
if (!originalSetRawMode) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(process.stdin as any).setRawMode = vi.fn();
|
||||
}
|
||||
|
||||
const stdinOnSpy = vi
|
||||
.spyOn(process.stdin, 'on')
|
||||
.mockImplementation(
|
||||
(event: string | symbol, listener: (...args: unknown[]) => void) => {
|
||||
if (event === 'keypress') {
|
||||
listener('\u0003', { ctrl: true, name: 'c' });
|
||||
}
|
||||
return process.stdin;
|
||||
},
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.spyOn(process.stdin as any, 'setRawMode').mockImplementation(() => true);
|
||||
vi.spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin);
|
||||
vi.spyOn(process.stdin, 'pause').mockImplementation(() => process.stdin);
|
||||
vi.spyOn(process.stdin, 'removeAllListeners').mockImplementation(
|
||||
() => process.stdin,
|
||||
);
|
||||
|
||||
const errors = await import('./utils/errors.js');
|
||||
const cancellationSentinel = new Error('Cancelled before send');
|
||||
const handleCancellationErrorSpy = vi
|
||||
.spyOn(errors, 'handleCancellationError')
|
||||
.mockImplementation(() => {
|
||||
throw cancellationSentinel;
|
||||
});
|
||||
|
||||
const { LegacyAgentSession } = await import('@google/gemini-cli-core');
|
||||
const sendSpy = vi.spyOn(LegacyAgentSession.prototype, 'send');
|
||||
|
||||
await expect(
|
||||
runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Cancelled query',
|
||||
prompt_id: 'prompt-id-pre-send-cancel',
|
||||
}),
|
||||
).rejects.toBe(cancellationSentinel);
|
||||
|
||||
expect(handleCancellationErrorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sendSpy).not.toHaveBeenCalled();
|
||||
expect(stdinOnSpy).toHaveBeenCalled();
|
||||
|
||||
handleCancellationErrorSpy.mockRestore();
|
||||
sendSpy.mockRestore();
|
||||
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: originalIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
if (originalSetRawMode) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(process.stdin as any).setRawMode = originalSetRawMode;
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (process.stdin as any).setRawMode;
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw FatalInputError if a command requires confirmation', async () => {
|
||||
const mockCommand = {
|
||||
name: 'confirm',
|
||||
@@ -1329,6 +1519,9 @@ describe('runNonInteractive', () => {
|
||||
name: 'ShellTool',
|
||||
description: 'A shell tool',
|
||||
run: vi.fn(),
|
||||
build: vi.fn().mockReturnValue({
|
||||
getDescription: () => 'A shell tool',
|
||||
}),
|
||||
}),
|
||||
getFunctionDeclarations: vi.fn().mockReturnValue([{ name: 'ShellTool' }]),
|
||||
} as unknown as ToolRegistry);
|
||||
@@ -1777,9 +1970,7 @@ describe('runNonInteractive', () => {
|
||||
throw new Error('Recording failed');
|
||||
}),
|
||||
};
|
||||
// @ts-expect-error - Mocking internal structure
|
||||
mockGeminiClient.getChat = vi.fn().mockReturnValue(mockChat);
|
||||
// @ts-expect-error - Mocking internal structure
|
||||
mockGeminiClient.getCurrentSequenceModel = vi
|
||||
.fn()
|
||||
.mockReturnValue('model-1');
|
||||
@@ -2000,7 +2191,6 @@ describe('runNonInteractive', () => {
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'Agent execution stopped: Stopped by hook\n',
|
||||
);
|
||||
// Should exit without calling sendMessageStream again
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -2031,9 +2221,9 @@ describe('runNonInteractive', () => {
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'[WARNING] Agent execution blocked: Blocked by hook\n',
|
||||
);
|
||||
// sendMessageStream is called once, recursion is internal to it and transparent to the caller
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
// Stream continues after blocked event — content should be output
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2174,6 +2364,40 @@ describe('runNonInteractive', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit warning event for loop_detected in streaming JSON mode', async () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
const streamEvents: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.LoopDetected } as ServerGeminiStreamEvent,
|
||||
{ type: GeminiEventType.Content, value: 'Continuing after loop' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(streamEvents),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Loop test explicit',
|
||||
prompt_id: 'prompt-id-loop-explicit',
|
||||
});
|
||||
|
||||
const output = getWrittenOutput();
|
||||
// The STREAM_JSON output should contain an error event with warning severity
|
||||
expect(output).toContain('"type":"error"');
|
||||
expect(output).toContain('"severity":"warning"');
|
||||
expect(output).toContain('Loop detected');
|
||||
});
|
||||
|
||||
it('should report cancelled tool calls as success in stream-json mode (legacy parity)', async () => {
|
||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
|
||||
@@ -6,33 +6,40 @@
|
||||
|
||||
import type {
|
||||
Config,
|
||||
ToolCallRequestInfo,
|
||||
ResumedSessionData,
|
||||
UserFeedbackPayload,
|
||||
AgentEvent,
|
||||
ContentPart,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import {
|
||||
convertSessionToClientHistory,
|
||||
GeminiEventType,
|
||||
FatalError,
|
||||
FatalAuthenticationError,
|
||||
FatalInputError,
|
||||
FatalSandboxError,
|
||||
FatalConfigError,
|
||||
FatalTurnLimitedError,
|
||||
FatalToolExecutionError,
|
||||
FatalCancellationError,
|
||||
promptIdContext,
|
||||
OutputFormat,
|
||||
JsonFormatter,
|
||||
StreamJsonFormatter,
|
||||
JsonStreamEventType,
|
||||
uiTelemetryService,
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
createWorkingStdio,
|
||||
recordToolCallInteractions,
|
||||
ToolErrorType,
|
||||
Scheduler,
|
||||
ROOT_SCHEDULER_ID,
|
||||
LegacyAgentSession,
|
||||
ToolErrorType,
|
||||
geminiPartsToContentParts,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import type { Part } from '@google/genai';
|
||||
import readline from 'node:readline';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
@@ -151,8 +158,6 @@ export async function runNonInteractive({
|
||||
}, 200);
|
||||
|
||||
abortController.abort();
|
||||
// Note: Don't exit here - let the abort flow through the system
|
||||
// and trigger handleCancellationError() which will exit with proper code
|
||||
}
|
||||
};
|
||||
|
||||
@@ -183,6 +188,8 @@ export async function runNonInteractive({
|
||||
};
|
||||
|
||||
let errorToHandle: unknown | undefined;
|
||||
let terminalProcessExitHandled = false;
|
||||
let abortSession = () => {};
|
||||
try {
|
||||
consolePatcher.patch();
|
||||
|
||||
@@ -247,9 +254,6 @@ export async function runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
);
|
||||
// If a slash command is found and returns a prompt, use it.
|
||||
// Otherwise, slashCommandResult falls through to the default prompt
|
||||
// handling.
|
||||
if (slashCommandResult) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
query = slashCommandResult as Part[];
|
||||
@@ -267,8 +271,6 @@ export async function runNonInteractive({
|
||||
escapePastedAtSymbols: false,
|
||||
});
|
||||
if (error || !processedQuery) {
|
||||
// An error occurred during @include processing (e.g., file not found).
|
||||
// The error message is already logged by handleAtCommand.
|
||||
throw new FatalInputError(
|
||||
error || 'Exiting due to an error processing the @ command.',
|
||||
);
|
||||
@@ -287,235 +289,334 @@ export async function runNonInteractive({
|
||||
});
|
||||
}
|
||||
|
||||
let currentMessages: Content[] = [{ role: 'user', parts: query }];
|
||||
// Create LegacyAgentSession — owns the agentic loop
|
||||
const session = new LegacyAgentSession({
|
||||
client: geminiClient,
|
||||
scheduler,
|
||||
config,
|
||||
promptId: prompt_id,
|
||||
});
|
||||
|
||||
let turnCount = 0;
|
||||
while (true) {
|
||||
turnCount++;
|
||||
if (
|
||||
config.getMaxSessionTurns() >= 0 &&
|
||||
turnCount > config.getMaxSessionTurns()
|
||||
) {
|
||||
handleMaxTurnsExceededError(config);
|
||||
}
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
// Wire Ctrl+C to session abort
|
||||
abortSession = () => {
|
||||
void session.abort();
|
||||
};
|
||||
abortController.signal.addEventListener('abort', abortSession);
|
||||
if (abortController.signal.aborted) {
|
||||
return handleCancellationError(config);
|
||||
}
|
||||
|
||||
const responseStream = geminiClient.sendMessageStream(
|
||||
currentMessages[0]?.parts || [],
|
||||
abortController.signal,
|
||||
prompt_id,
|
||||
undefined,
|
||||
false,
|
||||
turnCount === 1 ? input : undefined,
|
||||
// Start the agentic loop (runs in background)
|
||||
const { streamId } = await session.send({
|
||||
message: {
|
||||
content: geminiPartsToContentParts(query),
|
||||
displayContent: input,
|
||||
},
|
||||
});
|
||||
if (streamId === null) {
|
||||
throw new Error(
|
||||
'LegacyAgentSession.send() unexpectedly returned no stream for a message send.',
|
||||
);
|
||||
}
|
||||
|
||||
let responseText = '';
|
||||
for await (const event of responseStream) {
|
||||
if (abortController.signal.aborted) {
|
||||
handleCancellationError(config);
|
||||
}
|
||||
const getTextContent = (parts?: ContentPart[]): string | undefined => {
|
||||
const text = parts
|
||||
?.map((part) => (part.type === 'text' ? part.text : ''))
|
||||
.join('');
|
||||
return text ? text : undefined;
|
||||
};
|
||||
|
||||
if (event.type === GeminiEventType.Content) {
|
||||
const isRaw =
|
||||
config.getRawOutput() || config.getAcceptRawOutputRisk();
|
||||
const output = isRaw ? event.value : stripAnsi(event.value);
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.MESSAGE,
|
||||
timestamp: new Date().toISOString(),
|
||||
role: 'assistant',
|
||||
content: output,
|
||||
delta: true,
|
||||
const emitFinalSuccessResult = (): void => {
|
||||
if (streamFormatter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const durationMs = Date.now() - startTime;
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
stats: streamFormatter.convertToStreamStats(metrics, durationMs),
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
const formatter = new JsonFormatter();
|
||||
const stats = uiTelemetryService.getMetrics();
|
||||
textOutput.write(
|
||||
formatter.format(config.getSessionId(), responseText, stats),
|
||||
);
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline();
|
||||
}
|
||||
};
|
||||
|
||||
const reconstructFatalError = (event: AgentEvent<'error'>): Error => {
|
||||
const errorMeta = event._meta;
|
||||
const name =
|
||||
typeof errorMeta?.['errorName'] === 'string'
|
||||
? errorMeta['errorName']
|
||||
: undefined;
|
||||
|
||||
let errToThrow: Error;
|
||||
switch (name) {
|
||||
case 'FatalAuthenticationError':
|
||||
errToThrow = new FatalAuthenticationError(event.message);
|
||||
break;
|
||||
case 'FatalInputError':
|
||||
errToThrow = new FatalInputError(event.message);
|
||||
break;
|
||||
case 'FatalSandboxError':
|
||||
errToThrow = new FatalSandboxError(event.message);
|
||||
break;
|
||||
case 'FatalConfigError':
|
||||
errToThrow = new FatalConfigError(event.message);
|
||||
break;
|
||||
case 'FatalTurnLimitedError':
|
||||
errToThrow = new FatalTurnLimitedError(event.message);
|
||||
break;
|
||||
case 'FatalToolExecutionError':
|
||||
errToThrow = new FatalToolExecutionError(event.message);
|
||||
break;
|
||||
case 'FatalCancellationError':
|
||||
errToThrow = new FatalCancellationError(event.message);
|
||||
break;
|
||||
case 'FatalError':
|
||||
errToThrow = new FatalError(
|
||||
event.message,
|
||||
typeof errorMeta?.['exitCode'] === 'number'
|
||||
? errorMeta['exitCode']
|
||||
: 1,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
errToThrow = new Error(event.message);
|
||||
if (name) {
|
||||
Object.defineProperty(errToThrow, 'name', {
|
||||
value: name,
|
||||
enumerable: true,
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
responseText += output;
|
||||
} else {
|
||||
if (event.value) {
|
||||
textOutput.write(output);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (errorMeta?.['exitCode'] !== undefined) {
|
||||
Object.defineProperty(errToThrow, 'exitCode', {
|
||||
value: errorMeta['exitCode'],
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
if (errorMeta?.['code'] !== undefined) {
|
||||
Object.defineProperty(errToThrow, 'code', {
|
||||
value: errorMeta['code'],
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
if (errorMeta?.['status'] !== undefined) {
|
||||
Object.defineProperty(errToThrow, 'status', {
|
||||
value: errorMeta['status'],
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
return errToThrow;
|
||||
};
|
||||
|
||||
const runTerminalExitHandler = (
|
||||
handler: () => void | never,
|
||||
): void | never => {
|
||||
terminalProcessExitHandled = true;
|
||||
return handler();
|
||||
};
|
||||
|
||||
// Consume AgentEvents for output formatting
|
||||
let responseText = '';
|
||||
let preToolResponseText: string | undefined;
|
||||
let streamEnded = false;
|
||||
for await (const event of session.stream({ streamId })) {
|
||||
if (streamEnded) break;
|
||||
switch (event.type) {
|
||||
case 'message': {
|
||||
if (event.role === 'agent') {
|
||||
for (const part of event.content) {
|
||||
if (part.type === 'text') {
|
||||
const isRaw =
|
||||
config.getRawOutput() || config.getAcceptRawOutputRisk();
|
||||
const output = isRaw ? part.text : stripAnsi(part.text);
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.MESSAGE,
|
||||
timestamp: new Date().toISOString(),
|
||||
role: 'assistant',
|
||||
content: output,
|
||||
delta: true,
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
responseText += output;
|
||||
} else {
|
||||
if (part.text) {
|
||||
textOutput.write(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (event.type === GeminiEventType.ToolCallRequest) {
|
||||
break;
|
||||
}
|
||||
case 'tool_request': {
|
||||
if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
// Final JSON output should reflect the last assistant answer after
|
||||
// any tool orchestration, not intermediate pre-tool text.
|
||||
preToolResponseText = responseText || preToolResponseText;
|
||||
responseText = '';
|
||||
}
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.TOOL_USE,
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_name: event.value.name,
|
||||
tool_id: event.value.callId,
|
||||
parameters: event.value.args,
|
||||
tool_name: event.name,
|
||||
tool_id: event.requestId,
|
||||
parameters: event.args,
|
||||
});
|
||||
}
|
||||
toolCallRequests.push(event.value);
|
||||
} else if (event.type === GeminiEventType.LoopDetected) {
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'warning',
|
||||
message: 'Loop detected, stopping execution',
|
||||
});
|
||||
}
|
||||
} else if (event.type === GeminiEventType.MaxSessionTurns) {
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'error',
|
||||
message: 'Maximum session turns exceeded',
|
||||
});
|
||||
}
|
||||
} else if (event.type === GeminiEventType.Error) {
|
||||
throw event.value.error;
|
||||
} else if (event.type === GeminiEventType.AgentExecutionStopped) {
|
||||
const stopMessage = `Agent execution stopped: ${event.value.systemMessage?.trim() || event.value.reason}`;
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`${stopMessage}\n`);
|
||||
}
|
||||
// Emit final result event for streaming JSON if needed
|
||||
if (streamFormatter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const durationMs = Date.now() - startTime;
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
stats: streamFormatter.convertToStreamStats(
|
||||
metrics,
|
||||
durationMs,
|
||||
),
|
||||
});
|
||||
}
|
||||
return;
|
||||
} else if (event.type === GeminiEventType.AgentExecutionBlocked) {
|
||||
const blockMessage = `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`;
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`[WARNING] ${blockMessage}\n`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolCallRequests.length > 0) {
|
||||
textOutput.ensureTrailingNewline();
|
||||
const completedToolCalls = await scheduler.schedule(
|
||||
toolCallRequests,
|
||||
abortController.signal,
|
||||
);
|
||||
const toolResponseParts: Part[] = [];
|
||||
|
||||
for (const completedToolCall of completedToolCalls) {
|
||||
const toolResponse = completedToolCall.response;
|
||||
const requestInfo = completedToolCall.request;
|
||||
|
||||
case 'tool_response': {
|
||||
textOutput.ensureTrailingNewline();
|
||||
if (streamFormatter) {
|
||||
const displayText = getTextContent(event.displayContent);
|
||||
const errorMsg = getTextContent(event.content) ?? 'Tool error';
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.TOOL_RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_id: requestInfo.callId,
|
||||
status:
|
||||
completedToolCall.status === 'error' ? 'error' : 'success',
|
||||
output:
|
||||
typeof toolResponse.resultDisplay === 'string'
|
||||
? toolResponse.resultDisplay
|
||||
: undefined,
|
||||
error: toolResponse.error
|
||||
tool_id: event.requestId,
|
||||
status: event.isError ? 'error' : 'success',
|
||||
output: displayText,
|
||||
error: event.isError
|
||||
? {
|
||||
type: toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
|
||||
message: toolResponse.error.message,
|
||||
type:
|
||||
typeof event.data?.['errorType'] === 'string'
|
||||
? event.data['errorType']
|
||||
: 'TOOL_EXECUTION_ERROR',
|
||||
message: errorMsg,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
if (event.isError) {
|
||||
const displayText = getTextContent(event.displayContent);
|
||||
const errorMsg = getTextContent(event.content) ?? 'Tool error';
|
||||
|
||||
if (toolResponse.error) {
|
||||
if (event.data?.['errorType'] === ToolErrorType.STOP_EXECUTION) {
|
||||
if (
|
||||
config.getOutputFormat() === OutputFormat.JSON &&
|
||||
!responseText &&
|
||||
preToolResponseText
|
||||
) {
|
||||
responseText = preToolResponseText;
|
||||
}
|
||||
const stopMessage = `Agent execution stopped: ${errorMsg}`;
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`${stopMessage}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.data?.['errorType'] === ToolErrorType.NO_SPACE_LEFT) {
|
||||
terminalProcessExitHandled = true;
|
||||
handleToolError(
|
||||
event.name,
|
||||
new Error(errorMsg),
|
||||
config,
|
||||
typeof event.data?.['errorType'] === 'string'
|
||||
? event.data['errorType']
|
||||
: undefined,
|
||||
displayText,
|
||||
);
|
||||
return;
|
||||
}
|
||||
handleToolError(
|
||||
requestInfo.name,
|
||||
toolResponse.error,
|
||||
event.name,
|
||||
new Error(errorMsg),
|
||||
config,
|
||||
toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
|
||||
typeof toolResponse.resultDisplay === 'string'
|
||||
? toolResponse.resultDisplay
|
||||
typeof event.data?.['errorType'] === 'string'
|
||||
? event.data['errorType']
|
||||
: undefined,
|
||||
displayText,
|
||||
);
|
||||
}
|
||||
|
||||
if (toolResponse.responseParts) {
|
||||
toolResponseParts.push(...toolResponse.responseParts);
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
if (event.fatal) {
|
||||
throw reconstructFatalError(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Record tool calls with full metadata before sending responses to Gemini
|
||||
try {
|
||||
const currentModel =
|
||||
geminiClient.getCurrentSequenceModel() ?? config.getModel();
|
||||
geminiClient
|
||||
.getChat()
|
||||
.recordCompletedToolCalls(currentModel, completedToolCalls);
|
||||
const errorCode = event._meta?.['code'];
|
||||
|
||||
await recordToolCallInteractions(config, completedToolCalls);
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Error recording completed tool call information: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if any tool requested to stop execution immediately
|
||||
const stopExecutionTool = completedToolCalls.find(
|
||||
(tc) => tc.response.errorType === ToolErrorType.STOP_EXECUTION,
|
||||
);
|
||||
|
||||
if (stopExecutionTool && stopExecutionTool.response.error) {
|
||||
const stopMessage = `Agent execution stopped: ${stopExecutionTool.response.error.message}`;
|
||||
if (errorCode === 'AGENT_EXECUTION_BLOCKED') {
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`[WARNING] ${event.message}\n`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const severity =
|
||||
event.status === 'RESOURCE_EXHAUSTED' ? 'error' : 'warning';
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`${stopMessage}\n`);
|
||||
process.stderr.write(`[WARNING] ${event.message}\n`);
|
||||
}
|
||||
|
||||
// Emit final result event for streaming JSON
|
||||
if (streamFormatter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const durationMs = Date.now() - startTime;
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.RESULT,
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
stats: streamFormatter.convertToStreamStats(
|
||||
metrics,
|
||||
durationMs,
|
||||
),
|
||||
severity,
|
||||
message: event.message,
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
const formatter = new JsonFormatter();
|
||||
const stats = uiTelemetryService.getMetrics();
|
||||
textOutput.write(
|
||||
formatter.format(config.getSessionId(), responseText, stats),
|
||||
);
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline(); // Ensure a final newline
|
||||
}
|
||||
return;
|
||||
break;
|
||||
}
|
||||
case 'agent_end': {
|
||||
if (event.reason === 'aborted') {
|
||||
runTerminalExitHandler(() => handleCancellationError(config));
|
||||
} else if (event.reason === 'max_turns') {
|
||||
const isConfiguredTurnLimit =
|
||||
typeof event.data?.['maxTurns'] === 'number' ||
|
||||
typeof event.data?.['turnCount'] === 'number';
|
||||
|
||||
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
||||
} else {
|
||||
// Emit final result event for streaming JSON
|
||||
if (streamFormatter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const durationMs = Date.now() - startTime;
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
stats: streamFormatter.convertToStreamStats(metrics, durationMs),
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
const formatter = new JsonFormatter();
|
||||
const stats = uiTelemetryService.getMetrics();
|
||||
textOutput.write(
|
||||
formatter.format(config.getSessionId(), responseText, stats),
|
||||
);
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline(); // Ensure a final newline
|
||||
if (isConfiguredTurnLimit) {
|
||||
runTerminalExitHandler(() =>
|
||||
handleMaxTurnsExceededError(config),
|
||||
);
|
||||
} else if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'error',
|
||||
message: 'Maximum session turns exceeded',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const stopMessage =
|
||||
typeof event.data?.['message'] === 'string'
|
||||
? event.data['message']
|
||||
: '';
|
||||
if (stopMessage && config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`Agent execution stopped: ${stopMessage}\n`);
|
||||
}
|
||||
|
||||
emitFinalSuccessResult();
|
||||
streamEnded = true;
|
||||
break;
|
||||
}
|
||||
return;
|
||||
case 'initialize':
|
||||
case 'session_update':
|
||||
case 'agent_start':
|
||||
case 'tool_update':
|
||||
case 'elicitation_request':
|
||||
case 'elicitation_response':
|
||||
case 'usage':
|
||||
case 'custom':
|
||||
// Explicitly ignore these non-interactive events
|
||||
break;
|
||||
default:
|
||||
event satisfies never;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -523,12 +624,16 @@ export async function runNonInteractive({
|
||||
} finally {
|
||||
// Cleanup stdin cancellation before other cleanup
|
||||
cleanupStdinCancellation();
|
||||
abortController.signal.removeEventListener('abort', abortSession);
|
||||
|
||||
consolePatcher.cleanup();
|
||||
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
|
||||
}
|
||||
|
||||
if (errorToHandle) {
|
||||
if (terminalProcessExitHandled) {
|
||||
throw errorToHandle;
|
||||
}
|
||||
handleError(errorToHandle, config);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -32,7 +32,6 @@ import { docsCommand } from '../ui/commands/docsCommand.js';
|
||||
import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||
import { loopCommand } from '../ui/commands/loopCommand.js';
|
||||
import { footerCommand } from '../ui/commands/footerCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js';
|
||||
@@ -156,7 +155,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
]
|
||||
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
|
||||
helpCommand,
|
||||
loopCommand,
|
||||
footerCommand,
|
||||
shortcutsCommand,
|
||||
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
|
||||
|
||||
@@ -82,9 +82,8 @@ import {
|
||||
buildUserSteeringHintPrompt,
|
||||
logBillingEvent,
|
||||
ApiKeyUpdatedEvent,
|
||||
LegacyAgentProtocol,
|
||||
type InjectionSource,
|
||||
cronSchedulerService,
|
||||
type ScheduledTask,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import process from 'node:process';
|
||||
@@ -112,6 +111,7 @@ import { computeTerminalTitle } from '../utils/windowTitle.js';
|
||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
import { useLogger } from './hooks/useLogger.js';
|
||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||
import { useAgentStream } from './hooks/useAgentStream.js';
|
||||
import { type BackgroundTask } from './hooks/useExecutionLifecycle.js';
|
||||
import { useVim } from './hooks/vim.js';
|
||||
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
|
||||
@@ -1093,6 +1093,46 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
const streamAgent = useMemo(
|
||||
() =>
|
||||
config?.getExperimentalUseAgentProtocol()
|
||||
? new LegacyAgentProtocol({ config, getPreferredEditor })
|
||||
: undefined,
|
||||
[config, getPreferredEditor],
|
||||
);
|
||||
|
||||
const activeStream = streamAgent
|
||||
? // eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useAgentStream({
|
||||
agent: streamAgent,
|
||||
addItem: historyManager.addItem,
|
||||
onCancelSubmit,
|
||||
isShellFocused: embeddedShellFocused,
|
||||
logger,
|
||||
})
|
||||
: // eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useGeminiStream(
|
||||
config.getGeminiClient(),
|
||||
historyManager.history,
|
||||
historyManager.addItem,
|
||||
config,
|
||||
settings,
|
||||
setDebugMessage,
|
||||
handleSlashCommand,
|
||||
shellModeActive,
|
||||
getPreferredEditor,
|
||||
onAuthError,
|
||||
performMemoryRefresh,
|
||||
modelSwitchedFromQuotaError,
|
||||
setModelSwitchedFromQuotaError,
|
||||
onCancelSubmit,
|
||||
setEmbeddedShellFocused,
|
||||
terminalWidth,
|
||||
terminalHeight,
|
||||
embeddedShellFocused,
|
||||
consumePendingHints,
|
||||
);
|
||||
|
||||
const {
|
||||
streamingState,
|
||||
submitQuery,
|
||||
@@ -1112,27 +1152,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
backgroundTasks,
|
||||
dismissBackgroundTask,
|
||||
retryStatus,
|
||||
} = useGeminiStream(
|
||||
config.getGeminiClient(),
|
||||
historyManager.history,
|
||||
historyManager.addItem,
|
||||
config,
|
||||
settings,
|
||||
setDebugMessage,
|
||||
handleSlashCommand,
|
||||
shellModeActive,
|
||||
getPreferredEditor,
|
||||
onAuthError,
|
||||
performMemoryRefresh,
|
||||
modelSwitchedFromQuotaError,
|
||||
setModelSwitchedFromQuotaError,
|
||||
onCancelSubmit,
|
||||
setEmbeddedShellFocused,
|
||||
terminalWidth,
|
||||
terminalHeight,
|
||||
embeddedShellFocused,
|
||||
consumePendingHints,
|
||||
);
|
||||
} = activeStream;
|
||||
|
||||
const pendingHistoryItems = useMemo(
|
||||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||
@@ -1200,18 +1220,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const { isMcpReady } = useMcpStatus(config);
|
||||
|
||||
const [scheduledTasks, setScheduledTasks] = useState<ScheduledTask[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateTasks = () => {
|
||||
setScheduledTasks(cronSchedulerService.listTasks());
|
||||
};
|
||||
updateTasks();
|
||||
// Poor man's sync: polling for list updates since it's mostly local
|
||||
const interval = setInterval(updateTasks, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
messageQueue,
|
||||
addMessage,
|
||||
@@ -1225,16 +1233,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isMcpReady,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleTaskDue = (task: ScheduledTask) => {
|
||||
addMessage(task.prompt);
|
||||
};
|
||||
cronSchedulerService.on('task_due', handleTaskDue);
|
||||
return () => {
|
||||
cronSchedulerService.off('task_due', handleTaskDue);
|
||||
};
|
||||
}, [addMessage]);
|
||||
|
||||
cancelHandlerRef.current = useCallback(
|
||||
(shouldRestorePrompt: boolean = true) => {
|
||||
if (isToolAwaitingConfirmation(pendingHistoryItems)) {
|
||||
@@ -1727,7 +1725,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
// If the user presses Ctrl+C, we want to cancel any ongoing requests.
|
||||
// This should happen regardless of the count.
|
||||
cancelOngoingRequest?.();
|
||||
void cancelOngoingRequest?.();
|
||||
|
||||
handleCtrlCPress();
|
||||
return true;
|
||||
@@ -2349,7 +2347,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
terminalBackgroundColor: config.getTerminalBackground(),
|
||||
settingsNonce,
|
||||
backgroundTasks,
|
||||
scheduledTasks,
|
||||
activeBackgroundTaskPid,
|
||||
backgroundTaskHeight,
|
||||
isBackgroundTaskListOpen,
|
||||
@@ -2477,7 +2474,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
settingsNonce,
|
||||
backgroundTaskHeight,
|
||||
isBackgroundTaskListOpen,
|
||||
scheduledTasks,
|
||||
activeBackgroundTaskPid,
|
||||
backgroundTasks,
|
||||
adminSettingsChanged,
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { loopCommand } from './loopCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { cronSchedulerService } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
cronSchedulerService: {
|
||||
scheduleTask: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('loopCommand', () => {
|
||||
let mockContext: ReturnType<typeof createMockCommandContext>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should print an error if no args are provided', async () => {
|
||||
mockContext.invocation!.args = ' ';
|
||||
await loopCommand.action!(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining('Please provide a prompt'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should default to 10m if no interval is provided', async () => {
|
||||
mockContext.invocation!.args = 'check the build';
|
||||
vi.mocked(cronSchedulerService.scheduleTask).mockReturnValue('abc12345');
|
||||
|
||||
await loopCommand.action!(mockContext, '');
|
||||
|
||||
expect(cronSchedulerService.scheduleTask).toHaveBeenCalledWith(
|
||||
'10m',
|
||||
'check the build',
|
||||
true,
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('every 10m'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse leading interval', async () => {
|
||||
mockContext.invocation!.args = '5m check the build';
|
||||
vi.mocked(cronSchedulerService.scheduleTask).mockReturnValue('def56789');
|
||||
|
||||
await loopCommand.action!(mockContext, '');
|
||||
|
||||
expect(cronSchedulerService.scheduleTask).toHaveBeenCalledWith(
|
||||
'5m',
|
||||
'check the build',
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle scheduling errors', async () => {
|
||||
mockContext.invocation!.args = 'invalid check the build';
|
||||
vi.mocked(cronSchedulerService.scheduleTask).mockImplementation(() => {
|
||||
throw new Error('Invalid format');
|
||||
});
|
||||
|
||||
await loopCommand.action!(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining(
|
||||
'Failed to schedule task: Invalid format',
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import { cronSchedulerService } from '@google/gemini-cli-core';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
export const loopCommand: SlashCommand = {
|
||||
name: 'loop',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Schedules a repeating prompt (e.g., /loop 5m check the build)',
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
const args = context.invocation?.args?.trim() || '';
|
||||
if (!args) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: 'Please provide a prompt to loop. Example: /loop 5m check the build',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Default to 10 minutes if no interval is provided
|
||||
let intervalString = '10m';
|
||||
|
||||
// Check if the first word is an interval
|
||||
const match = args.match(/^(\d+[smhd])\s+(.*)/i);
|
||||
let prompt = args;
|
||||
|
||||
if (match) {
|
||||
intervalString = match[1].toLowerCase();
|
||||
prompt = match[2].trim();
|
||||
}
|
||||
|
||||
try {
|
||||
const id = cronSchedulerService.scheduleTask(
|
||||
intervalString,
|
||||
prompt,
|
||||
true,
|
||||
);
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Scheduled recurring task \`${id}\` to run \`${prompt}\` every ${intervalString}.`,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Failed to schedule task: ${message}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -149,7 +149,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
height={24}
|
||||
@@ -170,7 +169,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
height={24}
|
||||
@@ -191,7 +189,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
height={24}
|
||||
@@ -212,7 +209,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
height={24}
|
||||
@@ -233,7 +229,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={shell1.pid}
|
||||
width={100}
|
||||
height={30}
|
||||
@@ -257,7 +252,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
height={24}
|
||||
@@ -278,7 +272,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
height={24}
|
||||
@@ -310,7 +303,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
height={24}
|
||||
@@ -343,7 +335,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
height={24}
|
||||
@@ -369,7 +360,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={shell2.pid}
|
||||
width={width}
|
||||
height={24}
|
||||
@@ -401,7 +391,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={exitedShell.pid}
|
||||
width={width}
|
||||
height={24}
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
type AnsiOutput,
|
||||
type AnsiLine,
|
||||
type AnsiToken,
|
||||
type ScheduledTask,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||
@@ -37,7 +36,6 @@ import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
|
||||
interface BackgroundTaskDisplayProps {
|
||||
shells: Map<number, BackgroundTask>;
|
||||
scheduledTasks: ScheduledTask[];
|
||||
activePid: number;
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -65,7 +63,6 @@ const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
|
||||
|
||||
export const BackgroundTaskDisplay = ({
|
||||
shells,
|
||||
scheduledTasks,
|
||||
activePid,
|
||||
width,
|
||||
height,
|
||||
@@ -338,74 +335,60 @@ export const BackgroundTaskDisplay = ({
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width="100%">
|
||||
{shells.size > 0 && (
|
||||
<RadioButtonSelect
|
||||
items={items}
|
||||
initialIndex={initialIndex >= 0 ? initialIndex : 0}
|
||||
onSelect={(pid) => {
|
||||
setActiveBackgroundTaskPid(pid);
|
||||
setIsBackgroundTaskListOpen(false);
|
||||
}}
|
||||
onHighlight={(pid) => setHighlightedPid(pid)}
|
||||
isFocused={isFocused}
|
||||
maxItemsToShow={Math.max(
|
||||
1,
|
||||
height -
|
||||
TOTAL_OVERHEAD_HEIGHT -
|
||||
PROCESS_LIST_HEADER_HEIGHT -
|
||||
(scheduledTasks.length > 0 ? scheduledTasks.length + 2 : 0),
|
||||
)}
|
||||
renderItem={(
|
||||
item,
|
||||
{ isSelected: _isSelected, titleColor: _titleColor },
|
||||
) => {
|
||||
const shell = shells.get(item.value);
|
||||
if (!shell) return <Text>{item.label}</Text>;
|
||||
<RadioButtonSelect
|
||||
items={items}
|
||||
initialIndex={initialIndex >= 0 ? initialIndex : 0}
|
||||
onSelect={(pid) => {
|
||||
setActiveBackgroundTaskPid(pid);
|
||||
setIsBackgroundTaskListOpen(false);
|
||||
}}
|
||||
onHighlight={(pid) => setHighlightedPid(pid)}
|
||||
isFocused={isFocused}
|
||||
maxItemsToShow={Math.max(
|
||||
1,
|
||||
height - TOTAL_OVERHEAD_HEIGHT - PROCESS_LIST_HEADER_HEIGHT,
|
||||
)}
|
||||
renderItem={(
|
||||
item,
|
||||
{ isSelected: _isSelected, titleColor: _titleColor },
|
||||
) => {
|
||||
// Custom render to handle exit code coloring if needed,
|
||||
// or just use default. The default RadioButtonSelect renderer
|
||||
// handles standard label.
|
||||
// But we want to color exit code differently?
|
||||
// The previous implementation colored exit code green/red.
|
||||
// Let's reimplement that.
|
||||
|
||||
const truncatedCommand = formatShellCommandForDisplay(
|
||||
shell.command,
|
||||
maxCommandLength,
|
||||
);
|
||||
// We need access to shell details here.
|
||||
// We can put shell details in the item or lookup.
|
||||
// Lookup from shells map.
|
||||
const shell = shells.get(item.value);
|
||||
if (!shell) return <Text>{item.label}</Text>;
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{truncatedCommand} (PID: {shell.pid})
|
||||
{shell.status === 'exited' ? (
|
||||
<Text
|
||||
color={
|
||||
shell.exitCode === 0
|
||||
? theme.status.success
|
||||
: theme.status.error
|
||||
}
|
||||
>
|
||||
{' '}
|
||||
(Exit Code: {shell.exitCode})
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{scheduledTasks.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={shells.size > 0 ? 1 : 0}>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderBottom={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
paddingTop={1}
|
||||
marginBottom={1}
|
||||
>
|
||||
<Text bold>Scheduled Loops</Text>
|
||||
</Box>
|
||||
{scheduledTasks.map((task) => (
|
||||
<Text key={task.id}>
|
||||
{` ● [${task.id}] every ${task.intervalMs! / 1000}s: "${task.prompt}"`}
|
||||
const truncatedCommand = formatShellCommandForDisplay(
|
||||
shell.command,
|
||||
maxCommandLength,
|
||||
);
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{truncatedCommand} (PID: {shell.pid})
|
||||
{shell.status === 'exited' ? (
|
||||
<Text
|
||||
color={
|
||||
shell.exitCode === 0
|
||||
? theme.status.success
|
||||
: theme.status.error
|
||||
}
|
||||
>
|
||||
{' '}
|
||||
(Exit Code: {shell.exitCode})
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,6 @@ import type {
|
||||
AgentDefinition,
|
||||
FolderDiscoveryResults,
|
||||
PolicyUpdateConfirmationRequest,
|
||||
ScheduledTask,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type TransientMessageType } from '../../utils/events.js';
|
||||
import type { DOMElement } from 'ink';
|
||||
@@ -217,7 +216,6 @@ export interface UIState {
|
||||
terminalBackgroundColor: TerminalBackgroundColor;
|
||||
settingsNonce: number;
|
||||
backgroundTasks: Map<number, BackgroundTask>;
|
||||
scheduledTasks: ScheduledTask[];
|
||||
activeBackgroundTaskPid: number | null;
|
||||
backgroundTaskHeight: number;
|
||||
isBackgroundTaskListOpen: boolean;
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import type { LegacyAgentProtocol } from '@google/gemini-cli-core';
|
||||
import { renderHookWithProviders } from '../../test-utils/render.js';
|
||||
|
||||
// --- MOCKS ---
|
||||
|
||||
const mockLegacyAgentProtocol = vi.hoisted(() => ({
|
||||
send: vi.fn().mockResolvedValue({ streamId: 'test-stream-id' }),
|
||||
subscribe: vi.fn().mockReturnValue(() => {}),
|
||||
abort: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
useSessionStats: vi.fn(() => ({
|
||||
startNewPrompt: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// --- END MOCKS ---
|
||||
|
||||
import { useAgentStream } from './useAgentStream.js';
|
||||
import { MessageType, StreamingState } from '../types.js';
|
||||
|
||||
describe('useAgentStream', () => {
|
||||
const mockAddItem = vi.fn();
|
||||
const mockOnCancelSubmit = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize on mount', async () => {
|
||||
await renderHookWithProviders(() =>
|
||||
useAgentStream({
|
||||
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
|
||||
addItem: mockAddItem,
|
||||
onCancelSubmit: mockOnCancelSubmit,
|
||||
isShellFocused: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockLegacyAgentProtocol.subscribe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call agent.send when submitQuery is called', async () => {
|
||||
const { result } = await renderHookWithProviders(() =>
|
||||
useAgentStream({
|
||||
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
|
||||
addItem: mockAddItem,
|
||||
onCancelSubmit: mockOnCancelSubmit,
|
||||
isShellFocused: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('hello');
|
||||
});
|
||||
|
||||
expect(mockLegacyAgentProtocol.send).toHaveBeenCalledWith({
|
||||
message: { content: [{ type: 'text', text: 'hello' }] },
|
||||
});
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: MessageType.USER, text: 'hello' }),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update streamingState based on agent_start and agent_end events', async () => {
|
||||
const { result } = await renderHookWithProviders(() =>
|
||||
useAgentStream({
|
||||
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
|
||||
addItem: mockAddItem,
|
||||
onCancelSubmit: mockOnCancelSubmit,
|
||||
isShellFocused: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock
|
||||
.calls[0][0];
|
||||
|
||||
expect(result.current.streamingState).toBe(StreamingState.Idle);
|
||||
|
||||
act(() => {
|
||||
eventHandler({
|
||||
type: 'agent_start',
|
||||
id: '1',
|
||||
timestamp: '',
|
||||
streamId: '',
|
||||
});
|
||||
});
|
||||
expect(result.current.streamingState).toBe(StreamingState.Responding);
|
||||
|
||||
act(() => {
|
||||
eventHandler({
|
||||
type: 'agent_end',
|
||||
reason: 'completed',
|
||||
id: '2',
|
||||
timestamp: '',
|
||||
streamId: '',
|
||||
});
|
||||
});
|
||||
expect(result.current.streamingState).toBe(StreamingState.Idle);
|
||||
});
|
||||
|
||||
it('should accumulate text content and update pendingHistoryItems', async () => {
|
||||
const { result } = await renderHookWithProviders(() =>
|
||||
useAgentStream({
|
||||
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
|
||||
addItem: mockAddItem,
|
||||
onCancelSubmit: mockOnCancelSubmit,
|
||||
isShellFocused: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock
|
||||
.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
eventHandler({
|
||||
type: 'message',
|
||||
role: 'agent',
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
id: '1',
|
||||
timestamp: '',
|
||||
streamId: '',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.pendingHistoryItems).toHaveLength(1);
|
||||
expect(result.current.pendingHistoryItems[0]).toMatchObject({
|
||||
type: 'gemini',
|
||||
text: 'Hello',
|
||||
});
|
||||
|
||||
act(() => {
|
||||
eventHandler({
|
||||
type: 'message',
|
||||
role: 'agent',
|
||||
content: [{ type: 'text', text: ' world' }],
|
||||
id: '2',
|
||||
timestamp: '',
|
||||
streamId: '',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.pendingHistoryItems[0].text).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should process thought events and update thought state', async () => {
|
||||
const { result } = await renderHookWithProviders(() =>
|
||||
useAgentStream({
|
||||
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
|
||||
addItem: mockAddItem,
|
||||
onCancelSubmit: mockOnCancelSubmit,
|
||||
isShellFocused: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock
|
||||
.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
eventHandler({
|
||||
type: 'message',
|
||||
role: 'agent',
|
||||
content: [{ type: 'thought', thought: '**Thinking** about tests' }],
|
||||
id: '1',
|
||||
timestamp: '',
|
||||
streamId: '',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.thought).toEqual({
|
||||
subject: 'Thinking',
|
||||
description: 'about tests',
|
||||
});
|
||||
});
|
||||
|
||||
it('should call agent.abort when cancelOngoingRequest is called', async () => {
|
||||
const { result } = await renderHookWithProviders(() =>
|
||||
useAgentStream({
|
||||
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
|
||||
addItem: mockAddItem,
|
||||
onCancelSubmit: mockOnCancelSubmit,
|
||||
isShellFocused: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.cancelOngoingRequest();
|
||||
});
|
||||
|
||||
expect(mockLegacyAgentProtocol.abort).toHaveBeenCalled();
|
||||
expect(mockOnCancelSubmit).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
getErrorMessage,
|
||||
MessageSenderType,
|
||||
debugLogger,
|
||||
geminiPartsToContentParts,
|
||||
parseThought,
|
||||
CoreToolCallStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type ApprovalMode,
|
||||
Kind,
|
||||
type ThoughtSummary,
|
||||
type RetryAttemptPayload,
|
||||
type AgentEvent,
|
||||
type AgentProtocol,
|
||||
type Logger,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
HistoryItemWithoutId,
|
||||
LoopDetectionConfirmationRequest,
|
||||
IndividualToolCallDisplay,
|
||||
HistoryItemToolGroup,
|
||||
} from '../types.js';
|
||||
import { StreamingState, MessageType } from '../types.js';
|
||||
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
|
||||
import { getToolGroupBorderAppearance } from '../utils/borderStyles.js';
|
||||
import { type BackgroundTask } from './useExecutionLifecycle.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||
import { useStateAndRef } from './useStateAndRef.js';
|
||||
import { type MinimalTrackedToolCall } from './useTurnActivityMonitor.js';
|
||||
|
||||
export interface UseAgentStreamOptions {
|
||||
agent?: AgentProtocol;
|
||||
addItem: UseHistoryManagerReturn['addItem'];
|
||||
onCancelSubmit: (shouldRestorePrompt?: boolean) => void;
|
||||
isShellFocused?: boolean;
|
||||
logger?: Logger | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* useAgentStream implements the interactive agent loop using an AgentProtocol.
|
||||
* It is completely agnostic to the specific agent implementation.
|
||||
*/
|
||||
export const useAgentStream = ({
|
||||
agent,
|
||||
addItem,
|
||||
onCancelSubmit,
|
||||
isShellFocused,
|
||||
logger,
|
||||
}: UseAgentStreamOptions) => {
|
||||
const [initError] = useState<string | null>(null);
|
||||
const [retryStatus] = useState<RetryAttemptPayload | null>(null);
|
||||
const [streamingState, setStreamingState] = useState<StreamingState>(
|
||||
StreamingState.Idle,
|
||||
);
|
||||
const [thought, setThought] = useState<ThoughtSummary | null>(null);
|
||||
|
||||
const currentStreamIdRef = useRef<string | null>(null);
|
||||
const userMessageTimestampRef = useRef<number>(0);
|
||||
const geminiMessageBufferRef = useRef<string>('');
|
||||
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
|
||||
useStateAndRef<HistoryItemWithoutId | null>(null);
|
||||
|
||||
const [trackedTools, , setTrackedTools] = useStateAndRef<
|
||||
IndividualToolCallDisplay[]
|
||||
>([]);
|
||||
const [pushedToolCallIds, pushedToolCallIdsRef, setPushedToolCallIds] =
|
||||
useStateAndRef<Set<string>>(new Set());
|
||||
const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
|
||||
useStateAndRef<boolean>(true);
|
||||
|
||||
const { startNewPrompt } = useSessionStats();
|
||||
|
||||
const activePtyId = undefined;
|
||||
const backgroundTaskCount = 0;
|
||||
const isBackgroundTaskVisible = false;
|
||||
const toggleBackgroundTasks = useCallback(() => {}, []);
|
||||
const backgroundCurrentExecution = undefined;
|
||||
const backgroundTasks = useMemo(() => new Map<number, BackgroundTask>(), []);
|
||||
const dismissBackgroundTask = useCallback(async (_pid: number) => {}, []);
|
||||
|
||||
// Use the trackedTools to mock pendingToolCalls for inactivity monitors
|
||||
const pendingToolCalls = useMemo(
|
||||
(): MinimalTrackedToolCall[] =>
|
||||
trackedTools.map((t) => ({
|
||||
request: {
|
||||
name: t.originalRequestName || t.name,
|
||||
args: { command: t.description },
|
||||
callId: t.callId,
|
||||
isClientInitiated: t.isClientInitiated ?? false,
|
||||
prompt_id: '',
|
||||
},
|
||||
status: t.status,
|
||||
})),
|
||||
[trackedTools],
|
||||
);
|
||||
|
||||
const lastOutputTime = Date.now(); // We could track actual time if needed, simplified for now
|
||||
|
||||
// TODO: Support LoopDetection confirmation requests
|
||||
const [loopDetectionConfirmationRequest] =
|
||||
useState<LoopDetectionConfirmationRequest | null>(null);
|
||||
|
||||
const flushPendingText = useCallback(() => {
|
||||
if (pendingHistoryItemRef.current) {
|
||||
addItem(pendingHistoryItemRef.current, userMessageTimestampRef.current);
|
||||
setPendingHistoryItem(null);
|
||||
geminiMessageBufferRef.current = '';
|
||||
}
|
||||
}, [addItem, pendingHistoryItemRef, setPendingHistoryItem]);
|
||||
|
||||
const cancelOngoingRequest = useCallback(async () => {
|
||||
if (agent) {
|
||||
await agent.abort();
|
||||
setStreamingState(StreamingState.Idle);
|
||||
onCancelSubmit(false);
|
||||
}
|
||||
}, [agent, onCancelSubmit]);
|
||||
|
||||
// TODO: Support native handleApprovalModeChange for Plan Mode
|
||||
const handleApprovalModeChange = useCallback(
|
||||
async (newApprovalMode: ApprovalMode) => {
|
||||
debugLogger.debug(`Approval mode changed to ${newApprovalMode} (stub)`);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleEvent = useCallback(
|
||||
(event: AgentEvent) => {
|
||||
switch (event.type) {
|
||||
case 'agent_start':
|
||||
setStreamingState(StreamingState.Responding);
|
||||
break;
|
||||
case 'agent_end':
|
||||
setStreamingState(StreamingState.Idle);
|
||||
flushPendingText();
|
||||
break;
|
||||
case 'message':
|
||||
if (event.role === 'agent') {
|
||||
for (const part of event.content) {
|
||||
if (part.type === 'text') {
|
||||
geminiMessageBufferRef.current += part.text;
|
||||
// Update pending history item with incremental text
|
||||
const splitPoint = findLastSafeSplitPoint(
|
||||
geminiMessageBufferRef.current,
|
||||
);
|
||||
if (splitPoint === geminiMessageBufferRef.current.length) {
|
||||
setPendingHistoryItem({
|
||||
type: 'gemini',
|
||||
text: geminiMessageBufferRef.current,
|
||||
});
|
||||
} else {
|
||||
const before = geminiMessageBufferRef.current.substring(
|
||||
0,
|
||||
splitPoint,
|
||||
);
|
||||
const after =
|
||||
geminiMessageBufferRef.current.substring(splitPoint);
|
||||
addItem(
|
||||
{ type: 'gemini', text: before },
|
||||
userMessageTimestampRef.current,
|
||||
);
|
||||
geminiMessageBufferRef.current = after;
|
||||
setPendingHistoryItem({
|
||||
type: 'gemini_content',
|
||||
text: after,
|
||||
});
|
||||
}
|
||||
} else if (part.type === 'thought') {
|
||||
setThought(parseThought(part.thought));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'tool_request': {
|
||||
flushPendingText();
|
||||
const legacyState = event._meta?.legacyState;
|
||||
const displayName = legacyState?.displayName ?? event.name;
|
||||
const isOutputMarkdown = legacyState?.isOutputMarkdown ?? false;
|
||||
const desc = legacyState?.description ?? '';
|
||||
|
||||
const fallbackKind = Kind.Other;
|
||||
|
||||
const newCall: IndividualToolCallDisplay = {
|
||||
callId: event.requestId,
|
||||
name: displayName,
|
||||
originalRequestName: event.name,
|
||||
description: desc,
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
isClientInitiated: false,
|
||||
renderOutputAsMarkdown: isOutputMarkdown,
|
||||
kind: legacyState?.kind ?? fallbackKind,
|
||||
confirmationDetails: undefined,
|
||||
resultDisplay: undefined,
|
||||
};
|
||||
setTrackedTools((prev) => [...prev, newCall]);
|
||||
break;
|
||||
}
|
||||
case 'tool_update': {
|
||||
setTrackedTools((prev) =>
|
||||
prev.map((tc): IndividualToolCallDisplay => {
|
||||
if (tc.callId !== event.requestId) return tc;
|
||||
|
||||
const legacyState = event._meta?.legacyState;
|
||||
const evtStatus = legacyState?.status;
|
||||
|
||||
let status = tc.status;
|
||||
if (evtStatus === 'executing')
|
||||
status = CoreToolCallStatus.Executing;
|
||||
else if (evtStatus === 'error') status = CoreToolCallStatus.Error;
|
||||
else if (evtStatus === 'success')
|
||||
status = CoreToolCallStatus.Success;
|
||||
|
||||
const liveOutput =
|
||||
event.displayContent?.[0]?.type === 'text'
|
||||
? event.displayContent[0].text
|
||||
: tc.resultDisplay;
|
||||
const progressMessage =
|
||||
legacyState?.progressMessage ?? tc.progressMessage;
|
||||
const progress = legacyState?.progress ?? tc.progress;
|
||||
const progressTotal =
|
||||
legacyState?.progressTotal ?? tc.progressTotal;
|
||||
const ptyId = legacyState?.pid ?? tc.ptyId;
|
||||
const description = legacyState?.description ?? tc.description;
|
||||
|
||||
return {
|
||||
...tc,
|
||||
status,
|
||||
resultDisplay: liveOutput,
|
||||
progressMessage,
|
||||
progress,
|
||||
progressTotal,
|
||||
ptyId,
|
||||
description,
|
||||
};
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'tool_response': {
|
||||
setTrackedTools((prev) =>
|
||||
prev.map((tc): IndividualToolCallDisplay => {
|
||||
if (tc.callId !== event.requestId) return tc;
|
||||
|
||||
const legacyState = event._meta?.legacyState;
|
||||
const outputFile = legacyState?.outputFile;
|
||||
const resultDisplay =
|
||||
event.displayContent?.[0]?.type === 'text'
|
||||
? event.displayContent[0].text
|
||||
: tc.resultDisplay;
|
||||
|
||||
return {
|
||||
...tc,
|
||||
status: event.isError
|
||||
? CoreToolCallStatus.Error
|
||||
: CoreToolCallStatus.Success,
|
||||
resultDisplay,
|
||||
outputFile,
|
||||
};
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error':
|
||||
addItem(
|
||||
{ type: MessageType.ERROR, text: event.message },
|
||||
userMessageTimestampRef.current,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[addItem, flushPendingText, setPendingHistoryItem, setTrackedTools],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (agent) {
|
||||
return agent.subscribe(handleEvent);
|
||||
}
|
||||
return undefined;
|
||||
}, [agent, handleEvent]);
|
||||
|
||||
const submitQuery = useCallback(
|
||||
async (
|
||||
query: Array<import('@google/gemini-cli-core').Part> | string,
|
||||
options?: { isContinuation: boolean },
|
||||
_prompt_id?: string,
|
||||
) => {
|
||||
if (!agent) return;
|
||||
|
||||
const timestamp = Date.now();
|
||||
userMessageTimestampRef.current = timestamp;
|
||||
geminiMessageBufferRef.current = '';
|
||||
|
||||
if (!options?.isContinuation) {
|
||||
if (typeof query === 'string') {
|
||||
addItem({ type: MessageType.USER, text: query }, timestamp);
|
||||
void logger?.logMessage(MessageSenderType.USER, query);
|
||||
}
|
||||
startNewPrompt();
|
||||
}
|
||||
|
||||
const parts = geminiPartsToContentParts(
|
||||
typeof query === 'string' ? [{ text: query }] : query,
|
||||
);
|
||||
|
||||
try {
|
||||
const { streamId } = await agent.send({
|
||||
message: { content: parts },
|
||||
});
|
||||
currentStreamIdRef.current = streamId;
|
||||
} catch (err) {
|
||||
addItem(
|
||||
{ type: MessageType.ERROR, text: getErrorMessage(err) },
|
||||
timestamp,
|
||||
);
|
||||
}
|
||||
},
|
||||
[agent, addItem, logger, startNewPrompt],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (trackedTools.length > 0) {
|
||||
const isNewBatch = !trackedTools.some((tc) =>
|
||||
pushedToolCallIdsRef.current.has(tc.callId),
|
||||
);
|
||||
if (isNewBatch) {
|
||||
setPushedToolCallIds(new Set());
|
||||
setIsFirstToolInGroup(true);
|
||||
}
|
||||
} else if (streamingState === StreamingState.Idle) {
|
||||
setPushedToolCallIds(new Set());
|
||||
setIsFirstToolInGroup(true);
|
||||
}
|
||||
}, [
|
||||
trackedTools,
|
||||
pushedToolCallIdsRef,
|
||||
setPushedToolCallIds,
|
||||
setIsFirstToolInGroup,
|
||||
streamingState,
|
||||
]);
|
||||
|
||||
// Push completed tools to history
|
||||
useEffect(() => {
|
||||
const toolsToPush: IndividualToolCallDisplay[] = [];
|
||||
for (let i = 0; i < trackedTools.length; i++) {
|
||||
const tc = trackedTools[i];
|
||||
if (pushedToolCallIdsRef.current.has(tc.callId)) continue;
|
||||
|
||||
if (
|
||||
tc.status === 'success' ||
|
||||
tc.status === 'error' ||
|
||||
tc.status === 'cancelled'
|
||||
) {
|
||||
toolsToPush.push(tc);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolsToPush.length > 0) {
|
||||
const newPushed = new Set(pushedToolCallIdsRef.current);
|
||||
for (const tc of toolsToPush) {
|
||||
newPushed.add(tc.callId);
|
||||
}
|
||||
|
||||
const isLastInBatch =
|
||||
toolsToPush[toolsToPush.length - 1] ===
|
||||
trackedTools[trackedTools.length - 1];
|
||||
|
||||
const appearance = getToolGroupBorderAppearance(
|
||||
{ type: 'tool_group', tools: trackedTools },
|
||||
activePtyId,
|
||||
!!isShellFocused,
|
||||
[],
|
||||
backgroundTasks,
|
||||
);
|
||||
|
||||
const historyItem: HistoryItemToolGroup = {
|
||||
type: 'tool_group',
|
||||
tools: toolsToPush,
|
||||
borderTop: isFirstToolInGroupRef.current,
|
||||
borderBottom: isLastInBatch,
|
||||
...appearance,
|
||||
};
|
||||
|
||||
addItem(historyItem);
|
||||
setPushedToolCallIds(newPushed);
|
||||
setIsFirstToolInGroup(false);
|
||||
}
|
||||
}, [
|
||||
trackedTools,
|
||||
pushedToolCallIdsRef,
|
||||
isFirstToolInGroupRef,
|
||||
setPushedToolCallIds,
|
||||
setIsFirstToolInGroup,
|
||||
addItem,
|
||||
activePtyId,
|
||||
isShellFocused,
|
||||
backgroundTasks,
|
||||
]);
|
||||
|
||||
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
|
||||
const remainingTools = trackedTools.filter(
|
||||
(tc) => !pushedToolCallIds.has(tc.callId),
|
||||
);
|
||||
|
||||
const items: HistoryItemWithoutId[] = [];
|
||||
|
||||
const appearance = getToolGroupBorderAppearance(
|
||||
{ type: 'tool_group', tools: trackedTools },
|
||||
activePtyId,
|
||||
!!isShellFocused,
|
||||
[],
|
||||
backgroundTasks,
|
||||
);
|
||||
|
||||
if (remainingTools.length > 0) {
|
||||
items.push({
|
||||
type: 'tool_group',
|
||||
tools: remainingTools,
|
||||
borderTop: pushedToolCallIds.size === 0,
|
||||
borderBottom: false,
|
||||
...appearance,
|
||||
});
|
||||
}
|
||||
|
||||
const allTerminal =
|
||||
trackedTools.length > 0 &&
|
||||
trackedTools.every(
|
||||
(tc) =>
|
||||
tc.status === 'success' ||
|
||||
tc.status === 'error' ||
|
||||
tc.status === 'cancelled',
|
||||
);
|
||||
|
||||
const allPushed =
|
||||
trackedTools.length > 0 &&
|
||||
trackedTools.every((tc) => pushedToolCallIds.has(tc.callId));
|
||||
|
||||
const anyVisibleInHistory = pushedToolCallIds.size > 0;
|
||||
const anyVisibleInPending = remainingTools.length > 0;
|
||||
|
||||
if (
|
||||
trackedTools.length > 0 &&
|
||||
!(allTerminal && allPushed) &&
|
||||
(anyVisibleInHistory || anyVisibleInPending)
|
||||
) {
|
||||
items.push({
|
||||
type: 'tool_group' as const,
|
||||
tools: [],
|
||||
borderTop: false,
|
||||
borderBottom: true,
|
||||
...appearance,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [
|
||||
trackedTools,
|
||||
pushedToolCallIds,
|
||||
activePtyId,
|
||||
isShellFocused,
|
||||
backgroundTasks,
|
||||
]);
|
||||
|
||||
const pendingHistoryItems = useMemo(
|
||||
() =>
|
||||
[pendingHistoryItem, ...pendingToolGroupItems].filter(
|
||||
(i): i is HistoryItemWithoutId => i !== undefined && i !== null,
|
||||
),
|
||||
[pendingHistoryItem, pendingToolGroupItems],
|
||||
);
|
||||
|
||||
return {
|
||||
streamingState,
|
||||
submitQuery,
|
||||
initError,
|
||||
pendingHistoryItems,
|
||||
thought,
|
||||
cancelOngoingRequest,
|
||||
pendingToolCalls,
|
||||
handleApprovalModeChange,
|
||||
activePtyId,
|
||||
loopDetectionConfirmationRequest,
|
||||
lastOutputTime,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
toggleBackgroundTasks,
|
||||
backgroundCurrentExecution,
|
||||
backgroundTasks,
|
||||
retryStatus,
|
||||
dismissBackgroundTask,
|
||||
};
|
||||
};
|
||||
@@ -5,20 +5,22 @@
|
||||
*/
|
||||
|
||||
import { useInactivityTimer } from './useInactivityTimer.js';
|
||||
import { useTurnActivityMonitor } from './useTurnActivityMonitor.js';
|
||||
import {
|
||||
useTurnActivityMonitor,
|
||||
type MinimalTrackedToolCall,
|
||||
} from './useTurnActivityMonitor.js';
|
||||
import {
|
||||
SHELL_FOCUS_HINT_DELAY_MS,
|
||||
SHELL_ACTION_REQUIRED_TITLE_DELAY_MS,
|
||||
SHELL_SILENT_WORKING_TITLE_DELAY_MS,
|
||||
} from '../constants.js';
|
||||
import type { StreamingState } from '../types.js';
|
||||
import { type TrackedToolCall } from './useToolScheduler.js';
|
||||
|
||||
interface ShellInactivityStatusProps {
|
||||
activePtyId: number | string | null | undefined;
|
||||
lastOutputTime: number;
|
||||
streamingState: StreamingState;
|
||||
pendingToolCalls: TrackedToolCall[];
|
||||
pendingToolCalls: MinimalTrackedToolCall[];
|
||||
embeddedShellFocused: boolean;
|
||||
isInteractiveShellEnabled: boolean;
|
||||
}
|
||||
|
||||
@@ -31,9 +31,10 @@ export type CancelAllFn = (signal: AbortSignal) => void;
|
||||
* The shape expected by useGeminiStream.
|
||||
* It matches the Core ToolCall structure + the UI metadata flag.
|
||||
*/
|
||||
export type TrackedToolCall = ToolCall & {
|
||||
responseSubmittedToGemini?: boolean;
|
||||
};
|
||||
export type Tracked<T> = T extends unknown
|
||||
? T & { responseSubmittedToGemini?: boolean }
|
||||
: never;
|
||||
export type TrackedToolCall = Tracked<ToolCall>;
|
||||
|
||||
// Narrowed types for specific statuses (used by useGeminiStream)
|
||||
export type TrackedScheduledToolCall = Extract<
|
||||
@@ -75,6 +76,7 @@ export function useToolScheduler(
|
||||
React.Dispatch<React.SetStateAction<TrackedToolCall[]>>,
|
||||
CancelAllFn,
|
||||
number,
|
||||
Scheduler,
|
||||
] {
|
||||
// State stores tool calls organized by their originating schedulerId
|
||||
const [toolCallsMap, setToolCallsMap] = useState<
|
||||
@@ -257,6 +259,7 @@ export function useToolScheduler(
|
||||
setToolCallsForDisplay,
|
||||
cancelAll,
|
||||
lastToolOutputTime,
|
||||
scheduler,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,16 @@
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { hasRedirection } from '@google/gemini-cli-core';
|
||||
import { type TrackedToolCall } from './useToolScheduler.js';
|
||||
import {
|
||||
hasRedirection,
|
||||
type CoreToolCallStatus,
|
||||
type ToolCallRequestInfo,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export interface MinimalTrackedToolCall {
|
||||
status: CoreToolCallStatus;
|
||||
request: ToolCallRequestInfo;
|
||||
}
|
||||
|
||||
export interface TurnActivityStatus {
|
||||
operationStartTime: number;
|
||||
@@ -21,7 +29,7 @@ export interface TurnActivityStatus {
|
||||
export const useTurnActivityMonitor = (
|
||||
streamingState: StreamingState,
|
||||
activePtyId: number | string | null | undefined,
|
||||
pendingToolCalls: TrackedToolCall[] = [],
|
||||
pendingToolCalls: MinimalTrackedToolCall[] = [],
|
||||
): TurnActivityStatus => {
|
||||
const [operationStartTime, setOperationStartTime] = useState(0);
|
||||
|
||||
|
||||
@@ -40,15 +40,14 @@ export const DefaultAppLayout: React.FC = () => {
|
||||
<MainContent />
|
||||
|
||||
{uiState.isBackgroundTaskVisible &&
|
||||
(uiState.backgroundTasks.size > 0 ||
|
||||
uiState.scheduledTasks.length > 0) &&
|
||||
uiState.backgroundTasks.size > 0 &&
|
||||
uiState.activeBackgroundTaskPid &&
|
||||
uiState.backgroundTaskHeight > 0 &&
|
||||
uiState.streamingState !== StreamingState.WaitingForConfirmation && (
|
||||
<Box height={uiState.backgroundTaskHeight} flexShrink={0}>
|
||||
<BackgroundTaskDisplay
|
||||
shells={uiState.backgroundTasks}
|
||||
scheduledTasks={uiState.scheduledTasks}
|
||||
activePid={uiState.activeBackgroundTaskPid ?? 0}
|
||||
activePid={uiState.activeBackgroundTaskPid}
|
||||
width={uiState.terminalWidth}
|
||||
height={uiState.backgroundTaskHeight}
|
||||
isFocused={
|
||||
|
||||
@@ -29,7 +29,10 @@ export function getToolGroupBorderAppearance(
|
||||
item:
|
||||
| HistoryItem
|
||||
| HistoryItemWithoutId
|
||||
| { type: 'tool_group'; tools: TrackedToolCall[] },
|
||||
| {
|
||||
type: 'tool_group';
|
||||
tools: Array<IndividualToolCallDisplay | TrackedToolCall>;
|
||||
},
|
||||
activeShellPtyId: number | null | undefined,
|
||||
embeddedShellFocused: boolean | undefined,
|
||||
allPendingItems: HistoryItemWithoutId[] = [],
|
||||
@@ -41,7 +44,7 @@ export function getToolGroupBorderAppearance(
|
||||
|
||||
// If this item has no tools, it's a closing slice for the current batch.
|
||||
// We need to look at the last pending item to determine the batch's appearance.
|
||||
const toolsToInspect: Array<IndividualToolCallDisplay | TrackedToolCall> =
|
||||
const toolsToInspect =
|
||||
item.tools.length > 0
|
||||
? item.tools
|
||||
: allPendingItems
|
||||
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
isFatalToolError,
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
getErrorMessage,
|
||||
getErrorType,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { runSyncCleanup } from './cleanup.js';
|
||||
|
||||
|
||||
@@ -7,7 +7,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AgentSession } from './agent-session.js';
|
||||
import { MockAgentProtocol } from './mock.js';
|
||||
import type { AgentEvent } from './types.js';
|
||||
import type { AgentEvent, AgentSend } from './types.js';
|
||||
|
||||
function makeMessageSend(
|
||||
text: string,
|
||||
displayContent?: string,
|
||||
): Extract<AgentSend, { message: unknown }> {
|
||||
return {
|
||||
message: {
|
||||
content: [{ type: 'text', text }],
|
||||
...(displayContent ? { displayContent } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('AgentSession', () => {
|
||||
it('should passthrough simple methods', async () => {
|
||||
@@ -51,7 +63,7 @@ describe('AgentSession', () => {
|
||||
|
||||
const events: AgentEvent[] = [];
|
||||
for await (const event of session.sendStream({
|
||||
message: [{ type: 'text', text: 'hi' }],
|
||||
...makeMessageSend('hi'),
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
@@ -139,7 +151,7 @@ describe('AgentSession', () => {
|
||||
|
||||
const events: AgentEvent[] = [];
|
||||
for await (const event of session.sendStream({
|
||||
message: [{ type: 'text', text: 'hi' }],
|
||||
...makeMessageSend('hi'),
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
@@ -178,7 +190,7 @@ describe('AgentSession', () => {
|
||||
|
||||
protocol.pushResponse([{ type: 'message' }]);
|
||||
const { streamId } = await session.send({
|
||||
message: [{ type: 'text', text: 'request' }],
|
||||
...makeMessageSend('request'),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
@@ -242,7 +254,7 @@ describe('AgentSession', () => {
|
||||
},
|
||||
]);
|
||||
await session.send({
|
||||
message: [{ type: 'text', text: 'request' }],
|
||||
...makeMessageSend('request'),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
@@ -303,7 +315,7 @@ describe('AgentSession', () => {
|
||||
},
|
||||
]);
|
||||
const { streamId: streamId1 } = await session.send({
|
||||
message: [{ type: 'text', text: 'first request' }],
|
||||
...makeMessageSend('first request'),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
@@ -315,7 +327,7 @@ describe('AgentSession', () => {
|
||||
},
|
||||
]);
|
||||
await session.send({
|
||||
message: [{ type: 'text', text: 'second request' }],
|
||||
...makeMessageSend('second request'),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
|
||||
@@ -679,6 +679,7 @@ describe('mapError', () => {
|
||||
expect(result.status).toBe('RESOURCE_EXHAUSTED');
|
||||
expect(result.message).toBe('Rate limit');
|
||||
expect(result.fatal).toBe(true);
|
||||
expect(result._meta?.['status']).toBe(429);
|
||||
expect(result._meta?.['rawError']).toEqual({
|
||||
message: 'Rate limit',
|
||||
status: 429,
|
||||
|
||||
@@ -403,7 +403,7 @@ export function mapError(
|
||||
}
|
||||
|
||||
if (isStructuredError(error)) {
|
||||
const structuredMeta = { ...meta, rawError: error };
|
||||
const structuredMeta = { ...meta, rawError: error, status: error.status };
|
||||
return {
|
||||
status: mapHttpToGrpcStatus(error.status),
|
||||
message: error.message,
|
||||
|
||||
@@ -10,13 +10,16 @@ import { LegacyAgentSession } from './legacy-agent-session.js';
|
||||
import type { LegacyAgentSessionDeps } from './legacy-agent-session.js';
|
||||
import { GeminiEventType } from '../core/turn.js';
|
||||
import type { ServerGeminiStreamEvent } from '../core/turn.js';
|
||||
import type { AgentEvent } from './types.js';
|
||||
import type { AgentEvent, AgentSend } from './types.js';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
import type {
|
||||
CompletedToolCall,
|
||||
ToolCallRequestInfo,
|
||||
} from '../scheduler/types.js';
|
||||
import { CoreToolCallStatus } from '../scheduler/types.js';
|
||||
import type { GeminiClient } from '../core/client.js';
|
||||
import type { Scheduler } from '../scheduler/scheduler.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock helpers
|
||||
@@ -24,7 +27,7 @@ import { CoreToolCallStatus } from '../scheduler/types.js';
|
||||
|
||||
function createMockDeps(
|
||||
overrides?: Partial<LegacyAgentSessionDeps>,
|
||||
): LegacyAgentSessionDeps {
|
||||
): Required<LegacyAgentSessionDeps> {
|
||||
const mockClient = {
|
||||
sendMessageStream: vi.fn(),
|
||||
getChat: vi.fn().mockReturnValue({
|
||||
@@ -40,18 +43,22 @@ function createMockDeps(
|
||||
const mockConfig = {
|
||||
getMaxSessionTurns: vi.fn().mockReturnValue(-1),
|
||||
getModel: vi.fn().mockReturnValue('gemini-2.5-pro'),
|
||||
getGeminiClient: vi.fn().mockReturnValue(mockClient),
|
||||
getMessageBus: vi.fn().mockImplementation(() => ({
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
})),
|
||||
};
|
||||
|
||||
return {
|
||||
client: mockClient as unknown as LegacyAgentSessionDeps['client'],
|
||||
|
||||
scheduler: mockScheduler as unknown as LegacyAgentSessionDeps['scheduler'],
|
||||
|
||||
config: mockConfig as unknown as LegacyAgentSessionDeps['config'],
|
||||
client: mockClient as unknown as GeminiClient,
|
||||
scheduler: mockScheduler as unknown as Scheduler,
|
||||
config: mockConfig as unknown as Config,
|
||||
promptId: 'test-prompt',
|
||||
streamId: 'test-stream',
|
||||
getPreferredEditor: vi.fn().mockReturnValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
} as Required<LegacyAgentSessionDeps>;
|
||||
}
|
||||
|
||||
async function* makeStream(
|
||||
@@ -72,6 +79,18 @@ function makeToolRequest(callId: string, name: string): ToolCallRequestInfo {
|
||||
};
|
||||
}
|
||||
|
||||
function makeMessageSend(
|
||||
text: string,
|
||||
displayContent?: string,
|
||||
): Extract<AgentSend, { message: unknown }> {
|
||||
return {
|
||||
message: {
|
||||
content: [{ type: 'text', text }],
|
||||
...(displayContent ? { displayContent } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeCompletedToolCall(
|
||||
callId: string,
|
||||
name: string,
|
||||
@@ -117,7 +136,7 @@ async function collectEvents(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('LegacyAgentSession', () => {
|
||||
let deps: LegacyAgentSessionDeps;
|
||||
let deps: Required<LegacyAgentSessionDeps>;
|
||||
|
||||
beforeEach(() => {
|
||||
deps = createMockDeps();
|
||||
@@ -140,9 +159,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
const result = await session.send({
|
||||
message: [{ type: 'text', text: 'hi' }],
|
||||
});
|
||||
const result = await session.send(makeMessageSend('hi'));
|
||||
|
||||
expect(result.streamId).toBe('test-stream');
|
||||
});
|
||||
@@ -162,7 +179,10 @@ describe('LegacyAgentSession', () => {
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
const { streamId } = await session.send({
|
||||
message: [{ type: 'text', text: 'hi' }],
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
displayContent: 'raw input',
|
||||
},
|
||||
_meta: { source: 'user-test' },
|
||||
});
|
||||
|
||||
@@ -170,8 +190,19 @@ describe('LegacyAgentSession', () => {
|
||||
(e): e is AgentEvent<'message'> =>
|
||||
e.type === 'message' && e.role === 'user' && e.streamId === streamId,
|
||||
);
|
||||
expect(userMessage?.content).toEqual([{ type: 'text', text: 'hi' }]);
|
||||
expect(userMessage?.content).toEqual([
|
||||
{ type: 'text', text: 'raw input' },
|
||||
]);
|
||||
expect(userMessage?._meta).toEqual({ source: 'user-test' });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
[{ text: 'hi' }],
|
||||
expect.any(AbortSignal),
|
||||
'test-prompt',
|
||||
undefined,
|
||||
false,
|
||||
'raw input',
|
||||
);
|
||||
|
||||
await collectEvents(session, { streamId: streamId ?? undefined });
|
||||
});
|
||||
@@ -195,9 +226,7 @@ describe('LegacyAgentSession', () => {
|
||||
liveEvents.push(event);
|
||||
});
|
||||
|
||||
const { streamId } = await session.send({
|
||||
message: [{ type: 'text', text: 'hi' }],
|
||||
});
|
||||
const { streamId } = await session.send(makeMessageSend('hi'));
|
||||
|
||||
expect(streamId).toBe('test-stream');
|
||||
expect(liveEvents.some((event) => event.type === 'agent_start')).toBe(
|
||||
@@ -235,14 +264,12 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
const { streamId } = await session.send({
|
||||
message: [{ type: 'text', text: 'first' }],
|
||||
});
|
||||
const { streamId } = await session.send(makeMessageSend('first'));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await expect(
|
||||
session.send({ message: [{ type: 'text', text: 'second' }] }),
|
||||
).rejects.toThrow('cannot be called while a stream is active');
|
||||
await expect(session.send(makeMessageSend('second'))).rejects.toThrow(
|
||||
'cannot be called while a stream is active',
|
||||
);
|
||||
|
||||
resolveHang?.();
|
||||
await collectEvents(session, { streamId: streamId ?? undefined });
|
||||
@@ -273,16 +300,12 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
const first = await session.send({
|
||||
message: [{ type: 'text', text: 'first' }],
|
||||
});
|
||||
const first = await session.send(makeMessageSend('first'));
|
||||
const firstEvents = await collectEvents(session, {
|
||||
streamId: first.streamId ?? undefined,
|
||||
});
|
||||
|
||||
const second = await session.send({
|
||||
message: [{ type: 'text', text: 'second' }],
|
||||
});
|
||||
const second = await session.send(makeMessageSend('second'));
|
||||
const secondEvents = await collectEvents(session, {
|
||||
streamId: second.streamId ?? undefined,
|
||||
});
|
||||
@@ -330,7 +353,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const types = events.map((e) => e.type);
|
||||
@@ -387,7 +410,7 @@ describe('LegacyAgentSession', () => {
|
||||
]);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'read a file' }] });
|
||||
await session.send(makeMessageSend('read a file'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const types = events.map((e) => e.type);
|
||||
@@ -455,9 +478,7 @@ describe('LegacyAgentSession', () => {
|
||||
scheduleMock.mockResolvedValueOnce([errorToolCall]);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({
|
||||
message: [{ type: 'text', text: 'write file' }],
|
||||
});
|
||||
await session.send(makeMessageSend('write file'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const toolResp = events.find(
|
||||
@@ -506,9 +527,7 @@ describe('LegacyAgentSession', () => {
|
||||
scheduleMock.mockResolvedValueOnce([stopToolCall]);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({
|
||||
message: [{ type: 'text', text: 'do something' }],
|
||||
});
|
||||
await session.send(makeMessageSend('do something'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const streamEnd = events.find(
|
||||
@@ -552,9 +571,7 @@ describe('LegacyAgentSession', () => {
|
||||
scheduleMock.mockResolvedValueOnce([fatalToolCall]);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({
|
||||
message: [{ type: 'text', text: 'write file' }],
|
||||
});
|
||||
await session.send(makeMessageSend('write file'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const toolResp = events.find(
|
||||
@@ -592,7 +609,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const streamEnd = events.find(
|
||||
@@ -621,7 +638,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const blocked = events.find(
|
||||
@@ -663,7 +680,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const err = events.find(
|
||||
@@ -690,7 +707,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const warning = events.find(
|
||||
@@ -738,7 +755,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const streamEnd = events.find(
|
||||
@@ -762,7 +779,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const errorEvents = events.filter(
|
||||
@@ -799,9 +816,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
const { streamId } = await session.send({
|
||||
message: [{ type: 'text', text: 'hi' }],
|
||||
});
|
||||
const { streamId } = await session.send(makeMessageSend('hi'));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await session.abort();
|
||||
@@ -847,7 +862,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
|
||||
// Give the loop time to start processing
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
@@ -891,9 +906,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
const { streamId } = await session.send({
|
||||
message: [{ type: 'text', text: 'hi' }],
|
||||
});
|
||||
const { streamId } = await session.send(makeMessageSend('hi'));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
await session.abort();
|
||||
@@ -935,7 +948,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
await collectEvents(session);
|
||||
|
||||
expect(session.events.length).toBeGreaterThan(0);
|
||||
@@ -964,9 +977,7 @@ describe('LegacyAgentSession', () => {
|
||||
liveEvents.push(event);
|
||||
});
|
||||
|
||||
const { streamId } = await session.send({
|
||||
message: [{ type: 'text', text: 'hi' }],
|
||||
});
|
||||
const { streamId } = await session.send(makeMessageSend('hi'));
|
||||
await collectEvents(session, { streamId: streamId ?? undefined });
|
||||
unsubscribe();
|
||||
|
||||
@@ -1002,9 +1013,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
const first = await session.send({
|
||||
message: [{ type: 'text', text: 'first request' }],
|
||||
});
|
||||
const first = await session.send(makeMessageSend('first request'));
|
||||
await collectEvents(session, { streamId: first.streamId ?? undefined });
|
||||
|
||||
const liveEvents: AgentEvent[] = [];
|
||||
@@ -1012,9 +1021,7 @@ describe('LegacyAgentSession', () => {
|
||||
liveEvents.push(event);
|
||||
});
|
||||
|
||||
const second = await session.send({
|
||||
message: [{ type: 'text', text: 'second request' }],
|
||||
});
|
||||
const second = await session.send(makeMessageSend('second request'));
|
||||
await collectEvents(session, { streamId: second.streamId ?? undefined });
|
||||
unsubscribe();
|
||||
|
||||
@@ -1058,14 +1065,10 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
const first = await session.send({
|
||||
message: [{ type: 'text', text: 'first request' }],
|
||||
});
|
||||
const first = await session.send(makeMessageSend('first request'));
|
||||
await collectEvents(session, { streamId: first.streamId ?? undefined });
|
||||
|
||||
const second = await session.send({
|
||||
message: [{ type: 'text', text: 'second request' }],
|
||||
});
|
||||
const second = await session.send(makeMessageSend('second request'));
|
||||
await collectEvents(session, { streamId: second.streamId ?? undefined });
|
||||
|
||||
const firstStreamEvents = await collectEvents(session, {
|
||||
@@ -1120,14 +1123,10 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
const first = await session.send({
|
||||
message: [{ type: 'text', text: 'first request' }],
|
||||
});
|
||||
const first = await session.send(makeMessageSend('first request'));
|
||||
await collectEvents(session, { streamId: first.streamId ?? undefined });
|
||||
|
||||
await session.send({
|
||||
message: [{ type: 'text', text: 'second request' }],
|
||||
});
|
||||
await session.send(makeMessageSend('second request'));
|
||||
await collectEvents(session);
|
||||
|
||||
const firstAgentMessage = session.events.find(
|
||||
@@ -1175,7 +1174,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
@@ -1196,7 +1195,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
expect(events[events.length - 1]?.type).toBe('agent_end');
|
||||
@@ -1244,7 +1243,7 @@ describe('LegacyAgentSession', () => {
|
||||
]);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'do it' }] });
|
||||
await session.send(makeMessageSend('do it'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
// Only one agent_end at the very end
|
||||
@@ -1291,7 +1290,7 @@ describe('LegacyAgentSession', () => {
|
||||
]);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'go' }] });
|
||||
await session.send(makeMessageSend('go'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
// Should have at least one usage event from the intermediate Finished
|
||||
@@ -1314,7 +1313,7 @@ describe('LegacyAgentSession', () => {
|
||||
});
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const err = events.find(
|
||||
@@ -1342,7 +1341,7 @@ describe('LegacyAgentSession', () => {
|
||||
});
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const err = events.find(
|
||||
@@ -1365,7 +1364,7 @@ describe('LegacyAgentSession', () => {
|
||||
});
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const err = events.find(
|
||||
@@ -1385,7 +1384,7 @@ describe('LegacyAgentSession', () => {
|
||||
});
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const err = events.find(
|
||||
@@ -1405,7 +1404,7 @@ describe('LegacyAgentSession', () => {
|
||||
});
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const err = events.find(
|
||||
|
||||
@@ -14,10 +14,11 @@ import type { Part } from '@google/genai';
|
||||
import type { GeminiClient } from '../core/client.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { ToolCallRequestInfo } from '../scheduler/types.js';
|
||||
import type { Scheduler } from '../scheduler/scheduler.js';
|
||||
import { Scheduler } from '../scheduler/scheduler.js';
|
||||
import { recordToolCallInteractions } from '../code_assist/telemetry.js';
|
||||
import { ToolErrorType, isFatalToolError } from '../tools/tool-error.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import type { EditorType } from '../utils/editor.js';
|
||||
import {
|
||||
buildToolResponseData,
|
||||
contentPartsToGeminiParts,
|
||||
@@ -45,14 +46,15 @@ function isAbortLikeError(err: unknown): boolean {
|
||||
}
|
||||
|
||||
export interface LegacyAgentSessionDeps {
|
||||
client: GeminiClient;
|
||||
scheduler: Scheduler;
|
||||
config: Config;
|
||||
promptId: string;
|
||||
client?: GeminiClient;
|
||||
scheduler?: Scheduler;
|
||||
promptId?: string;
|
||||
streamId?: string;
|
||||
getPreferredEditor?: () => EditorType | undefined;
|
||||
}
|
||||
|
||||
class LegacyAgentProtocol implements AgentProtocol {
|
||||
export class LegacyAgentProtocol implements AgentProtocol {
|
||||
private _events: AgentEvent[] = [];
|
||||
private _subscribers = new Set<(event: AgentEvent) => void>();
|
||||
private _translationState: TranslationState;
|
||||
@@ -69,10 +71,16 @@ class LegacyAgentProtocol implements AgentProtocol {
|
||||
constructor(deps: LegacyAgentSessionDeps) {
|
||||
this._translationState = createTranslationState(deps.streamId);
|
||||
this._nextStreamIdOverride = deps.streamId;
|
||||
this._client = deps.client;
|
||||
this._scheduler = deps.scheduler;
|
||||
this._config = deps.config;
|
||||
this._promptId = deps.promptId;
|
||||
this._client = deps.client ?? deps.config.getGeminiClient();
|
||||
this._promptId = deps.promptId ?? deps.config.promptId ?? '';
|
||||
this._scheduler =
|
||||
deps.scheduler ??
|
||||
new Scheduler({
|
||||
context: deps.config,
|
||||
schedulerId: 'legacy-agent-scheduler',
|
||||
getPreferredEditor: deps.getPreferredEditor ?? (() => undefined),
|
||||
});
|
||||
}
|
||||
|
||||
get events(): readonly AgentEvent[] {
|
||||
@@ -105,12 +113,16 @@ class LegacyAgentProtocol implements AgentProtocol {
|
||||
|
||||
this._beginNewStream();
|
||||
const streamId = this._translationState.streamId;
|
||||
const parts = contentPartsToGeminiParts(message);
|
||||
const userMessage = this._makeUserMessageEvent(message, payload._meta);
|
||||
const parts = contentPartsToGeminiParts(message.content);
|
||||
const userMessage = this._makeUserMessageEvent(
|
||||
message.content,
|
||||
message.displayContent,
|
||||
payload._meta,
|
||||
);
|
||||
|
||||
this._emit([userMessage]);
|
||||
|
||||
this._scheduleRunLoop(parts);
|
||||
this._scheduleRunLoop(parts, message.displayContent);
|
||||
|
||||
return { streamId };
|
||||
}
|
||||
@@ -119,18 +131,24 @@ class LegacyAgentProtocol implements AgentProtocol {
|
||||
this._abortController.abort();
|
||||
}
|
||||
|
||||
private _scheduleRunLoop(initialParts: Part[]): void {
|
||||
private _scheduleRunLoop(
|
||||
initialParts: Part[],
|
||||
displayContent?: string,
|
||||
): void {
|
||||
// Use a macrotask so send() resolves with the streamId before agent_start
|
||||
// is emitted and consumers can attach to the stream without racing startup.
|
||||
setTimeout(() => {
|
||||
void this._runLoopInBackground(initialParts);
|
||||
void this._runLoopInBackground(initialParts, displayContent);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private async _runLoopInBackground(initialParts: Part[]): Promise<void> {
|
||||
private async _runLoopInBackground(
|
||||
initialParts: Part[],
|
||||
displayContent?: string,
|
||||
): Promise<void> {
|
||||
this._ensureAgentStart();
|
||||
try {
|
||||
await this._runLoop(initialParts);
|
||||
await this._runLoop(initialParts, displayContent);
|
||||
} catch (err: unknown) {
|
||||
if (this._abortController.signal.aborted || isAbortLikeError(err)) {
|
||||
this._ensureAgentEnd('aborted');
|
||||
@@ -141,8 +159,12 @@ class LegacyAgentProtocol implements AgentProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
private async _runLoop(initialParts: Part[]): Promise<void> {
|
||||
private async _runLoop(
|
||||
initialParts: Part[],
|
||||
initialDisplayContent?: string,
|
||||
): Promise<void> {
|
||||
let currentParts: Part[] = initialParts;
|
||||
let currentDisplayContent = initialDisplayContent;
|
||||
let turnCount = 0;
|
||||
const maxTurns = this._config.getMaxSessionTurns();
|
||||
|
||||
@@ -162,7 +184,11 @@ class LegacyAgentProtocol implements AgentProtocol {
|
||||
currentParts,
|
||||
this._abortController.signal,
|
||||
this._promptId,
|
||||
undefined,
|
||||
false,
|
||||
currentDisplayContent,
|
||||
);
|
||||
currentDisplayContent = undefined;
|
||||
|
||||
for await (const event of responseStream) {
|
||||
if (this._abortController.signal.aborted) {
|
||||
@@ -383,13 +409,17 @@ class LegacyAgentProtocol implements AgentProtocol {
|
||||
|
||||
private _makeUserMessageEvent(
|
||||
content: ContentPart[],
|
||||
displayContent?: string,
|
||||
meta?: Record<string, unknown>,
|
||||
): AgentEvent<'message'> {
|
||||
const eventContent: ContentPart[] = displayContent
|
||||
? [{ type: 'text', text: displayContent }]
|
||||
: content;
|
||||
const event = {
|
||||
...this._nextEventFields(),
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content,
|
||||
content: eventContent,
|
||||
...(meta ? { _meta: meta } : {}),
|
||||
} satisfies AgentEvent<'message'>;
|
||||
return event;
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('MockAgentProtocol', () => {
|
||||
const streamPromise = waitForStreamEnd(session);
|
||||
|
||||
const { streamId } = await session.send({
|
||||
message: [{ type: 'text', text: 'hi' }],
|
||||
message: { content: [{ type: 'text', text: 'hi' }] },
|
||||
});
|
||||
expect(streamId).toBeDefined();
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
AgentEventData,
|
||||
AgentProtocol,
|
||||
AgentSend,
|
||||
ContentPart,
|
||||
Unsubscribe,
|
||||
} from './types.js';
|
||||
|
||||
@@ -133,11 +134,17 @@ export class MockAgentProtocol implements AgentProtocol {
|
||||
|
||||
// 1. User/Update event (BEFORE agent_start)
|
||||
if ('message' in payload && payload.message) {
|
||||
const message = Array.isArray(payload.message)
|
||||
? { content: payload.message, displayContent: undefined }
|
||||
: payload.message;
|
||||
const userContent: ContentPart[] = message.displayContent
|
||||
? [{ type: 'text', text: message.displayContent }]
|
||||
: message.content;
|
||||
eventsToEmit.push(
|
||||
normalize({
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: payload.message,
|
||||
content: userContent,
|
||||
_meta: payload._meta,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Kind } from '../tools/tools.js';
|
||||
|
||||
export type WithMeta = { _meta?: Record<string, unknown> };
|
||||
|
||||
export type Unsubscribe = () => void;
|
||||
@@ -46,7 +48,10 @@ type RequireExactlyOne<T> = {
|
||||
}[keyof T];
|
||||
|
||||
interface AgentSendPayloads {
|
||||
message: ContentPart[];
|
||||
message: {
|
||||
content: ContentPart[];
|
||||
displayContent?: string;
|
||||
};
|
||||
elicitations: ElicitationResponse[];
|
||||
update: { title?: string; model?: string; config?: Record<string, unknown> };
|
||||
action: { type: string; data: unknown };
|
||||
@@ -177,6 +182,16 @@ export interface ToolRequest {
|
||||
name: string;
|
||||
/** The arguments for the tool. */
|
||||
args: Record<string, unknown>;
|
||||
/** UI specific metadata */
|
||||
_meta?: {
|
||||
legacyState?: {
|
||||
displayName?: string;
|
||||
isOutputMarkdown?: boolean;
|
||||
description?: string;
|
||||
kind?: Kind;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,6 +204,18 @@ export interface ToolUpdate {
|
||||
displayContent?: ContentPart[];
|
||||
content?: ContentPart[];
|
||||
data?: Record<string, unknown>;
|
||||
/** UI specific metadata */
|
||||
_meta?: {
|
||||
legacyState?: {
|
||||
status?: string;
|
||||
progressMessage?: string;
|
||||
progress?: number;
|
||||
progressTotal?: number;
|
||||
pid?: number;
|
||||
description?: string;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolResponse {
|
||||
@@ -202,6 +229,13 @@ export interface ToolResponse {
|
||||
data?: Record<string, unknown>;
|
||||
/** When true, the tool call encountered an error that will be sent to the model. */
|
||||
isError?: boolean;
|
||||
/** UI specific metadata */
|
||||
_meta?: {
|
||||
legacyState?: {
|
||||
outputFile?: string;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export type ElicitationRequest = {
|
||||
|
||||
@@ -78,11 +78,6 @@ import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
|
||||
import type { MCPOAuthConfig } from '../mcp/oauth-provider.js';
|
||||
import { ideContextStore } from '../ide/ideContext.js';
|
||||
import { WriteTodosTool } from '../tools/write-todos.js';
|
||||
import {
|
||||
ScheduleTaskTool,
|
||||
ListTasksTool,
|
||||
CancelTaskTool,
|
||||
} from '../tools/cronTools.js';
|
||||
import {
|
||||
StandardFileSystemService,
|
||||
type FileSystemService,
|
||||
@@ -689,6 +684,7 @@ export interface ConfigParameters {
|
||||
adminSkillsEnabled?: boolean;
|
||||
experimentalJitContext?: boolean;
|
||||
experimentalMemoryManager?: boolean;
|
||||
useAgentProtocol?: boolean;
|
||||
experimentalAgentHistoryTruncation?: boolean;
|
||||
experimentalAgentHistoryTruncationThreshold?: number;
|
||||
experimentalAgentHistoryRetainedMessages?: number;
|
||||
@@ -927,6 +923,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
private readonly experimentalJitContext: boolean;
|
||||
private readonly experimentalMemoryManager: boolean;
|
||||
private readonly useAgentProtocol: boolean;
|
||||
private readonly experimentalAgentHistoryTruncation: boolean;
|
||||
private readonly experimentalAgentHistoryTruncationThreshold: number;
|
||||
private readonly experimentalAgentHistoryRetainedMessages: number;
|
||||
@@ -1141,6 +1138,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
this.experimentalJitContext = params.experimentalJitContext ?? true;
|
||||
this.experimentalMemoryManager = params.experimentalMemoryManager ?? false;
|
||||
this.useAgentProtocol = params.useAgentProtocol ?? false;
|
||||
this.experimentalAgentHistoryTruncation =
|
||||
params.experimentalAgentHistoryTruncation ?? false;
|
||||
this.experimentalAgentHistoryTruncationThreshold =
|
||||
@@ -2343,6 +2341,12 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.experimentalMemoryManager;
|
||||
}
|
||||
|
||||
getExperimentalUseAgentProtocol(): boolean {
|
||||
return (
|
||||
this.useAgentProtocol ||
|
||||
process.env['GEMINI_CLI_USE_AGENT_PROTOCOL'] === 'true'
|
||||
);
|
||||
}
|
||||
isExperimentalAgentHistoryTruncationEnabled(): boolean {
|
||||
return this.experimentalAgentHistoryTruncation;
|
||||
}
|
||||
@@ -3444,15 +3448,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
maybeRegister(AskUserTool, () =>
|
||||
registry.registerTool(new AskUserTool(this.messageBus)),
|
||||
);
|
||||
maybeRegister(ScheduleTaskTool, () =>
|
||||
registry.registerTool(new ScheduleTaskTool(this.messageBus)),
|
||||
);
|
||||
maybeRegister(ListTasksTool, () =>
|
||||
registry.registerTool(new ListTasksTool(this.messageBus)),
|
||||
);
|
||||
maybeRegister(CancelTaskTool, () =>
|
||||
registry.registerTool(new CancelTaskTool(this.messageBus)),
|
||||
);
|
||||
if (this.getUseWriteTodos()) {
|
||||
maybeRegister(WriteTodosTool, () =>
|
||||
registry.registerTool(new WriteTodosTool(this.messageBus)),
|
||||
|
||||
@@ -137,7 +137,6 @@ export * from './services/sessionSummaryUtils.js';
|
||||
export * from './services/contextManager.js';
|
||||
export * from './services/trackerService.js';
|
||||
export * from './services/trackerTypes.js';
|
||||
export * from './services/cronSchedulerService.js';
|
||||
export * from './services/keychainService.js';
|
||||
export * from './services/keychainTypes.js';
|
||||
export * from './skills/skillManager.js';
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import type { SessionMetrics } from '../telemetry/uiTelemetry.js';
|
||||
import { getErrorType } from '../utils/errors.js';
|
||||
import type { JsonError, JsonOutput } from './types.js';
|
||||
|
||||
export class JsonFormatter {
|
||||
@@ -42,7 +43,7 @@ export class JsonFormatter {
|
||||
sessionId?: string,
|
||||
): string {
|
||||
const jsonError: JsonError = {
|
||||
type: error.constructor.name,
|
||||
type: getErrorType(error),
|
||||
message: stripAnsi(error.message),
|
||||
...(code && { code }),
|
||||
};
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
CronSchedulerService,
|
||||
type ScheduledTask,
|
||||
} from './cronSchedulerService.js';
|
||||
|
||||
describe('CronSchedulerService', () => {
|
||||
let service: CronSchedulerService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
service = new CronSchedulerService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should parse intervals and schedule a task', () => {
|
||||
const id = service.scheduleTask('5m', 'test prompt');
|
||||
const tasks = service.listTasks();
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].id).toBe(id);
|
||||
expect(tasks[0].prompt).toBe('test prompt');
|
||||
expect(tasks[0].intervalMs).toBe(5 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('should throw on invalid interval', () => {
|
||||
expect(() => service.scheduleTask('invalid', 'test prompt')).toThrow(
|
||||
/Invalid interval format/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit event when task is due', () => {
|
||||
const callback = vi.fn();
|
||||
service.on('task_due', callback);
|
||||
|
||||
service.scheduleTask('10s', 'test prompt');
|
||||
|
||||
// Advance 9 seconds, shouldn't fire
|
||||
vi.advanceTimersByTime(9000);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
// Advance 1 more second, should fire
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
const taskArg = callback.mock.calls[0][0] as ScheduledTask;
|
||||
expect(taskArg.prompt).toBe('test prompt');
|
||||
});
|
||||
|
||||
it('should handle recurring tasks correctly', () => {
|
||||
const callback = vi.fn();
|
||||
service.on('task_due', callback);
|
||||
|
||||
service.scheduleTask('10s', 'test prompt', true);
|
||||
|
||||
// First run
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second run
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle one-shot tasks correctly', () => {
|
||||
const callback = vi.fn();
|
||||
service.on('task_due', callback);
|
||||
|
||||
service.scheduleTask('10s', 'test prompt', false);
|
||||
|
||||
// First run
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(service.listTasks()).toHaveLength(0); // Task should be removed
|
||||
|
||||
// Advance again, shouldn't fire
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should cancel a task', () => {
|
||||
const id = service.scheduleTask('10s', 'test prompt');
|
||||
expect(service.listTasks()).toHaveLength(1);
|
||||
|
||||
service.cancelTask(id);
|
||||
expect(service.listTasks()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
export interface ScheduledTask {
|
||||
id: string;
|
||||
intervalMs: number | null; // null if one-shot (though this basic implementation focuses on recurring)
|
||||
prompt: string;
|
||||
createdAt: number;
|
||||
nextRunAt: number;
|
||||
isRecurring: boolean;
|
||||
}
|
||||
|
||||
export class CronSchedulerService extends EventEmitter {
|
||||
private tasks: Map<string, ScheduledTask> = new Map();
|
||||
private tickInterval: NodeJS.Timeout | null = null;
|
||||
private isRunning = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the background interval that checks for due tasks.
|
||||
*/
|
||||
start() {
|
||||
if (this.isRunning) return;
|
||||
this.isRunning = true;
|
||||
this.tickInterval = setInterval(() => this.tick(), 1000);
|
||||
// Don't prevent process exit if only the scheduler is running
|
||||
this.tickInterval.unref();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the background interval.
|
||||
*/
|
||||
stop() {
|
||||
this.isRunning = false;
|
||||
if (this.tickInterval) {
|
||||
clearInterval(this.tickInterval);
|
||||
this.tickInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a new task.
|
||||
* @param intervalString An interval string like "5m", "10s", "2h".
|
||||
* @param prompt The prompt to execute.
|
||||
* @param isRecurring Whether it's a recurring task or one-shot.
|
||||
* @returns The generated task ID.
|
||||
*/
|
||||
scheduleTask(
|
||||
intervalString: string,
|
||||
prompt: string,
|
||||
isRecurring: boolean = true,
|
||||
): string {
|
||||
const intervalMs = this.parseInterval(intervalString);
|
||||
if (!intervalMs) {
|
||||
throw new Error(
|
||||
`Invalid interval format: ${intervalString}. Supported formats: 10s, 5m, 2h.`,
|
||||
);
|
||||
}
|
||||
|
||||
const id = Math.random().toString(36).substring(2, 10); // 8-character ID
|
||||
const now = Date.now();
|
||||
|
||||
const task: ScheduledTask = {
|
||||
id,
|
||||
intervalMs,
|
||||
prompt,
|
||||
createdAt: now,
|
||||
nextRunAt: now + intervalMs,
|
||||
isRecurring,
|
||||
};
|
||||
|
||||
this.tasks.set(id, task);
|
||||
|
||||
if (!this.isRunning) {
|
||||
this.start();
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all active scheduled tasks.
|
||||
*/
|
||||
listTasks(): ScheduledTask[] {
|
||||
return Array.from(this.tasks.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a scheduled task by ID.
|
||||
*/
|
||||
cancelTask(id: string): boolean {
|
||||
return this.tasks.delete(id);
|
||||
}
|
||||
|
||||
private tick() {
|
||||
const now = Date.now();
|
||||
for (const [id, task] of this.tasks.entries()) {
|
||||
if (now >= task.nextRunAt) {
|
||||
// Emit the event so the REPL can pick it up
|
||||
this.emit('task_due', task);
|
||||
|
||||
if (task.isRecurring && task.intervalMs) {
|
||||
// Calculate next run time
|
||||
task.nextRunAt = now + task.intervalMs;
|
||||
} else {
|
||||
// One-shot, remove it
|
||||
this.tasks.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-stop if no tasks
|
||||
if (this.tasks.size === 0) {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses strings like "10s", "5m", "2h", "1d" into milliseconds.
|
||||
*/
|
||||
private parseInterval(interval: string): number | null {
|
||||
const match = interval.match(/^(\d+)([smhd])$/);
|
||||
if (!match) return null;
|
||||
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
|
||||
switch (unit) {
|
||||
case 's':
|
||||
return value * 1000;
|
||||
case 'm':
|
||||
return value * 60 * 1000;
|
||||
case 'h':
|
||||
return value * 60 * 60 * 1000;
|
||||
case 'd':
|
||||
return value * 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const cronSchedulerService = new CronSchedulerService();
|
||||
@@ -1,254 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Kind,
|
||||
type ToolResult,
|
||||
} from './tools.js';
|
||||
import { cronSchedulerService } from '../services/cronSchedulerService.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { Type, type FunctionDeclaration } from '@google/genai';
|
||||
|
||||
// --- Declarations ---
|
||||
|
||||
export const SCHEDULE_TASK_TOOL_NAME = 'schedule_task';
|
||||
export const LIST_TASKS_TOOL_NAME = 'list_scheduled_tasks';
|
||||
export const CANCEL_TASK_TOOL_NAME = 'cancel_scheduled_task';
|
||||
|
||||
export const SCHEDULE_TASK_DECLARATION: FunctionDeclaration = {
|
||||
name: SCHEDULE_TASK_TOOL_NAME,
|
||||
description: 'Schedules a prompt to run after a specified interval.',
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
interval: {
|
||||
type: Type.STRING,
|
||||
description: 'Interval string like "10s", "5m", "1h".',
|
||||
},
|
||||
prompt: {
|
||||
type: Type.STRING,
|
||||
description: 'The prompt to run when the task triggers.',
|
||||
},
|
||||
recurring: {
|
||||
type: Type.BOOLEAN,
|
||||
description: 'Whether the task should run repeatedly.',
|
||||
},
|
||||
},
|
||||
required: ['interval', 'prompt'],
|
||||
},
|
||||
};
|
||||
|
||||
export const LIST_TASKS_DECLARATION: FunctionDeclaration = {
|
||||
name: LIST_TASKS_TOOL_NAME,
|
||||
description: 'Lists all currently scheduled tasks.',
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {},
|
||||
},
|
||||
};
|
||||
|
||||
export const CANCEL_TASK_DECLARATION: FunctionDeclaration = {
|
||||
name: CANCEL_TASK_TOOL_NAME,
|
||||
description: 'Cancels a scheduled task by ID.',
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
id: {
|
||||
type: Type.STRING,
|
||||
description: 'The ID of the task to cancel.',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
};
|
||||
|
||||
// --- Invocations ---
|
||||
|
||||
interface ScheduleTaskParams {
|
||||
interval: string;
|
||||
prompt: string;
|
||||
recurring?: boolean;
|
||||
}
|
||||
|
||||
class ScheduleTaskInvocation extends BaseToolInvocation<
|
||||
ScheduleTaskParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
params: ScheduleTaskParams,
|
||||
messageBus: MessageBus,
|
||||
toolName: string,
|
||||
) {
|
||||
super(params, messageBus, toolName);
|
||||
}
|
||||
|
||||
override getDescription(): string {
|
||||
return `Schedule task: ${this.params.prompt} (Interval: ${this.params.interval})`;
|
||||
}
|
||||
|
||||
async execute(): Promise<ToolResult> {
|
||||
try {
|
||||
const isRecurring = this.params.recurring !== false;
|
||||
const id = cronSchedulerService.scheduleTask(
|
||||
this.params.interval,
|
||||
this.params.prompt,
|
||||
isRecurring,
|
||||
);
|
||||
return {
|
||||
llmContent: `Task scheduled successfully. ID: ${id}`,
|
||||
returnDisplay: `Task scheduled successfully. ID: ${id}`,
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return {
|
||||
llmContent: `Error scheduling task: ${message}`,
|
||||
returnDisplay: `Error scheduling task: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
override async getConfirmationDetails() {
|
||||
return false as const;
|
||||
}
|
||||
}
|
||||
|
||||
class ListTasksInvocation extends BaseToolInvocation<object, ToolResult> {
|
||||
constructor(params: object, messageBus: MessageBus, toolName: string) {
|
||||
super(params, messageBus, toolName);
|
||||
}
|
||||
|
||||
override getDescription(): string {
|
||||
return 'List scheduled tasks';
|
||||
}
|
||||
|
||||
async execute(): Promise<ToolResult> {
|
||||
const tasks = cronSchedulerService.listTasks();
|
||||
if (tasks.length === 0) {
|
||||
return {
|
||||
llmContent: 'No scheduled tasks.',
|
||||
returnDisplay: 'No scheduled tasks.',
|
||||
};
|
||||
}
|
||||
const lines = tasks.map(
|
||||
(t) =>
|
||||
`- ID: ${t.id}, Interval: ${t.intervalMs}ms, Prompt: "${t.prompt}", Recurring: ${t.isRecurring}`,
|
||||
);
|
||||
return { llmContent: lines.join('\n'), returnDisplay: lines.join('\n') };
|
||||
}
|
||||
|
||||
override async getConfirmationDetails() {
|
||||
return false as const;
|
||||
}
|
||||
}
|
||||
|
||||
interface CancelTaskParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
class CancelTaskInvocation extends BaseToolInvocation<
|
||||
CancelTaskParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
params: CancelTaskParams,
|
||||
messageBus: MessageBus,
|
||||
toolName: string,
|
||||
) {
|
||||
super(params, messageBus, toolName);
|
||||
}
|
||||
|
||||
override getDescription(): string {
|
||||
return `Cancel task ID: ${this.params.id}`;
|
||||
}
|
||||
|
||||
async execute(): Promise<ToolResult> {
|
||||
const success = cronSchedulerService.cancelTask(this.params.id);
|
||||
if (success) {
|
||||
return {
|
||||
llmContent: `Task ${this.params.id} cancelled successfully.`,
|
||||
returnDisplay: `Task ${this.params.id} cancelled successfully.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
llmContent: `Task ${this.params.id} not found.`,
|
||||
returnDisplay: `Task ${this.params.id} not found.`,
|
||||
};
|
||||
}
|
||||
|
||||
override async getConfirmationDetails() {
|
||||
return false as const;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tools ---
|
||||
|
||||
export class ScheduleTaskTool extends BaseDeclarativeTool<
|
||||
ScheduleTaskParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(messageBus: MessageBus) {
|
||||
super(
|
||||
SCHEDULE_TASK_TOOL_NAME,
|
||||
'ScheduleTask',
|
||||
SCHEDULE_TASK_DECLARATION.description ?? '',
|
||||
Kind.Other,
|
||||
SCHEDULE_TASK_DECLARATION.parameters,
|
||||
messageBus,
|
||||
);
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: ScheduleTaskParams,
|
||||
messageBus: MessageBus,
|
||||
): ScheduleTaskInvocation {
|
||||
return new ScheduleTaskInvocation(params, messageBus, this.name);
|
||||
}
|
||||
}
|
||||
|
||||
export class ListTasksTool extends BaseDeclarativeTool<object, ToolResult> {
|
||||
constructor(messageBus: MessageBus) {
|
||||
super(
|
||||
LIST_TASKS_TOOL_NAME,
|
||||
'ListTasks',
|
||||
LIST_TASKS_DECLARATION.description ?? '',
|
||||
Kind.Other,
|
||||
LIST_TASKS_DECLARATION.parameters,
|
||||
messageBus,
|
||||
);
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: object,
|
||||
messageBus: MessageBus,
|
||||
): ListTasksInvocation {
|
||||
return new ListTasksInvocation(params, messageBus, this.name);
|
||||
}
|
||||
}
|
||||
|
||||
export class CancelTaskTool extends BaseDeclarativeTool<
|
||||
CancelTaskParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(messageBus: MessageBus) {
|
||||
super(
|
||||
CANCEL_TASK_TOOL_NAME,
|
||||
'CancelTask',
|
||||
CANCEL_TASK_DECLARATION.description ?? '',
|
||||
Kind.Other,
|
||||
CANCEL_TASK_DECLARATION.parameters,
|
||||
messageBus,
|
||||
);
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: CancelTaskParams,
|
||||
messageBus: MessageBus,
|
||||
): CancelTaskInvocation {
|
||||
return new CancelTaskInvocation(params, messageBus, this.name);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user