imcodes 2026.4.1873 → 2026.4.1875-dev.1861
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/shared/agent-types.d.ts +2 -2
- package/dist/shared/agent-types.d.ts.map +1 -1
- package/dist/shared/agent-types.js +2 -0
- package/dist/shared/agent-types.js.map +1 -1
- package/dist/shared/supervision-config.d.ts +1 -1
- package/dist/shared/supervision-config.d.ts.map +1 -1
- package/dist/src/agent/codex-runtime-config.d.ts +11 -0
- package/dist/src/agent/codex-runtime-config.d.ts.map +1 -1
- package/dist/src/agent/codex-runtime-config.js +162 -34
- package/dist/src/agent/codex-runtime-config.js.map +1 -1
- package/dist/src/agent/detect.d.ts +1 -1
- package/dist/src/agent/detect.d.ts.map +1 -1
- package/dist/src/agent/provider-registry.js +4 -0
- package/dist/src/agent/provider-registry.js.map +1 -1
- package/dist/src/agent/providers/codex-sdk.d.ts +7 -0
- package/dist/src/agent/providers/codex-sdk.d.ts.map +1 -1
- package/dist/src/agent/providers/codex-sdk.js +46 -0
- package/dist/src/agent/providers/codex-sdk.js.map +1 -1
- package/dist/src/agent/providers/gemini-sdk.d.ts +113 -0
- package/dist/src/agent/providers/gemini-sdk.d.ts.map +1 -0
- package/dist/src/agent/providers/gemini-sdk.js +795 -0
- package/dist/src/agent/providers/gemini-sdk.js.map +1 -0
- package/dist/src/daemon/codex-watcher.d.ts.map +1 -1
- package/dist/src/daemon/codex-watcher.js +61 -19
- package/dist/src/daemon/codex-watcher.js.map +1 -1
- package/dist/src/daemon/command-handler.d.ts.map +1 -1
- package/dist/src/daemon/command-handler.js +43 -4
- package/dist/src/daemon/command-handler.js.map +1 -1
- package/dist/src/daemon/jsonl-watcher.d.ts.map +1 -1
- package/dist/src/daemon/jsonl-watcher.js +72 -9
- package/dist/src/daemon/jsonl-watcher.js.map +1 -1
- package/dist/src/daemon/lifecycle.d.ts.map +1 -1
- package/dist/src/daemon/lifecycle.js +2 -1
- package/dist/src/daemon/lifecycle.js.map +1 -1
- package/dist/src/daemon/session-list.d.ts +1 -0
- package/dist/src/daemon/session-list.d.ts.map +1 -1
- package/dist/src/daemon/session-list.js +3 -0
- package/dist/src/daemon/session-list.js.map +1 -1
- package/dist/src/store/session-store.d.ts +3 -0
- package/dist/src/store/session-store.d.ts.map +1 -1
- package/dist/src/store/session-store.js.map +1 -1
- package/dist/web/src/i18n/locales/en.json +2 -1
- package/dist/web/src/i18n/locales/es.json +2 -1
- package/dist/web/src/i18n/locales/ja.json +2 -1
- package/dist/web/src/i18n/locales/ko.json +2 -1
- package/dist/web/src/i18n/locales/ru.json +2 -1
- package/dist/web/src/i18n/locales/zh-CN.json +2 -1
- package/dist/web/src/i18n/locales/zh-TW.json +2 -1
- package/package.json +5 -4
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GeminiSdkProvider — TransportProvider that drives `gemini --acp` over the
|
|
3
|
+
* Agent Client Protocol (ACP, https://agentclientprotocol.com/).
|
|
4
|
+
*
|
|
5
|
+
* Architecture
|
|
6
|
+
* ------------
|
|
7
|
+
* ACP is a JSON-RPC 2.0 protocol over stdio. We use the canonical TypeScript
|
|
8
|
+
* client from `@agentclientprotocol/sdk` (the same library the Gemini CLI
|
|
9
|
+
* itself implements on the agent side, see
|
|
10
|
+
* gemini-cli/packages/cli/src/acp/acpClient.ts) so we don't have to reimplement
|
|
11
|
+
* request/response correlation, ndjson framing, or bidirectional RPC routing.
|
|
12
|
+
*
|
|
13
|
+
* One `gemini --acp` child is spawned per daemon on connect() and held for
|
|
14
|
+
* the lifetime of the provider. Multiple ACP sessions are multiplexed over the
|
|
15
|
+
* single stdio connection — the SDK routes each notification/request by
|
|
16
|
+
* `sessionId`, we just maintain a `sessionId → route` map.
|
|
17
|
+
*
|
|
18
|
+
* Unlike QwenProvider (which spawns per-turn `qwen -p` processes and parses
|
|
19
|
+
* Anthropic-shaped stream-json lines), this provider:
|
|
20
|
+
* - Holds a single long-lived process (like CodexSdkProvider).
|
|
21
|
+
* - Emits completion when `agent.prompt()` resolves with `{ stopReason }`,
|
|
22
|
+
* not on a final line event.
|
|
23
|
+
* - Routes tool_call / tool_call_update events by ACP `toolCallId`.
|
|
24
|
+
*
|
|
25
|
+
* Session persistence
|
|
26
|
+
* -------------------
|
|
27
|
+
* Gemini CLI persists session history to
|
|
28
|
+
* `~/.gemini/tmp/<project>/chats/<sessionId>.json`, so `session/load` works
|
|
29
|
+
* across process restarts. We call `newSession` on first send and `loadSession`
|
|
30
|
+
* when a `resumeId` is present.
|
|
31
|
+
*
|
|
32
|
+
* Limitations (intentional for MVP)
|
|
33
|
+
* ---------------------------------
|
|
34
|
+
* - Reasoning effort: ACP has no per-session effort knob; Gemini bakes thinking
|
|
35
|
+
* budget into the model choice. capabilities.reasoningEffort = false.
|
|
36
|
+
* - Attachments: ACP supports image/audio ContentBlocks, but we currently
|
|
37
|
+
* accept text only to match other SDK providers.
|
|
38
|
+
* - Filesystem reverse-RPC: we advertise fs capabilities = false so the agent
|
|
39
|
+
* uses its own fs access. Wiring client-side fs through the daemon would
|
|
40
|
+
* require a permission-broker integration we don't need yet.
|
|
41
|
+
* - Auth: the Gemini CLI caches OAuth credentials under ~/.gemini/. We do NOT
|
|
42
|
+
* call `authenticate()` and rely on the user having logged in once. API-key
|
|
43
|
+
* auth can be added later by wiring the `AUTHENTICATE` path.
|
|
44
|
+
*/
|
|
45
|
+
import { spawn } from 'node:child_process';
|
|
46
|
+
import { Readable, Writable } from 'node:stream';
|
|
47
|
+
import { randomUUID } from 'node:crypto';
|
|
48
|
+
import { ClientSideConnection, ndJsonStream, RequestError, } from '@agentclientprotocol/sdk';
|
|
49
|
+
import { killProcessTree } from '../../util/kill-process-tree.js';
|
|
50
|
+
import { CONNECTION_MODES, normalizeProviderPayload, SESSION_OWNERSHIP, PROVIDER_ERROR_CODES, } from '../transport-provider.js';
|
|
51
|
+
import logger from '../../util/logger.js';
|
|
52
|
+
import { normalizeTransportCwd, resolveExecutableForSpawn } from '../transport-paths.js';
|
|
53
|
+
const GEMINI_BIN = 'gemini';
|
|
54
|
+
/** ACP mode id we request once per session. Matches the `yolo` mode advertised
|
|
55
|
+
* by the Gemini CLI (see packages/core/src/policy/types.ts ApprovalMode). */
|
|
56
|
+
const GEMINI_YOLO_MODE = 'yolo';
|
|
57
|
+
export class GeminiSdkProvider {
|
|
58
|
+
id = 'gemini-sdk';
|
|
59
|
+
connectionMode = CONNECTION_MODES.LOCAL_SDK;
|
|
60
|
+
sessionOwnership = SESSION_OWNERSHIP.SHARED;
|
|
61
|
+
capabilities = {
|
|
62
|
+
streaming: true,
|
|
63
|
+
toolCalling: true,
|
|
64
|
+
approval: false,
|
|
65
|
+
sessionRestore: true,
|
|
66
|
+
multiTurn: true,
|
|
67
|
+
attachments: false,
|
|
68
|
+
reasoningEffort: false,
|
|
69
|
+
contextSupport: 'degraded-message-side-context-mapping',
|
|
70
|
+
};
|
|
71
|
+
config = null;
|
|
72
|
+
sessions = new Map();
|
|
73
|
+
/** Reverse lookup so `sessionUpdate` notifications can find the right state
|
|
74
|
+
* by ACP sessionId (which we only learn after newSession/loadSession). */
|
|
75
|
+
acpToRoute = new Map();
|
|
76
|
+
deltaCallbacks = [];
|
|
77
|
+
completeCallbacks = [];
|
|
78
|
+
errorCallbacks = [];
|
|
79
|
+
toolCallCallbacks = [];
|
|
80
|
+
sessionInfoCallbacks = [];
|
|
81
|
+
statusCallbacks = [];
|
|
82
|
+
child = null;
|
|
83
|
+
connection = null;
|
|
84
|
+
/** Resolves once `initialize` has completed so subsequent RPCs can proceed. */
|
|
85
|
+
initPromise = null;
|
|
86
|
+
async connect(config) {
|
|
87
|
+
await this.startAcpServer(config);
|
|
88
|
+
this.config = config;
|
|
89
|
+
logger.info({ provider: this.id }, 'Gemini SDK provider connected via --acp');
|
|
90
|
+
}
|
|
91
|
+
async disconnect() {
|
|
92
|
+
this.teardownChild();
|
|
93
|
+
this.acpToRoute.clear();
|
|
94
|
+
this.sessions.clear();
|
|
95
|
+
this.config = null;
|
|
96
|
+
this.initPromise = null;
|
|
97
|
+
}
|
|
98
|
+
async createSession(config) {
|
|
99
|
+
const routeId = config.bindExistingKey ?? config.sessionKey;
|
|
100
|
+
const existing = config.fresh ? undefined : this.sessions.get(routeId);
|
|
101
|
+
const state = {
|
|
102
|
+
routeId,
|
|
103
|
+
cwd: normalizeTransportCwd(config.cwd) ?? existing?.cwd ?? normalizeTransportCwd(process.cwd()),
|
|
104
|
+
model: typeof config.agentId === 'string' ? config.agentId : existing?.model,
|
|
105
|
+
acpSessionId: config.resumeId ?? existing?.acpSessionId,
|
|
106
|
+
loaded: false,
|
|
107
|
+
modeApplied: false,
|
|
108
|
+
promptInFlight: false,
|
|
109
|
+
replaying: false,
|
|
110
|
+
cancelled: false,
|
|
111
|
+
currentMessageId: null,
|
|
112
|
+
currentText: '',
|
|
113
|
+
toolCalls: new Map(),
|
|
114
|
+
emittedToolSignatures: new Map(),
|
|
115
|
+
lastStatusSignature: null,
|
|
116
|
+
};
|
|
117
|
+
this.sessions.set(routeId, state);
|
|
118
|
+
if (state.acpSessionId) {
|
|
119
|
+
this.acpToRoute.set(state.acpSessionId, routeId);
|
|
120
|
+
this.emitSessionInfo(routeId, { resumeId: state.acpSessionId });
|
|
121
|
+
}
|
|
122
|
+
return routeId;
|
|
123
|
+
}
|
|
124
|
+
async endSession(sessionId) {
|
|
125
|
+
const state = this.sessions.get(sessionId);
|
|
126
|
+
if (!state)
|
|
127
|
+
return;
|
|
128
|
+
// ACP has a session/close RPC (optional capability). We call it best-effort
|
|
129
|
+
// so the agent can free state; if it fails, the session is still gone on
|
|
130
|
+
// our side. closeSession is optional on the Agent interface so we have to
|
|
131
|
+
// feature-detect.
|
|
132
|
+
if (state.acpSessionId && state.loaded && this.connection) {
|
|
133
|
+
const closer = this.connection.closeSession;
|
|
134
|
+
if (typeof closer === 'function') {
|
|
135
|
+
await closer.call(this.connection, { sessionId: state.acpSessionId }).catch(() => { });
|
|
136
|
+
}
|
|
137
|
+
this.acpToRoute.delete(state.acpSessionId);
|
|
138
|
+
}
|
|
139
|
+
this.sessions.delete(sessionId);
|
|
140
|
+
}
|
|
141
|
+
onDelta(cb) {
|
|
142
|
+
this.deltaCallbacks.push(cb);
|
|
143
|
+
return () => {
|
|
144
|
+
const idx = this.deltaCallbacks.indexOf(cb);
|
|
145
|
+
if (idx >= 0)
|
|
146
|
+
this.deltaCallbacks.splice(idx, 1);
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
onComplete(cb) {
|
|
150
|
+
this.completeCallbacks.push(cb);
|
|
151
|
+
return () => {
|
|
152
|
+
const idx = this.completeCallbacks.indexOf(cb);
|
|
153
|
+
if (idx >= 0)
|
|
154
|
+
this.completeCallbacks.splice(idx, 1);
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
onError(cb) {
|
|
158
|
+
this.errorCallbacks.push(cb);
|
|
159
|
+
return () => {
|
|
160
|
+
const idx = this.errorCallbacks.indexOf(cb);
|
|
161
|
+
if (idx >= 0)
|
|
162
|
+
this.errorCallbacks.splice(idx, 1);
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
onToolCall(cb) {
|
|
166
|
+
this.toolCallCallbacks.push(cb);
|
|
167
|
+
}
|
|
168
|
+
onSessionInfo(cb) {
|
|
169
|
+
this.sessionInfoCallbacks.push(cb);
|
|
170
|
+
return () => {
|
|
171
|
+
const idx = this.sessionInfoCallbacks.indexOf(cb);
|
|
172
|
+
if (idx >= 0)
|
|
173
|
+
this.sessionInfoCallbacks.splice(idx, 1);
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
onStatus(cb) {
|
|
177
|
+
this.statusCallbacks.push(cb);
|
|
178
|
+
return () => {
|
|
179
|
+
const idx = this.statusCallbacks.indexOf(cb);
|
|
180
|
+
if (idx >= 0)
|
|
181
|
+
this.statusCallbacks.splice(idx, 1);
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
setSessionAgentId(sessionId, agentId) {
|
|
185
|
+
const state = this.sessions.get(sessionId);
|
|
186
|
+
if (!state)
|
|
187
|
+
return;
|
|
188
|
+
state.model = agentId;
|
|
189
|
+
// If session already loaded, best-effort push the model change via the
|
|
190
|
+
// experimental RPC. Swallow errors: if the CLI version predates it,
|
|
191
|
+
// subsequent prompts still use the original model — still correct, just
|
|
192
|
+
// not the requested one.
|
|
193
|
+
if (state.acpSessionId && state.loaded && this.connection) {
|
|
194
|
+
const setter = this.connection.unstable_setSessionModel;
|
|
195
|
+
if (typeof setter === 'function') {
|
|
196
|
+
void setter.call(this.connection, {
|
|
197
|
+
sessionId: state.acpSessionId,
|
|
198
|
+
modelId: agentId,
|
|
199
|
+
}).catch((err) => {
|
|
200
|
+
logger.debug({ provider: this.id, err, agentId }, 'unstable_setSessionModel failed (non-fatal)');
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async send(sessionId, payloadOrMessage, attachments, extraSystemPrompt) {
|
|
206
|
+
if (!this.config || !this.connection) {
|
|
207
|
+
throw this.makeError(PROVIDER_ERROR_CODES.CONNECTION_LOST, 'Gemini ACP server not connected', false);
|
|
208
|
+
}
|
|
209
|
+
const state = this.sessions.get(sessionId);
|
|
210
|
+
if (!state) {
|
|
211
|
+
throw this.makeError(PROVIDER_ERROR_CODES.SESSION_NOT_FOUND, `Unknown Gemini SDK session: ${sessionId}`, false);
|
|
212
|
+
}
|
|
213
|
+
if (state.promptInFlight) {
|
|
214
|
+
throw this.makeError(PROVIDER_ERROR_CODES.PROVIDER_ERROR, 'Gemini SDK session is already busy', true);
|
|
215
|
+
}
|
|
216
|
+
state.cancelled = false;
|
|
217
|
+
// NOTE: currentText/currentMessageId are cleared AFTER ensureSessionReady
|
|
218
|
+
// inside startTurn — not here. `loadSession` replays the full conversation
|
|
219
|
+
// history as `agent_message_chunk` notifications, and if we cleared before
|
|
220
|
+
// resume those replay chunks would accumulate into currentText and be
|
|
221
|
+
// re-emitted as the new turn's content.
|
|
222
|
+
state.toolCalls.clear();
|
|
223
|
+
state.emittedToolSignatures.clear();
|
|
224
|
+
state.lastStatusSignature = null;
|
|
225
|
+
const payload = normalizeProviderPayload(payloadOrMessage, attachments, extraSystemPrompt);
|
|
226
|
+
await this.startTurn(sessionId, state, payload);
|
|
227
|
+
}
|
|
228
|
+
async cancel(sessionId) {
|
|
229
|
+
const state = this.sessions.get(sessionId);
|
|
230
|
+
if (!state?.acpSessionId || !state.promptInFlight || !this.connection)
|
|
231
|
+
return;
|
|
232
|
+
state.cancelled = true;
|
|
233
|
+
// `cancel` is a one-shot notification; the pending prompt() Promise will
|
|
234
|
+
// then resolve with stopReason='cancelled' (or occasionally the agent
|
|
235
|
+
// settles with the partial turn — we handle both in startTurn).
|
|
236
|
+
await this.connection.cancel({ sessionId: state.acpSessionId }).catch((err) => {
|
|
237
|
+
logger.debug({ provider: this.id, sessionId, err }, 'ACP cancel notification failed (non-fatal)');
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
// ── ACP client-side glue ────────────────────────────────────────────────
|
|
241
|
+
async startAcpServer(config) {
|
|
242
|
+
this.teardownChild();
|
|
243
|
+
const binaryPath = this.resolveBinaryPath(config);
|
|
244
|
+
const resolved = resolveExecutableForSpawn(binaryPath);
|
|
245
|
+
const args = [...resolved.prependArgs, '--acp'];
|
|
246
|
+
const child = spawn(resolved.executable, args, {
|
|
247
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
248
|
+
env: { ...process.env, ...(config.env ?? {}) },
|
|
249
|
+
windowsHide: true,
|
|
250
|
+
});
|
|
251
|
+
this.child = child;
|
|
252
|
+
child.stderr.on('data', (chunk) => {
|
|
253
|
+
const text = chunk.toString().trim();
|
|
254
|
+
if (!text)
|
|
255
|
+
return;
|
|
256
|
+
// Gemini CLI writes verbose startup noise to stderr. Keep it at debug to
|
|
257
|
+
// avoid polluting normal daemon logs.
|
|
258
|
+
logger.debug({ provider: this.id, stderr: text }, 'Gemini ACP stderr');
|
|
259
|
+
});
|
|
260
|
+
child.on('exit', (code, signal) => {
|
|
261
|
+
const err = new Error(`Gemini ACP server exited with code=${code} signal=${signal}`);
|
|
262
|
+
const sessions = [...this.sessions.keys()];
|
|
263
|
+
for (const sid of sessions) {
|
|
264
|
+
this.emitError(sid, this.makeError(PROVIDER_ERROR_CODES.CONNECTION_LOST, err.message, false));
|
|
265
|
+
}
|
|
266
|
+
this.child = null;
|
|
267
|
+
this.connection = null;
|
|
268
|
+
this.initPromise = null;
|
|
269
|
+
});
|
|
270
|
+
child.on('error', (err) => {
|
|
271
|
+
logger.error({ provider: this.id, err }, 'Gemini ACP spawn error');
|
|
272
|
+
const sessions = [...this.sessions.keys()];
|
|
273
|
+
for (const sid of sessions) {
|
|
274
|
+
this.emitError(sid, this.makeError(PROVIDER_ERROR_CODES.CONNECTION_LOST, err.message, false));
|
|
275
|
+
}
|
|
276
|
+
this.child = null;
|
|
277
|
+
this.connection = null;
|
|
278
|
+
this.initPromise = null;
|
|
279
|
+
});
|
|
280
|
+
// `ndJsonStream` wants Web streams; convert the Node stdio streams.
|
|
281
|
+
const writable = Writable.toWeb(child.stdin);
|
|
282
|
+
const readable = Readable.toWeb(child.stdout);
|
|
283
|
+
const stream = ndJsonStream(writable, readable);
|
|
284
|
+
// Construct the ACP connection. The callback receives the Agent handle we
|
|
285
|
+
// can use to call RPC methods; we return a Client impl that handles
|
|
286
|
+
// reverse RPC (requestPermission + session notifications).
|
|
287
|
+
this.connection = new ClientSideConnection(() => this.createClientImpl(), stream);
|
|
288
|
+
// Kick off initialize once; all subsequent calls await the same promise.
|
|
289
|
+
this.initPromise = (async () => {
|
|
290
|
+
const result = await this.connection.initialize({
|
|
291
|
+
// Protocol version number. 1 is the current major; the agent tells us
|
|
292
|
+
// what it supports in the response but for now we only talk 1.
|
|
293
|
+
protocolVersion: 1,
|
|
294
|
+
clientCapabilities: {
|
|
295
|
+
fs: { readTextFile: false, writeTextFile: false },
|
|
296
|
+
// `terminal` is a boolean in current ACP schema. Leave off — we don't
|
|
297
|
+
// provide a client-side terminal.
|
|
298
|
+
terminal: false,
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
logger.info({ provider: this.id, agentInfo: result.agentInfo, caps: result.agentCapabilities }, 'Gemini ACP initialized');
|
|
302
|
+
})().catch((err) => {
|
|
303
|
+
logger.error({ provider: this.id, err }, 'Gemini ACP initialize failed');
|
|
304
|
+
throw err;
|
|
305
|
+
});
|
|
306
|
+
await this.initPromise;
|
|
307
|
+
}
|
|
308
|
+
/** Build the Client impl passed to ClientSideConnection. The SDK invokes
|
|
309
|
+
* these methods when the agent sends us requests/notifications. */
|
|
310
|
+
createClientImpl() {
|
|
311
|
+
return {
|
|
312
|
+
requestPermission: async (params) => {
|
|
313
|
+
// We operate exclusively in `yolo` mode after setSessionMode, so this
|
|
314
|
+
// path should never fire — but guard against an agent that ignores the
|
|
315
|
+
// mode for particularly dangerous ops. Respond "cancelled" so the
|
|
316
|
+
// agent aborts the tool call rather than hanging forever. A future
|
|
317
|
+
// revision can plumb this to a real UI approval flow.
|
|
318
|
+
logger.warn({ provider: this.id, sessionId: params.sessionId, toolCall: params.toolCall?.title }, 'Gemini ACP requestPermission received in yolo mode; auto-denying');
|
|
319
|
+
return { outcome: { outcome: 'cancelled' } };
|
|
320
|
+
},
|
|
321
|
+
sessionUpdate: async (params) => {
|
|
322
|
+
this.handleSessionUpdate(params);
|
|
323
|
+
},
|
|
324
|
+
// Provide `readTextFile`/`writeTextFile` stubs so that if the agent ever
|
|
325
|
+
// tries to call them despite our caps advertising false, we fail loudly
|
|
326
|
+
// instead of hanging. These throw RequestError so the JSON-RPC layer
|
|
327
|
+
// reports a proper error code to the agent.
|
|
328
|
+
readTextFile: async (_params) => {
|
|
329
|
+
throw new RequestError(-32601, 'Method not available — client fs capability disabled');
|
|
330
|
+
},
|
|
331
|
+
writeTextFile: async (_params) => {
|
|
332
|
+
throw new RequestError(-32601, 'Method not available — client fs capability disabled');
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
// ── Prompt turn orchestration ───────────────────────────────────────────
|
|
337
|
+
async startTurn(sessionId, state, payload) {
|
|
338
|
+
state.promptInFlight = true;
|
|
339
|
+
try {
|
|
340
|
+
await this.ensureSessionReady(sessionId, state);
|
|
341
|
+
// Start the turn's delta buffer clean. (During loadSession the replay
|
|
342
|
+
// flag already suppresses accumulation; this belt-and-suspenders reset
|
|
343
|
+
// also catches any stray state left by a prior turn that errored out
|
|
344
|
+
// before settleTurn ran.)
|
|
345
|
+
state.currentText = '';
|
|
346
|
+
state.currentMessageId = null;
|
|
347
|
+
const promptBlocks = this.buildPromptContent(payload);
|
|
348
|
+
// Long-lived call — agent streams sessionUpdate notifications until this
|
|
349
|
+
// resolves with { stopReason }.
|
|
350
|
+
const result = await this.connection.prompt({
|
|
351
|
+
sessionId: state.acpSessionId,
|
|
352
|
+
prompt: promptBlocks,
|
|
353
|
+
});
|
|
354
|
+
this.settleTurn(sessionId, state, result.stopReason);
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
state.promptInFlight = false;
|
|
358
|
+
this.clearStatus(sessionId, state);
|
|
359
|
+
this.emitError(sessionId, this.normalizeError(err));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/** Create the session on the agent if it doesn't exist yet, otherwise
|
|
363
|
+
* resume it. Applies `yolo` mode once per session. */
|
|
364
|
+
async ensureSessionReady(sessionId, state) {
|
|
365
|
+
if (!this.connection) {
|
|
366
|
+
throw this.makeError(PROVIDER_ERROR_CODES.CONNECTION_LOST, 'Gemini ACP connection not ready', false);
|
|
367
|
+
}
|
|
368
|
+
await this.initPromise;
|
|
369
|
+
if (!state.loaded) {
|
|
370
|
+
if (state.acpSessionId) {
|
|
371
|
+
// Resume an existing session (survives across CLI restarts via
|
|
372
|
+
// ~/.gemini/tmp/<project>/chats/<id>.json).
|
|
373
|
+
try {
|
|
374
|
+
const loader = this.connection.loadSession;
|
|
375
|
+
if (typeof loader !== 'function') {
|
|
376
|
+
throw new Error('Agent does not implement loadSession (capability mismatch)');
|
|
377
|
+
}
|
|
378
|
+
// `loadSession` streams the full message history back as
|
|
379
|
+
// session/update notifications *before* the RPC resolves. Set the
|
|
380
|
+
// replay flag so handleSessionUpdate drops those history chunks
|
|
381
|
+
// instead of forwarding them to the current-turn delta listeners.
|
|
382
|
+
state.replaying = true;
|
|
383
|
+
let loadResult;
|
|
384
|
+
try {
|
|
385
|
+
loadResult = await loader.call(this.connection, {
|
|
386
|
+
sessionId: state.acpSessionId,
|
|
387
|
+
cwd: state.cwd,
|
|
388
|
+
mcpServers: [],
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
finally {
|
|
392
|
+
state.replaying = false;
|
|
393
|
+
}
|
|
394
|
+
state.loaded = true;
|
|
395
|
+
this.applySessionMetadata(sessionId, state, loadResult);
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
logger.info({ provider: this.id, sessionId, acpSessionId: state.acpSessionId, err }, 'Gemini ACP loadSession failed; falling back to newSession');
|
|
399
|
+
this.acpToRoute.delete(state.acpSessionId);
|
|
400
|
+
state.acpSessionId = undefined;
|
|
401
|
+
await this.createFreshAcpSession(sessionId, state);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
await this.createFreshAcpSession(sessionId, state);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (!state.modeApplied && state.acpSessionId && this.connection) {
|
|
409
|
+
const modeSetter = this.connection.setSessionMode;
|
|
410
|
+
if (typeof modeSetter === 'function') {
|
|
411
|
+
await modeSetter.call(this.connection, {
|
|
412
|
+
sessionId: state.acpSessionId,
|
|
413
|
+
modeId: GEMINI_YOLO_MODE,
|
|
414
|
+
}).catch((err) => {
|
|
415
|
+
// Not fatal — default mode just means the agent will issue
|
|
416
|
+
// requestPermission callbacks that we auto-deny. Worth a log.
|
|
417
|
+
logger.warn({ provider: this.id, sessionId, err }, 'setSessionMode(yolo) failed; tools may be denied');
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
state.modeApplied = true;
|
|
421
|
+
}
|
|
422
|
+
// If the caller specified a model, push it once per turn so we follow
|
|
423
|
+
// setSessionAgentId calls that happened before the session was loaded.
|
|
424
|
+
if (state.model && state.acpSessionId && this.connection) {
|
|
425
|
+
const modelSetter = this.connection.unstable_setSessionModel;
|
|
426
|
+
if (typeof modelSetter === 'function') {
|
|
427
|
+
await modelSetter.call(this.connection, {
|
|
428
|
+
sessionId: state.acpSessionId,
|
|
429
|
+
modelId: state.model,
|
|
430
|
+
}).catch((err) => {
|
|
431
|
+
logger.debug({ provider: this.id, sessionId, err }, 'unstable_setSessionModel pre-turn failed (non-fatal)');
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async createFreshAcpSession(sessionId, state) {
|
|
437
|
+
const result = await this.connection.newSession({
|
|
438
|
+
cwd: state.cwd,
|
|
439
|
+
mcpServers: [],
|
|
440
|
+
});
|
|
441
|
+
state.acpSessionId = result.sessionId;
|
|
442
|
+
state.loaded = true;
|
|
443
|
+
state.modeApplied = false;
|
|
444
|
+
this.acpToRoute.set(state.acpSessionId, sessionId);
|
|
445
|
+
this.applySessionMetadata(sessionId, state, result);
|
|
446
|
+
this.emitSessionInfo(sessionId, { resumeId: state.acpSessionId });
|
|
447
|
+
}
|
|
448
|
+
applySessionMetadata(sessionId, state, info) {
|
|
449
|
+
const currentModel = info.models?.currentModelId;
|
|
450
|
+
if (typeof currentModel === 'string' && !state.model) {
|
|
451
|
+
state.model = currentModel;
|
|
452
|
+
this.emitSessionInfo(sessionId, { model: currentModel });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
buildPromptContent(payload) {
|
|
456
|
+
// ACP has no separate system-prompt slot. Follow the CodexSdkProvider
|
|
457
|
+
// convention and prepend the resolved systemText as a context preamble so
|
|
458
|
+
// the agent sees the persona/description alongside the user turn.
|
|
459
|
+
const userText = payload.systemText
|
|
460
|
+
? `Context instructions:\n${payload.systemText}\n\n${payload.assembledMessage}`
|
|
461
|
+
: payload.assembledMessage;
|
|
462
|
+
return [{ type: 'text', text: userText }];
|
|
463
|
+
}
|
|
464
|
+
settleTurn(sessionId, state, stopReason) {
|
|
465
|
+
state.promptInFlight = false;
|
|
466
|
+
this.clearStatus(sessionId, state);
|
|
467
|
+
const text = state.currentText;
|
|
468
|
+
const messageId = state.currentMessageId ?? `${sessionId}:${randomUUID()}`;
|
|
469
|
+
state.currentText = '';
|
|
470
|
+
state.currentMessageId = null;
|
|
471
|
+
if (stopReason === 'cancelled' || state.cancelled) {
|
|
472
|
+
state.cancelled = false;
|
|
473
|
+
this.emitError(sessionId, this.makeError(PROVIDER_ERROR_CODES.CANCELLED, 'Gemini turn cancelled', true));
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (stopReason === 'refusal') {
|
|
477
|
+
this.emitError(sessionId, this.makeError(PROVIDER_ERROR_CODES.PROVIDER_ERROR, 'Gemini refused the request', false));
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (stopReason === 'max_tokens' || stopReason === 'max_turn_requests') {
|
|
481
|
+
// Still emit whatever text we accumulated — it's a partial but useful
|
|
482
|
+
// response — and mark metadata so the UI can show the truncation cause.
|
|
483
|
+
const msg = {
|
|
484
|
+
id: messageId,
|
|
485
|
+
sessionId,
|
|
486
|
+
kind: 'text',
|
|
487
|
+
role: 'assistant',
|
|
488
|
+
content: text,
|
|
489
|
+
timestamp: Date.now(),
|
|
490
|
+
status: 'complete',
|
|
491
|
+
metadata: {
|
|
492
|
+
stopReason,
|
|
493
|
+
...(state.model ? { model: state.model } : {}),
|
|
494
|
+
...(state.acpSessionId ? { resumeId: state.acpSessionId } : {}),
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
for (const cb of this.completeCallbacks)
|
|
498
|
+
cb(sessionId, msg);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
// stopReason === 'end_turn' (happy path).
|
|
502
|
+
const msg = {
|
|
503
|
+
id: messageId,
|
|
504
|
+
sessionId,
|
|
505
|
+
kind: 'text',
|
|
506
|
+
role: 'assistant',
|
|
507
|
+
content: text,
|
|
508
|
+
timestamp: Date.now(),
|
|
509
|
+
status: 'complete',
|
|
510
|
+
metadata: {
|
|
511
|
+
...(state.model ? { model: state.model } : {}),
|
|
512
|
+
...(state.acpSessionId ? { resumeId: state.acpSessionId } : {}),
|
|
513
|
+
},
|
|
514
|
+
};
|
|
515
|
+
for (const cb of this.completeCallbacks)
|
|
516
|
+
cb(sessionId, msg);
|
|
517
|
+
}
|
|
518
|
+
// ── sessionUpdate dispatch ──────────────────────────────────────────────
|
|
519
|
+
handleSessionUpdate(params) {
|
|
520
|
+
const routeId = this.acpToRoute.get(params.sessionId);
|
|
521
|
+
if (!routeId)
|
|
522
|
+
return;
|
|
523
|
+
const state = this.sessions.get(routeId);
|
|
524
|
+
if (!state)
|
|
525
|
+
return;
|
|
526
|
+
const update = params.update;
|
|
527
|
+
// While loadSession is replaying history, drop every turn-scoped event.
|
|
528
|
+
// We only resurface the persisted agent text/tools when the user actually
|
|
529
|
+
// asks a new question; replay is purely a server-side side-effect of
|
|
530
|
+
// session/load and must not be confused with the current-turn stream.
|
|
531
|
+
if (state.replaying) {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
switch (update.sessionUpdate) {
|
|
535
|
+
case 'agent_message_chunk':
|
|
536
|
+
this.handleAgentChunk(routeId, state, update);
|
|
537
|
+
return;
|
|
538
|
+
case 'agent_thought_chunk':
|
|
539
|
+
// ACP thought chunks map to our transient "thinking" status. We
|
|
540
|
+
// intentionally do NOT append the thought text to the main content
|
|
541
|
+
// stream — it's reasoning, not the assistant message.
|
|
542
|
+
this.emitStatus(routeId, state, { status: 'thinking', label: 'Thinking...' });
|
|
543
|
+
return;
|
|
544
|
+
case 'tool_call':
|
|
545
|
+
this.handleToolCall(routeId, state, update);
|
|
546
|
+
return;
|
|
547
|
+
case 'tool_call_update':
|
|
548
|
+
this.handleToolCallUpdate(routeId, state, update);
|
|
549
|
+
return;
|
|
550
|
+
case 'current_mode_update':
|
|
551
|
+
// Just informational — the user may have switched mode through the
|
|
552
|
+
// agent's own command vocabulary. Treat as metadata.
|
|
553
|
+
logger.debug({ provider: this.id, sessionId: routeId, modeId: update.currentModeId }, 'Gemini ACP mode changed');
|
|
554
|
+
return;
|
|
555
|
+
case 'usage_update': {
|
|
556
|
+
// Map ACP's usage update onto our generic quota/session-info signal.
|
|
557
|
+
// ACP's `UsageUpdate` is experimental; guard every field.
|
|
558
|
+
const u = update;
|
|
559
|
+
this.emitSessionInfo(routeId, {
|
|
560
|
+
...(typeof u.tokens === 'object' && u.tokens
|
|
561
|
+
? { quotaMeta: u.tokens }
|
|
562
|
+
: {}),
|
|
563
|
+
});
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
case 'available_commands_update':
|
|
567
|
+
case 'user_message_chunk':
|
|
568
|
+
case 'plan':
|
|
569
|
+
case 'config_option_update':
|
|
570
|
+
case 'session_info_update':
|
|
571
|
+
// Ignore for now. `user_message_chunk` arrives during history replay
|
|
572
|
+
// and would double-inject prior user turns into the delta stream.
|
|
573
|
+
return;
|
|
574
|
+
default:
|
|
575
|
+
logger.debug({ provider: this.id, sessionId: routeId, sessionUpdate: update.sessionUpdate }, 'Unhandled ACP session update');
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
handleAgentChunk(sessionId, state, update) {
|
|
580
|
+
const chunkText = extractTextFromContent(update.content);
|
|
581
|
+
if (!chunkText)
|
|
582
|
+
return;
|
|
583
|
+
this.clearStatus(sessionId, state);
|
|
584
|
+
// ACP has a `messageId` field on each chunk. When it changes we start a
|
|
585
|
+
// new assistant message. Fall back to our own UUID if the agent doesn't
|
|
586
|
+
// populate it (older CLI versions).
|
|
587
|
+
const incomingId = update.messageId ?? null;
|
|
588
|
+
if (incomingId && incomingId !== state.currentMessageId) {
|
|
589
|
+
state.currentMessageId = incomingId;
|
|
590
|
+
state.currentText = '';
|
|
591
|
+
}
|
|
592
|
+
else if (!state.currentMessageId) {
|
|
593
|
+
state.currentMessageId = randomUUID();
|
|
594
|
+
}
|
|
595
|
+
state.currentText += chunkText;
|
|
596
|
+
const delta = {
|
|
597
|
+
messageId: state.currentMessageId,
|
|
598
|
+
type: 'text',
|
|
599
|
+
delta: state.currentText,
|
|
600
|
+
role: 'assistant',
|
|
601
|
+
};
|
|
602
|
+
for (const cb of this.deltaCallbacks)
|
|
603
|
+
cb(sessionId, delta);
|
|
604
|
+
}
|
|
605
|
+
handleToolCall(sessionId, state, update) {
|
|
606
|
+
this.clearStatus(sessionId, state);
|
|
607
|
+
const merged = {
|
|
608
|
+
toolCallId: update.toolCallId,
|
|
609
|
+
title: update.title ?? update.toolCallId,
|
|
610
|
+
kind: update.kind ?? undefined,
|
|
611
|
+
status: update.status ?? 'pending',
|
|
612
|
+
content: Array.isArray(update.content) ? update.content : [],
|
|
613
|
+
rawInput: update.rawInput,
|
|
614
|
+
rawOutput: update.rawOutput,
|
|
615
|
+
};
|
|
616
|
+
state.toolCalls.set(update.toolCallId, merged);
|
|
617
|
+
this.emitMergedToolCall(sessionId, state, merged);
|
|
618
|
+
}
|
|
619
|
+
handleToolCallUpdate(sessionId, state, update) {
|
|
620
|
+
const existing = state.toolCalls.get(update.toolCallId);
|
|
621
|
+
// ACP spec: every field on ToolCallUpdate is optional and replaces the
|
|
622
|
+
// corresponding field on the original tool_call. We synthesize a stub if
|
|
623
|
+
// the agent updates a tool we never saw start (shouldn't happen, but
|
|
624
|
+
// defensive).
|
|
625
|
+
const merged = existing ?? {
|
|
626
|
+
toolCallId: update.toolCallId,
|
|
627
|
+
title: update.title ?? update.toolCallId,
|
|
628
|
+
kind: update.kind ?? undefined,
|
|
629
|
+
status: update.status ?? 'pending',
|
|
630
|
+
content: [],
|
|
631
|
+
};
|
|
632
|
+
if (typeof update.title === 'string')
|
|
633
|
+
merged.title = update.title;
|
|
634
|
+
if (typeof update.kind === 'string')
|
|
635
|
+
merged.kind = update.kind;
|
|
636
|
+
if (typeof update.status === 'string')
|
|
637
|
+
merged.status = update.status;
|
|
638
|
+
if (Array.isArray(update.content))
|
|
639
|
+
merged.content = update.content;
|
|
640
|
+
if ('rawInput' in update)
|
|
641
|
+
merged.rawInput = update.rawInput;
|
|
642
|
+
if ('rawOutput' in update)
|
|
643
|
+
merged.rawOutput = update.rawOutput;
|
|
644
|
+
state.toolCalls.set(update.toolCallId, merged);
|
|
645
|
+
this.emitMergedToolCall(sessionId, state, merged);
|
|
646
|
+
}
|
|
647
|
+
emitMergedToolCall(sessionId, state, merged) {
|
|
648
|
+
const normalizedStatus = mapToolStatus(merged.status);
|
|
649
|
+
const output = normalizedStatus === 'running' ? undefined : flattenToolContent(merged.content);
|
|
650
|
+
const evt = {
|
|
651
|
+
id: merged.toolCallId,
|
|
652
|
+
name: merged.title,
|
|
653
|
+
status: normalizedStatus,
|
|
654
|
+
...(merged.rawInput !== undefined ? { input: merged.rawInput } : {}),
|
|
655
|
+
...(output !== undefined ? { output } : {}),
|
|
656
|
+
detail: {
|
|
657
|
+
kind: merged.kind ?? 'tool_use',
|
|
658
|
+
summary: merged.title,
|
|
659
|
+
input: merged.rawInput,
|
|
660
|
+
output,
|
|
661
|
+
meta: { status: merged.status },
|
|
662
|
+
raw: merged,
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
const signature = JSON.stringify({
|
|
666
|
+
status: evt.status,
|
|
667
|
+
name: evt.name,
|
|
668
|
+
input: evt.input ?? null,
|
|
669
|
+
output: evt.output ?? null,
|
|
670
|
+
});
|
|
671
|
+
if (state.emittedToolSignatures.get(merged.toolCallId) === signature)
|
|
672
|
+
return;
|
|
673
|
+
state.emittedToolSignatures.set(merged.toolCallId, signature);
|
|
674
|
+
for (const cb of this.toolCallCallbacks)
|
|
675
|
+
cb(sessionId, evt);
|
|
676
|
+
}
|
|
677
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
678
|
+
teardownChild() {
|
|
679
|
+
// Closing the ACP connection is implicit when we close stdin. The SDK's
|
|
680
|
+
// internal readers finish when stdout ends. tree-kill the CLI so its
|
|
681
|
+
// node wrapper doesn't leave grandchildren behind.
|
|
682
|
+
if (this.child && !this.child.killed) {
|
|
683
|
+
try {
|
|
684
|
+
this.child.stdin.end();
|
|
685
|
+
}
|
|
686
|
+
catch { /* noop */ }
|
|
687
|
+
void killProcessTree(this.child);
|
|
688
|
+
}
|
|
689
|
+
this.child = null;
|
|
690
|
+
this.connection = null;
|
|
691
|
+
}
|
|
692
|
+
emitSessionInfo(sessionId, info) {
|
|
693
|
+
for (const cb of this.sessionInfoCallbacks)
|
|
694
|
+
cb(sessionId, info);
|
|
695
|
+
}
|
|
696
|
+
emitStatus(sessionId, state, status) {
|
|
697
|
+
const signature = JSON.stringify({ status: status.status, label: status.label ?? null });
|
|
698
|
+
if (state.lastStatusSignature === signature)
|
|
699
|
+
return;
|
|
700
|
+
state.lastStatusSignature = signature;
|
|
701
|
+
for (const cb of this.statusCallbacks)
|
|
702
|
+
cb(sessionId, status);
|
|
703
|
+
}
|
|
704
|
+
clearStatus(sessionId, state) {
|
|
705
|
+
this.emitStatus(sessionId, state, { status: null, label: null });
|
|
706
|
+
}
|
|
707
|
+
emitError(sessionId, error) {
|
|
708
|
+
for (const cb of this.errorCallbacks)
|
|
709
|
+
cb(sessionId, error);
|
|
710
|
+
}
|
|
711
|
+
resolveBinaryPath(config) {
|
|
712
|
+
return typeof config?.binaryPath === 'string' && config.binaryPath.trim()
|
|
713
|
+
? config.binaryPath
|
|
714
|
+
: GEMINI_BIN;
|
|
715
|
+
}
|
|
716
|
+
makeError(code, message, recoverable, details) {
|
|
717
|
+
return { code, message, recoverable, ...(details !== undefined ? { details } : {}) };
|
|
718
|
+
}
|
|
719
|
+
normalizeError(err) {
|
|
720
|
+
if (err && typeof err === 'object' && 'code' in err && 'message' in err && 'recoverable' in err) {
|
|
721
|
+
// Already a ProviderError.
|
|
722
|
+
return err;
|
|
723
|
+
}
|
|
724
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
725
|
+
if (err instanceof RequestError) {
|
|
726
|
+
// Map ACP auth_required (code -32000 in Gemini CLI today, but spec
|
|
727
|
+
// reserves this code) onto our AUTH_FAILED so the UI surfaces a
|
|
728
|
+
// login-needed card instead of a generic error.
|
|
729
|
+
if (/auth/i.test(message)) {
|
|
730
|
+
return this.makeError(PROVIDER_ERROR_CODES.AUTH_FAILED, message, false, err);
|
|
731
|
+
}
|
|
732
|
+
return this.makeError(PROVIDER_ERROR_CODES.PROVIDER_ERROR, message, false, err);
|
|
733
|
+
}
|
|
734
|
+
if (/ENOENT|not found|spawn .*gemini/i.test(message)) {
|
|
735
|
+
return this.makeError(PROVIDER_ERROR_CODES.PROVIDER_NOT_FOUND, `Gemini CLI not found: ${message}`, false, err);
|
|
736
|
+
}
|
|
737
|
+
if (/auth/i.test(message)) {
|
|
738
|
+
return this.makeError(PROVIDER_ERROR_CODES.AUTH_FAILED, message, false, err);
|
|
739
|
+
}
|
|
740
|
+
return this.makeError(PROVIDER_ERROR_CODES.PROVIDER_ERROR, message, false, err);
|
|
741
|
+
}
|
|
742
|
+
/** Hook used by setSessionEffort in the TransportProvider interface — we
|
|
743
|
+
* declare reasoningEffort:false so this should never be called, but other
|
|
744
|
+
* providers define the method so we mirror the shape. */
|
|
745
|
+
setSessionEffort(_sessionId, _effort) {
|
|
746
|
+
// no-op — effort is baked into the model choice.
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
// ── Module-scope pure helpers ─────────────────────────────────────────────
|
|
750
|
+
/** Extract a text string from a ContentBlock if it's a textual variant.
|
|
751
|
+
* Gemini's agent_message_chunk and agent_thought_chunk carry TextContent;
|
|
752
|
+
* image/audio/resource variants are silently dropped. */
|
|
753
|
+
function extractTextFromContent(block) {
|
|
754
|
+
if (!block || typeof block !== 'object')
|
|
755
|
+
return '';
|
|
756
|
+
if (block.type === 'text' && typeof block.text === 'string')
|
|
757
|
+
return block.text;
|
|
758
|
+
return '';
|
|
759
|
+
}
|
|
760
|
+
/** Join the content array of a completed tool call into a single human-readable
|
|
761
|
+
* string. ACP allows `content` to mix `{type:'content', content:TextContent}`
|
|
762
|
+
* and `{type:'diff',...}` entries. For the brief UI summary we only unwrap
|
|
763
|
+
* textual content; the full structured detail stays in `detail.raw`. */
|
|
764
|
+
function flattenToolContent(content) {
|
|
765
|
+
if (!Array.isArray(content) || content.length === 0)
|
|
766
|
+
return undefined;
|
|
767
|
+
const parts = [];
|
|
768
|
+
for (const item of content) {
|
|
769
|
+
if (!item || typeof item !== 'object')
|
|
770
|
+
continue;
|
|
771
|
+
if (item.type === 'content' && item.content?.type === 'text' && typeof item.content.text === 'string') {
|
|
772
|
+
parts.push(item.content.text);
|
|
773
|
+
}
|
|
774
|
+
else if (item.type === 'diff') {
|
|
775
|
+
const newText = typeof item.newText === 'string' ? item.newText : '';
|
|
776
|
+
parts.push(newText);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return parts.length > 0 ? parts.join('\n') : undefined;
|
|
780
|
+
}
|
|
781
|
+
/** Map ACP's ToolCallStatus enum onto our ToolCallEvent status union. */
|
|
782
|
+
function mapToolStatus(status) {
|
|
783
|
+
switch (status) {
|
|
784
|
+
case 'completed':
|
|
785
|
+
return 'complete';
|
|
786
|
+
case 'failed':
|
|
787
|
+
case 'cancelled':
|
|
788
|
+
return 'error';
|
|
789
|
+
case 'pending':
|
|
790
|
+
case 'in_progress':
|
|
791
|
+
default:
|
|
792
|
+
return 'running';
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
//# sourceMappingURL=gemini-sdk.js.map
|