Compare commits

..

31 Commits

Author SHA1 Message Date
Michael Bleigh d9e2c87131 Merge branch 'pr4-cli' into mb/agent-interactive 2026-03-30 12:59:33 -07:00
Michael Bleigh 3b0906f319 Merge branch 'main' into pr4-cli 2026-03-30 12:39:30 -07:00
Michael Bleigh cd7d91f35e refactor(cli): remove config dependency from useAgentStream 2026-03-30 12:35:31 -07:00
Michael Bleigh 6020551d1f Merge branch 'pr4-cli' of github.com:google-gemini/gemini-cli into mb/agent-interactive 2026-03-30 09:48:31 -07:00
Adam Weidman 8e9961a791 fixes 2026-03-29 17:43:07 -04:00
Michael Bleigh e046cbd8e0 fix: resolve rebase compilation and test failures 2026-03-27 16:05:06 -07:00
Michael Bleigh b810e57ef1 feat: add experimental useAgentProtocol flag 2026-03-27 15:42:12 -07:00
Michael Bleigh ecc9e50a1f fix: resolve typescript lint errors and test failures
- Remove unnecessary `any` casts and unsafe type assertions in `useAgentStream.ts`.
- Introduce `MinimalTrackedToolCall` to safely type mock tool calls for inactivity monitors.
- Fix arrow-body-style lint errors in `AppContainer.tsx` and `useAgentStream.ts`.
- Update `nonInteractiveCli.test.ts` to include a required `build` method in mock tools to prevent TypeErrors during stream initialization.
- Remove redundant non-null assertion in `legacy-agent-session.ts`.
2026-03-27 15:41:39 -07:00
joshualitt fd321abd3d feat(core): Land AgentHistoryProvider. (#23978) 2026-03-27 15:41:39 -07:00
Sri Pasumarthi 2bb161b36f fix(acp): prevent crash on empty response in ACP mode (#23952) 2026-03-27 15:35:58 -07:00
Jacob Richman 565851f2d5 Increase memory limited for eslint. (#24022) 2026-03-27 15:35:58 -07:00
Michael Bleigh 593c33f927 refactor(cli): simplify useAgentStream state to use IndividualToolCallDisplay
This commit refactors the `useAgentStream` hook to track its internal state using the lightweight `IndividualToolCallDisplay` interface instead of the heavyweight `TrackedToolCall`.

By mapping `AgentEvent` payloads directly to `IndividualToolCallDisplay`, we completely bypass the need for `DummyTool` re-hydration and the `mapToDisplay` adapter. This removes a redundant data bridging layer and properly aligns the UI state with the flattened data provided by the `AgentProtocol` in `legacyState`.
2026-03-27 15:35:58 -07:00
Michael Bleigh f85299717a refactor: allow GEMINI_CLI_USE_AGENT_PROTOCOL env to trigger agent use 2026-03-27 15:35:58 -07:00
Michael Bleigh 0afe5117a4 fix(cli,core): resolve lint and type errors in agent stream and core types 2026-03-27 15:35:54 -07:00
Michael Bleigh f853d2f9da fix: explicitly fail pending tool calls when approval is required
This commit ensures that when a tool call enters the 'awaiting_approval'
state (which is not yet supported in the new protocol), we not only
emit a fatal error but also emit a 'tool_response' event with 'isError: true'.

This allows the UI to correctly transition the tool call from a pending
(scheduled/executing) state to an error state, providing clear visual
feedback to the user and preventing the UI from appearing to hang.
2026-03-27 15:02:25 -07:00
Michael Bleigh 0f37dd1d78 fix: error out on tool approvals instead of hanging
This commit ensures that the interactive agent loop does not hang when
a tool requires manual approval (since confirmation dialogs are not yet
implemented for the new protocol).

Instead of waiting indefinitely, 'LegacyAgentSession' now detects the
'awaiting_approval' status, emits a descriptive error event, and
aborts the session.

Key changes:
- 'LegacyAgentSession' now emits status updates in 'tool_update' events.
- 'LegacyAgentSession' aborts and errors on 'awaiting_approval' status.
- 'useAgentStream' now correctly tracks and displays tool status transitions
  and intermediate progress via the 'AgentEvent' stream.
2026-03-27 15:02:25 -07:00
Michael Bleigh 87e9491e78 fix: ensure correct message ordering in agent stream
This commit fixes a bug where model text preceding a tool call would
be displayed after the tool call in the conversation history.

We now explicitly flush any pending agent text to the conversation
history (via 'addItem') immediately before a tool request is tracked.
This ensures the model's intent is rendered above the execution result.
2026-03-27 15:02:25 -07:00
Michael Bleigh 2a8918c72f feat: achieve rich tool call display parity for agent stream
This change ensures that tool execution in the Agent Protocol experiment
reaches visual parity with the legacy implementation by passing rich
metadata and objects (like FileDiff) through the AgentEvent stream.

Key changes:
- Core 'LegacyAgentSession' now builds temporary invocations for tool requests
  to capture rich, argument-aware descriptions (e.g. 'Writing to poem.md').
- Core 'LegacyAgentSession' now attaches the raw 'resultDisplay' object and
  'outputFile' path to the 'tool_response' event metadata.
- Core 'LegacyAgentSession' now passes the rich description through 'tool_update'
  events to ensure dynamic descriptions are updated during execution.
- UI 'useAgentStream' hook now extracts these rich values from event metadata
  to populate the local 'trackedTools' state, allowing 'mapToDisplay' to
  correctly trigger bespoke rendering components (like the diff viewer).
2026-03-27 15:02:25 -07:00
Michael Bleigh 6975224e45 feat: implement tool display parity for agent stream
This commit achieves visual parity for tool execution in the interactive stream when using the experimental 'useAgentProtocol' flag. It removes direct UI dependency on the tool scheduler's internal state.

Key changes:
- Core 'LegacyAgentSession' now attaches display metadata (displayName, description, etc.) to 'tool_request' AgentEvents.
- Core 'LegacyAgentSession' listens to the MessageBus to emit 'tool_update' AgentEvents for live output (e.g., shell commands).
- UI 'useAgentStream' now maintains its own 'trackedTools' local state, constructed entirely from incoming 'tool_request', 'tool_update', and 'tool_response' events.
- The local 'trackedTools' state is mapped to 'pendingToolGroupItems' using the existing 'mapToDisplay' function for seamless visual parity.
2026-03-27 15:02:18 -07:00
Michael Bleigh d3a7fc39e3 feat: implement experimental useAgentStream with unified agent protocol
This change introduces an experimental 'useAgentProtocol' flag to interactive mode.
When enabled, the UI uses the new 'useAgentStream' hook which leverages
the core 'LegacyAgentSession' (AgentProtocol) instead of the custom
'useGeminiStream' logic.

Key changes:
- Added 'useAgentProtocol' experimental setting to CLI and Core config.
- Implemented 'useAgentStream' hook with basic interaction and thought support.
- Modified 'useToolScheduler' to expose its internal Scheduler instance to ensure implementation parity.
- Updated 'AppContainer' to conditionally branch between implementations via ternary operator.
- Added comprehensive unit tests for the new hook.
2026-03-27 14:55:11 -07:00
Michael Bleigh 3b317f9198 feat: add experimental useAgentProtocol flag 2026-03-27 14:55:11 -07:00
Michael Bleigh 6a80311a37 Merge branch 'main' into pr4-cli 2026-03-27 11:08:15 -07:00
Adam Weidman 6fb7bcf868 fix(core): preserve first-turn display content 2026-03-26 11:51:15 -04:00
Adam Weidman 56656dfbc9 fix(cli): preserve max-turns non-interactive parity 2026-03-23 18:47:11 -04:00
Adam Weidman 93322430a0 fix(cli): align non-interactive loop warning handling 2026-03-23 18:38:56 -04:00
Adam Weidman 25c5d44678 !feat(cli): harden non-interactive agent session handling 2026-03-23 18:38:56 -04:00
Adam Weidman 2dfe237738 !feat(cli): address non-interactive review follow-ups 2026-03-23 18:38:56 -04:00
Adam Weidman f77e4716fa !feat(cli): adopt protocol-backed agent session streaming 2026-03-23 18:38:56 -04:00
Adam Weidman 3e0b8aa958 fix(cli): consume the stream returned by send 2026-03-23 18:38:56 -04:00
Adam Weidman b268e93a1d refactor(cli): handle max turns from stream end 2026-03-23 18:38:56 -04:00
Adam Weidman 218adcd6c3 feat(cli): migrate nonInteractiveCli to LegacyAgentSession 2026-03-23 18:38:56 -04:00
36 changed files with 1604 additions and 1255 deletions
-1
View File
@@ -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
-106
View File
@@ -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.
-1
View File
@@ -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": "🔬",
+1
View File
@@ -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,
+10
View File
@@ -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',
+246 -22
View File
@@ -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,
+303 -198
View File
@@ -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] : []),
+44 -48
View File
@@ -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);
});
});
+505
View File
@@ -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={
+5 -2
View File
@@ -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
+1 -1
View File
@@ -18,8 +18,8 @@ import {
isFatalToolError,
debugLogger,
coreEvents,
getErrorMessage,
getErrorType,
getErrorMessage,
} from '@google/gemini-cli-core';
import { runSyncCleanup } from './cleanup.js';
+19 -7
View File
@@ -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,
+1 -1
View File
@@ -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(
+47 -17
View File
@@ -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;
+1 -1
View File
@@ -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();
+8 -1
View File
@@ -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,
}),
);
+35 -1
View File
@@ -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 = {
+9 -14
View File
@@ -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)),
-1
View File
@@ -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';
+2 -1
View File
@@ -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();
-254
View File
@@ -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);
}
}