@volter/supercode-frontend-vscode 0.1.11
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/NOTICE.md +19 -0
- package/README.md +293 -0
- package/dist/bindings.d.ts +7 -0
- package/dist/bindings.js +5 -0
- package/dist/client.d.ts +11 -0
- package/dist/client.js +5 -0
- package/dist/context.d.ts +6 -0
- package/dist/context.js +14 -0
- package/dist/credential.d.ts +17 -0
- package/dist/credential.js +53 -0
- package/dist/extension.d.ts +39 -0
- package/dist/extension.js +1131 -0
- package/dist/host-controls.d.ts +58 -0
- package/dist/host-controls.js +66 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/native-session.d.ts +6 -0
- package/dist/native-session.js +41 -0
- package/dist/proposals.d.ts +24 -0
- package/dist/proposals.js +54 -0
- package/dist/session.d.ts +154 -0
- package/dist/session.js +431 -0
- package/dist/view.d.ts +176 -0
- package/dist/view.js +717 -0
- package/media/anthropic.svg +1 -0
- package/media/google.svg +1 -0
- package/media/icon.png +0 -0
- package/media/openai.svg +1 -0
- package/package.json +200 -0
- package/scripts/pack-extension.mjs +63 -0
- package/vendor/supercode-frontend/README.md +22 -0
- package/vendor/supercode-frontend/client.d.mts +106 -0
- package/vendor/supercode-frontend/client.mjs +300 -0
- package/vendor/supercode-frontend/contract.json +289 -0
- package/vendor/supercode-frontend/generated-client.d.mts +18 -0
- package/vendor/supercode-frontend/generated-client.mjs +20 -0
- package/vendor/supercode-frontend/generated.d.mts +146 -0
- package/vendor/supercode-frontend/generated.mjs +19 -0
- package/vendor/supercode-frontend/package.json +20 -0
|
@@ -0,0 +1,1131 @@
|
|
|
1
|
+
import { resolveSessionBinding } from './bindings.js';
|
|
2
|
+
import { withTextContext } from './context.js';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { ChatHostControls, runtimeIdentity, harnessDisplayName, turnPermission } from './host-controls.js';
|
|
5
|
+
import * as vscode from 'vscode';
|
|
6
|
+
import { createFrontendClient } from './client.js';
|
|
7
|
+
import { readCredentialFile } from './credential.js';
|
|
8
|
+
import { grantedProposals, PROPOSAL_EFFECTS, REQUESTED_PROPOSALS } from './proposals.js';
|
|
9
|
+
import { observeActiveResponse, sessionStatus } from './native-session.js';
|
|
10
|
+
import { RequestNotPendingError, VscodeFrontendSession } from './session.js';
|
|
11
|
+
import { fence, acceptedDecisions, opaqueText, projectEvent, projectPendingRequests, projectHistory, projectReplayTurns, resolveParts, resolutionSentence, typedAnswer, } from './view.js';
|
|
12
|
+
const SESSION_SCHEME = 'supercode';
|
|
13
|
+
const PARTICIPANT_ID = 'supercode.chat';
|
|
14
|
+
const DEFAULT_PARTICIPANT_ID = 'supercode.chat.default';
|
|
15
|
+
/** `package.json` gates the `isDefault` participant on this key. */
|
|
16
|
+
const DEFAULT_PARTICIPANT_CONTEXT_KEY = 'supercodeChatDefaultParticipant';
|
|
17
|
+
/** `chatViewsWelcome` shows the empty state until this is true. */
|
|
18
|
+
const ATTACHED_CONTEXT_KEY = 'supercodeAttached';
|
|
19
|
+
const PERMISSIONS = ['observe', 'interact', 'approve', 'terminate'];
|
|
20
|
+
/** The environment a runtime uses to hand this frontend a live connection. */
|
|
21
|
+
const ENV = Object.freeze({
|
|
22
|
+
url: 'SUPERCODE_FRONTEND_URL',
|
|
23
|
+
clientId: 'SUPERCODE_FRONTEND_CLIENT_ID',
|
|
24
|
+
credentialFile: 'SUPERCODE_FRONTEND_CREDENTIAL_FILE',
|
|
25
|
+
permissions: 'SUPERCODE_FRONTEND_PERMISSIONS',
|
|
26
|
+
/**
|
|
27
|
+
* Why there is no runtime to attach to, in the host's OWN words — "Install
|
|
28
|
+
* Claude Code and ensure `claude` is on PATH", "sign in with `codex login`".
|
|
29
|
+
* A host that could not start one knows exactly why and what to type; this
|
|
30
|
+
* frontend does not, and a chat panel that looks identical to the working
|
|
31
|
+
* case is how a person ends up typing into nothing.
|
|
32
|
+
*/
|
|
33
|
+
unavailable: 'SUPERCODE_FRONTEND_UNAVAILABLE',
|
|
34
|
+
});
|
|
35
|
+
function normalizePermissions(value, fallback) {
|
|
36
|
+
const raw = Array.isArray(value)
|
|
37
|
+
? value.map(String)
|
|
38
|
+
: typeof value === 'string'
|
|
39
|
+
? value.split(',')
|
|
40
|
+
: [];
|
|
41
|
+
const permissions = raw
|
|
42
|
+
.map((entry) => entry.trim())
|
|
43
|
+
.filter((entry) => PERMISSIONS.includes(entry));
|
|
44
|
+
if (!permissions.includes('observe'))
|
|
45
|
+
permissions.unshift('observe');
|
|
46
|
+
return value === undefined || value === null ? fallback : permissions;
|
|
47
|
+
}
|
|
48
|
+
/** The one session item that stands for "there is no runtime, and here is why". */
|
|
49
|
+
const UNAVAILABLE_PATH = '/unavailable';
|
|
50
|
+
/**
|
|
51
|
+
* The prompt of the turn that carries a status this frontend is reporting.
|
|
52
|
+
* It names itself; nothing here is presented as a person's words.
|
|
53
|
+
*/
|
|
54
|
+
const STATUS_MARKER_PROMPT = '⟨supercode⟩';
|
|
55
|
+
function unavailableResource() {
|
|
56
|
+
return vscode.Uri.from({ scheme: SESSION_SCHEME, authority: 'supercode', path: UNAVAILABLE_PATH });
|
|
57
|
+
}
|
|
58
|
+
function sessionResource(connection) {
|
|
59
|
+
const sessionId = 'conversation';
|
|
60
|
+
return vscode.Uri.from({ scheme: SESSION_SCHEME, authority: connection.id, path: `/${encodeURIComponent(sessionId)}` });
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Every attachment this window holds. It owns no agent, no model and no
|
|
64
|
+
* session file: one generated client per runtime and nothing else.
|
|
65
|
+
*/
|
|
66
|
+
class SupercodeFrontends {
|
|
67
|
+
#connections = new Map();
|
|
68
|
+
#changed = new vscode.EventEmitter();
|
|
69
|
+
onDidChange = this.#changed.event;
|
|
70
|
+
/**
|
|
71
|
+
* Why there is no runtime, in the host's own words. Held here because it is
|
|
72
|
+
* shown the same way an attached runtime is — as a session in the picker
|
|
73
|
+
* whose content is that sentence — so a person reading the panel learns
|
|
74
|
+
* what to do instead of typing into nothing.
|
|
75
|
+
*/
|
|
76
|
+
unavailable;
|
|
77
|
+
setUnavailable(reason) {
|
|
78
|
+
this.unavailable = reason?.trim() || undefined;
|
|
79
|
+
this.#changed.fire();
|
|
80
|
+
}
|
|
81
|
+
get all() {
|
|
82
|
+
return [...this.#connections.values()];
|
|
83
|
+
}
|
|
84
|
+
get(id) {
|
|
85
|
+
if (id)
|
|
86
|
+
return this.#connections.get(id);
|
|
87
|
+
return this.#connections.values().next().value;
|
|
88
|
+
}
|
|
89
|
+
info(connection) {
|
|
90
|
+
return {
|
|
91
|
+
id: connection.id,
|
|
92
|
+
baseUrl: connection.baseUrl,
|
|
93
|
+
label: connection.label,
|
|
94
|
+
sessionId: connection.session.descriptor?.session_id ?? null,
|
|
95
|
+
model: connection.session.descriptor?.model ?? null,
|
|
96
|
+
turnState: connection.session.descriptor?.turn_state ?? null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
async connect(options, log) {
|
|
100
|
+
// `{unavailable}` and no runtime: the host is telling this frontend why
|
|
101
|
+
// there is nothing to attach to. Record it and show it; do not attach.
|
|
102
|
+
if (!options.baseUrl || !options.token) {
|
|
103
|
+
if (options.unavailable) {
|
|
104
|
+
this.setUnavailable(options.unavailable);
|
|
105
|
+
log.info(`Volter Harness has no runtime: ${options.unavailable}`);
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
throw new TypeError('connect needs a baseUrl and token, or an `unavailable` reason');
|
|
109
|
+
}
|
|
110
|
+
const { baseUrl, token } = options;
|
|
111
|
+
const id = options.connectionId ?? options.clientId ?? `vscode-${randomUUID()}`;
|
|
112
|
+
const existing = this.#connections.get(id);
|
|
113
|
+
if (existing)
|
|
114
|
+
return existing;
|
|
115
|
+
const permissions = normalizePermissions(options.permissions, ['observe']);
|
|
116
|
+
const client = createFrontendClient({
|
|
117
|
+
baseUrl,
|
|
118
|
+
token,
|
|
119
|
+
clientId: options.clientId ?? id,
|
|
120
|
+
permissions,
|
|
121
|
+
});
|
|
122
|
+
const session = new VscodeFrontendSession(id, baseUrl, client, permissions);
|
|
123
|
+
// The generated client opens SSE before the snapshot RPC; `attach` is
|
|
124
|
+
// therefore the whole race-free attachment and `follow` only drains it.
|
|
125
|
+
await session.attach();
|
|
126
|
+
// An interactive attachment holds the runtime's controller lease; without
|
|
127
|
+
// it the first submit comes back `lease_expired`.
|
|
128
|
+
await session.takeControlAndHold().catch((error) => {
|
|
129
|
+
log.warn(`Volter Harness could not take control of ${baseUrl}: ${String(error?.message ?? error)}`);
|
|
130
|
+
});
|
|
131
|
+
// The runtime's stream can end under a live attachment: an HTTP client's
|
|
132
|
+
// body timeout ended an idle one at 300 s (the runtime sent no keep-alive),
|
|
133
|
+
// and the view went on showing a turn it would never see finish, with the
|
|
134
|
+
// next prompt queued behind it. While this connection is still the
|
|
135
|
+
// registered one, it re-attaches from its cursor and follows again.
|
|
136
|
+
let connection;
|
|
137
|
+
const stillWanted = () => connection !== undefined && this.#connections.get(id) === connection && session.connection !== 'closed';
|
|
138
|
+
const following = (async () => {
|
|
139
|
+
for (let attempt = 0;; attempt += 1) {
|
|
140
|
+
try {
|
|
141
|
+
await session.follow();
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
log.warn(`Volter Harness event stream ended: ${String(error?.message ?? error)}`);
|
|
145
|
+
}
|
|
146
|
+
if (!stillWanted())
|
|
147
|
+
return;
|
|
148
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(15_000, 500 * 2 ** Math.min(attempt, 5))));
|
|
149
|
+
if (!stillWanted())
|
|
150
|
+
return;
|
|
151
|
+
try {
|
|
152
|
+
await session.attach();
|
|
153
|
+
attempt = -1;
|
|
154
|
+
log.info(`Volter Harness re-attached to ${baseUrl} at event ${session.cursor}`);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
log.warn(`Volter Harness could not re-attach to ${baseUrl}: ${String(error?.message ?? error)}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
})();
|
|
161
|
+
connection = {
|
|
162
|
+
id,
|
|
163
|
+
label: options.label ?? session.descriptor?.session_id ?? baseUrl,
|
|
164
|
+
baseUrl,
|
|
165
|
+
session,
|
|
166
|
+
following,
|
|
167
|
+
};
|
|
168
|
+
session.onChange(() => this.#changed.fire());
|
|
169
|
+
this.#connections.set(id, connection);
|
|
170
|
+
this.#changed.fire();
|
|
171
|
+
this.setUnavailable(undefined);
|
|
172
|
+
log.info(`Volter Harness attached: ${connection.label} (${baseUrl}) as ${permissions.join(',')}` +
|
|
173
|
+
` · ${session.history.length} history messages`);
|
|
174
|
+
return connection;
|
|
175
|
+
}
|
|
176
|
+
async disconnect(id) {
|
|
177
|
+
const targets = id ? [this.#connections.get(id)].filter(Boolean) : this.all;
|
|
178
|
+
for (const connection of targets) {
|
|
179
|
+
try {
|
|
180
|
+
if (connection.session.actions().detach)
|
|
181
|
+
await connection.session.detach();
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// Detaching is best effort; dropping the client never closes a runtime.
|
|
185
|
+
}
|
|
186
|
+
connection.session.dispose();
|
|
187
|
+
this.#connections.delete(connection.id);
|
|
188
|
+
}
|
|
189
|
+
this.#changed.fire();
|
|
190
|
+
}
|
|
191
|
+
dispose() {
|
|
192
|
+
for (const connection of this.all)
|
|
193
|
+
connection.session.dispose();
|
|
194
|
+
this.#connections.clear();
|
|
195
|
+
this.#changed.dispose();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function writeProjected(stream, projected, granted, state) {
|
|
199
|
+
for (const candidate of projected) {
|
|
200
|
+
const parts = resolveParts([candidate], granted);
|
|
201
|
+
for (const part of parts) {
|
|
202
|
+
try {
|
|
203
|
+
writePart(stream, part, state);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// A host that advertised the proposal but refuses the call still gets
|
|
207
|
+
// the stable rendering rather than a dropped event.
|
|
208
|
+
for (const fallback of candidate.fallback ?? []) {
|
|
209
|
+
try {
|
|
210
|
+
writePart(stream, fallback, state);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
stream.markdown(opaqueText(fallback));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function writePart(stream, part, state) {
|
|
221
|
+
switch (part.part) {
|
|
222
|
+
case 'markdown':
|
|
223
|
+
stream.markdown(part.value);
|
|
224
|
+
return;
|
|
225
|
+
case 'progress':
|
|
226
|
+
stream.progress(part.value);
|
|
227
|
+
return;
|
|
228
|
+
case 'warning':
|
|
229
|
+
stream.warning(part.value);
|
|
230
|
+
return;
|
|
231
|
+
case 'codeblock':
|
|
232
|
+
stream.codeblockUri(vscode.Uri.parse(part.uri));
|
|
233
|
+
stream.markdown(part.value);
|
|
234
|
+
return;
|
|
235
|
+
case 'thinking':
|
|
236
|
+
stream.push(new vscode.ChatResponseThinkingProgressPart(part.value));
|
|
237
|
+
return;
|
|
238
|
+
case 'reference': {
|
|
239
|
+
// Only a path the runtime's own payload named, resolved against the
|
|
240
|
+
// workspace this window already has. Nothing is created or fabricated.
|
|
241
|
+
const folder = vscode.workspace.workspaceFolders?.[0];
|
|
242
|
+
if (folder)
|
|
243
|
+
stream.reference(vscode.Uri.joinPath(folder.uri, part.path));
|
|
244
|
+
else
|
|
245
|
+
stream.markdown(`\`${part.path}\``);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
case 'answers':
|
|
249
|
+
// Stable API on purpose: a command button runs INSIDE the live turn,
|
|
250
|
+
// which is when a supercode approval is raised. See `view.ts`.
|
|
251
|
+
stream.markdown(`\n\n**${part.title}**\n\n${part.message}\n\n`);
|
|
252
|
+
for (const answer of part.answers) {
|
|
253
|
+
stream.button({
|
|
254
|
+
title: answer.label,
|
|
255
|
+
command: 'supercode.frontend.respond',
|
|
256
|
+
arguments: [answer],
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
case 'tool': {
|
|
261
|
+
const toolName = part.toolName || state.toolNames.get(part.toolCallId) || 'tool';
|
|
262
|
+
if (part.toolName)
|
|
263
|
+
state.toolNames.set(part.toolCallId, part.toolName);
|
|
264
|
+
const invocation = new vscode.ChatToolInvocationPart(toolName, part.toolCallId);
|
|
265
|
+
invocation.enablePartialUpdate = true;
|
|
266
|
+
invocation.invocationMessage = part.invocationMessage || toolName;
|
|
267
|
+
invocation.isConfirmed = true;
|
|
268
|
+
invocation.isComplete = part.isComplete;
|
|
269
|
+
invocation.pastTenseMessage = part.pastTenseMessage || toolName;
|
|
270
|
+
if (part.isError !== undefined)
|
|
271
|
+
invocation.isError = part.isError;
|
|
272
|
+
if (part.inputText !== undefined)
|
|
273
|
+
state.toolInputs.set(part.toolCallId, part.inputText);
|
|
274
|
+
if (part.isComplete) {
|
|
275
|
+
invocation.toolSpecificData = {
|
|
276
|
+
input: state.toolInputs.get(part.toolCallId) ?? '',
|
|
277
|
+
output: part.resultText ?? '',
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
stream.push(invocation);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function pendingRequestOf(connection) {
|
|
286
|
+
return connection.session.pendingRequests.values().next().value;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* What the person reads when there is no runtime. A HOST-SUPPLIED reason is
|
|
290
|
+
* printed verbatim, because the host is the only thing that knows which
|
|
291
|
+
* harness it tried to start and what the person must type; this package would
|
|
292
|
+
* only be guessing. Without one it falls back to its own configuration
|
|
293
|
+
* pointer.
|
|
294
|
+
*/
|
|
295
|
+
function unavailableMarkdown(reason) {
|
|
296
|
+
if (reason) {
|
|
297
|
+
return ['**Volter Harness has no runtime attached.**', '', reason, ''].join('\n');
|
|
298
|
+
}
|
|
299
|
+
return [
|
|
300
|
+
'**Volter Harness is not attached to a runtime.**',
|
|
301
|
+
'',
|
|
302
|
+
`Set \`supercode.frontend.baseUrl\`, then run **Volter Harness: Set Runtime Token** — or start the editor from a runtime that exports \`${ENV.url}\`.`,
|
|
303
|
+
'',
|
|
304
|
+
].join('\n');
|
|
305
|
+
}
|
|
306
|
+
export async function activate(context) {
|
|
307
|
+
const log = vscode.window.createOutputChannel('Volter Harness', { log: true });
|
|
308
|
+
context.subscriptions.push(log);
|
|
309
|
+
// The HOST decides which proposals this extension got; its own manifest only
|
|
310
|
+
// asks. `context.extension.packageJSON` is the filtered extension
|
|
311
|
+
// description, so this is the grant, not the request.
|
|
312
|
+
const granted = grantedProposals(context.extension.packageJSON);
|
|
313
|
+
log.info(`Volter Harness: proposals requested ${REQUESTED_PROPOSALS.join(', ')}; granted ${granted.size ? [...granted].join(', ') : 'none (stable chat parts only)'}`);
|
|
314
|
+
const controls = await ChatHostControls.fromEnvironment();
|
|
315
|
+
const controlsChanged = new vscode.EventEmitter();
|
|
316
|
+
context.subscriptions.push(controlsChanged);
|
|
317
|
+
let permission = 'ask';
|
|
318
|
+
const permissions = new Map(context.workspaceState.get('supercode.sessionPermissions', []));
|
|
319
|
+
const savePermission = (id, value) => {
|
|
320
|
+
permissions.set(id, value);
|
|
321
|
+
void Promise.resolve(context.workspaceState.update('supercode.sessionPermissions', [...permissions])).catch(error => log.error(`Could not persist conversation permissions: ${String(error)}`));
|
|
322
|
+
};
|
|
323
|
+
const resourceBindings = new Map(context.workspaceState.get('supercode.sessionBindings', []));
|
|
324
|
+
const boundId = (resource) => resolveSessionBinding(resource, controls?.state.sessions ?? [], resourceBindings);
|
|
325
|
+
let applying = Promise.resolve();
|
|
326
|
+
const frontends = new SupercodeFrontends();
|
|
327
|
+
let approvalAvailable = false;
|
|
328
|
+
context.subscriptions.push(frontends.onDidChange(() => {
|
|
329
|
+
const active = frontends.get(undefined);
|
|
330
|
+
const available = Boolean(active?.session.descriptor?.actions.respond && active.session.permissions.has('approve'));
|
|
331
|
+
if (available !== approvalAvailable) {
|
|
332
|
+
approvalAvailable = available;
|
|
333
|
+
controlsChanged.fire();
|
|
334
|
+
}
|
|
335
|
+
}));
|
|
336
|
+
const identity = () => {
|
|
337
|
+
const descriptor = frontends.get(undefined)?.session.descriptor;
|
|
338
|
+
return runtimeIdentity(controls?.state.selection.harness ?? descriptor?.source_harness, controls?.state.selection.model || controls?.state.actualModel || descriptor?.model);
|
|
339
|
+
};
|
|
340
|
+
const icon = () => {
|
|
341
|
+
const provider = identity().provider;
|
|
342
|
+
return provider ? vscode.Uri.joinPath(context.extensionUri, 'media', `${provider}.svg`) : new vscode.ThemeIcon('hubot');
|
|
343
|
+
};
|
|
344
|
+
const nativeModels = Boolean(controls && granted.has('chatProvider'));
|
|
345
|
+
const modelSelections = new Map();
|
|
346
|
+
if (nativeModels && controls) {
|
|
347
|
+
context.subscriptions.push(vscode.lm.registerLanguageModelChatProvider('supercode-runtime', {
|
|
348
|
+
onDidChangeLanguageModelChatInformation: controlsChanged.event,
|
|
349
|
+
provideLanguageModelChatInformation: () => {
|
|
350
|
+
const { selection, models } = controls.state;
|
|
351
|
+
// A running harness does not advertise hot model changes. Configuration
|
|
352
|
+
// belongs to the explicit new-conversation flow.
|
|
353
|
+
const choices = models.filter(model => model.id === selection.model);
|
|
354
|
+
if (!choices.some(model => model.id === selection.model))
|
|
355
|
+
choices.push({ id: selection.model, name: selection.model });
|
|
356
|
+
modelSelections.clear();
|
|
357
|
+
return choices.map(model => {
|
|
358
|
+
const id = `${selection.harness}/${encodeURIComponent(model.id || '__default__')}`;
|
|
359
|
+
modelSelections.set(id, { ...selection, model: model.id });
|
|
360
|
+
const provider = runtimeIdentity(selection.harness, selection.model).provider;
|
|
361
|
+
return { id, name: model.name, family: selection.harness, version: '1',
|
|
362
|
+
statusIcon: new vscode.ThemeIcon(provider === 'anthropic' ? 'claude' : provider === 'openai' ? 'openai' : provider === 'google' ? 'google-gemini' : 'sparkle'),
|
|
363
|
+
maxInputTokens: 0, maxOutputTokens: 0, capabilities: {},
|
|
364
|
+
targetChatSessionType: SESSION_SCHEME, isUserSelectable: true, isDefault: model.id === selection.model,
|
|
365
|
+
tooltip: `${model.name} via ${harnessDisplayName(selection.harness)}${selection.effort ? ` · ${selection.effort} reasoning` : ''}. Model is fixed for this conversation. Use New Chat to choose another model.`,
|
|
366
|
+
};
|
|
367
|
+
});
|
|
368
|
+
},
|
|
369
|
+
// Like the workbench's AgentHostLanguageModelProvider, these are session
|
|
370
|
+
// model descriptors. The participant owns the conversation and tools.
|
|
371
|
+
provideLanguageModelChatResponse: async () => { throw new Error('Use these models through their Volter Harness chat session. Direct model requests are not supported.'); },
|
|
372
|
+
provideTokenCount: async () => 0,
|
|
373
|
+
}));
|
|
374
|
+
}
|
|
375
|
+
const currentOptions = (resource) => {
|
|
376
|
+
const id = resource ? boundId(resource) : controls?.state.activeSession;
|
|
377
|
+
return { permissions: id ? permissions.get(id) ?? 'ask' : permission };
|
|
378
|
+
};
|
|
379
|
+
const connectManaged = async () => {
|
|
380
|
+
const env = controls.state.connection;
|
|
381
|
+
if (!env || !controls.state.activeSession)
|
|
382
|
+
throw new Error('The requested conversation has no runtime connection.');
|
|
383
|
+
const id = controls.state.activeSession;
|
|
384
|
+
const existing = frontends.get(id);
|
|
385
|
+
if (existing && existing.baseUrl === env.SUPERCODE_FRONTEND_URL)
|
|
386
|
+
return existing;
|
|
387
|
+
await frontends.disconnect();
|
|
388
|
+
const connection = await frontends.connect({ connectionId: id, baseUrl: env.SUPERCODE_FRONTEND_URL,
|
|
389
|
+
token: await readCredentialFile(env.SUPERCODE_FRONTEND_CREDENTIAL_FILE), clientId: env.SUPERCODE_FRONTEND_CLIENT_ID,
|
|
390
|
+
permissions: normalizePermissions(env.SUPERCODE_FRONTEND_PERMISSIONS, ['observe']), label: identity().name }, log);
|
|
391
|
+
if (!connection)
|
|
392
|
+
throw new Error('Could not connect to the requested conversation.');
|
|
393
|
+
return connection;
|
|
394
|
+
};
|
|
395
|
+
const revealManaged = async () => {
|
|
396
|
+
const id = controls?.state.activeSession;
|
|
397
|
+
if (!id || !controls?.state.openSessionCommand)
|
|
398
|
+
throw new Error('The host cannot reveal this conversation.');
|
|
399
|
+
await vscode.commands.executeCommand(controls.state.openSessionCommand, vscode.Uri.from({ scheme: SESSION_SCHEME, authority: id, path: '/conversation' }).toString());
|
|
400
|
+
};
|
|
401
|
+
const connectionFor = async (resource) => {
|
|
402
|
+
if (!controls) {
|
|
403
|
+
const connection = frontends.get(resource.authority);
|
|
404
|
+
if (!connection)
|
|
405
|
+
throw new Error('This conversation is no longer connected.');
|
|
406
|
+
return connection;
|
|
407
|
+
}
|
|
408
|
+
const id = boundId(resource);
|
|
409
|
+
if (!id)
|
|
410
|
+
throw new Error('This chat has no backend session binding. Start a new chat; no message was sent.');
|
|
411
|
+
const active = frontends.get(undefined);
|
|
412
|
+
if (active && active.id !== id && (active.session.descriptor?.turn_state === 'busy' || active.session.pendingRequests.size))
|
|
413
|
+
throw new Error('Finish or cancel the active turn before opening another conversation.');
|
|
414
|
+
try {
|
|
415
|
+
await controls.open(id);
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
log.error(`Could not restore conversation ${id}: ${String(error)}`);
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
const history = controls.state.history;
|
|
422
|
+
const historyTruncated = controls.state.historyTruncated;
|
|
423
|
+
const connection = await connectManaged();
|
|
424
|
+
connection.persistedHistory = history;
|
|
425
|
+
connection.historyTruncated = historyTruncated;
|
|
426
|
+
// Resolving an already-focused session must not republish its catalogue
|
|
427
|
+
// while the native widget is still opening that same resource.
|
|
428
|
+
if (active !== connection)
|
|
429
|
+
controlsChanged.fire();
|
|
430
|
+
return connection;
|
|
431
|
+
};
|
|
432
|
+
async function applySelection(selection, openChat = true) {
|
|
433
|
+
if (!controls)
|
|
434
|
+
throw new Error('This host does not expose harness or model configuration.');
|
|
435
|
+
const active = frontends.get(undefined);
|
|
436
|
+
if (active?.session.descriptor?.turn_state === 'busy' || active?.session.pendingRequests.size)
|
|
437
|
+
throw new Error('Finish or cancel the current turn before starting a new chat.');
|
|
438
|
+
await controls.select(selection);
|
|
439
|
+
await connectManaged();
|
|
440
|
+
controlsChanged.fire();
|
|
441
|
+
if (openChat)
|
|
442
|
+
await revealManaged();
|
|
443
|
+
}
|
|
444
|
+
const enqueueSelection = (selection, openChat = true) => {
|
|
445
|
+
const pending = applying.then(() => applySelection(selection, openChat));
|
|
446
|
+
applying = pending.catch(() => undefined);
|
|
447
|
+
return pending;
|
|
448
|
+
};
|
|
449
|
+
// Publish capability-honest input groups only after the handoff descriptor is available.
|
|
450
|
+
if (controls?.state.connection)
|
|
451
|
+
await connectManaged().catch(error => log.warn(`Could not attach before registering chat input: ${String(error)}`));
|
|
452
|
+
const providerControls = {
|
|
453
|
+
changed: controlsChanged.event,
|
|
454
|
+
currentOptions,
|
|
455
|
+
options: (resource) => {
|
|
456
|
+
const id = resource ? boundId(resource) : undefined;
|
|
457
|
+
const active = resource ? (id ? frontends.get(id) : undefined) : frontends.get(undefined);
|
|
458
|
+
const canApprove = active?.session.descriptor?.actions.respond && active.session.permissions.has('approve');
|
|
459
|
+
const permissionItems = [{ id: 'ask', name: canApprove ? 'Ask for approval' : 'Harness permissions', description: canApprove ? 'Show each approval requested by the harness.' : 'Approval controls are unavailable for this harness.' },
|
|
460
|
+
...((canApprove || (!active && resource && currentOptions(resource).permissions === 'autoApprove')) ? [{ id: 'autoApprove', name: 'Auto approve', description: 'Allow tool approval requests for this chat. Harness sandbox rules still apply.' }] : [])];
|
|
461
|
+
const groups = [{ id: 'permissions', name: 'Permissions', kind: 'permissions', items: permissionItems, selected: permissionItems.find(i => i.id === (resource ? currentOptions(resource).permissions : 'ask')) }];
|
|
462
|
+
return groups;
|
|
463
|
+
},
|
|
464
|
+
setPermission: (resource, value) => {
|
|
465
|
+
const id = boundId(resource);
|
|
466
|
+
if (controls && !id)
|
|
467
|
+
throw new Error('This conversation has no session binding.');
|
|
468
|
+
if (id)
|
|
469
|
+
savePermission(id, turnPermission(value));
|
|
470
|
+
else
|
|
471
|
+
permission = turnPermission(value);
|
|
472
|
+
},
|
|
473
|
+
create: async (inputState) => {
|
|
474
|
+
if (!controls)
|
|
475
|
+
throw new Error('The host does not expose conversation creation.');
|
|
476
|
+
await enqueueSelection({ ...controls.state.selection }, false);
|
|
477
|
+
const connection = frontends.get(controls.state.activeSession);
|
|
478
|
+
savePermission(connection.id, turnPermission(inputState.groups.find(group => group.id === 'permissions')?.selected?.id));
|
|
479
|
+
return connection;
|
|
480
|
+
},
|
|
481
|
+
resolve: connectionFor,
|
|
482
|
+
catalogue: () => controls?.state.sessions ?? [],
|
|
483
|
+
bindings: boundId,
|
|
484
|
+
};
|
|
485
|
+
context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.activateSession', async (resource) => { await connectionFor(vscode.Uri.parse(resource)); }));
|
|
486
|
+
context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.openSession', async (id) => {
|
|
487
|
+
await connectionFor(vscode.Uri.from({ scheme: SESSION_SCHEME, authority: id, path: '/conversation' }));
|
|
488
|
+
await revealManaged();
|
|
489
|
+
}));
|
|
490
|
+
context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.configure', async (supplied) => {
|
|
491
|
+
if (supplied?.permissions) {
|
|
492
|
+
const id = controls?.state.activeSession;
|
|
493
|
+
if (id)
|
|
494
|
+
savePermission(id, turnPermission(supplied.permissions));
|
|
495
|
+
else
|
|
496
|
+
permission = turnPermission(supplied.permissions);
|
|
497
|
+
controlsChanged.fire();
|
|
498
|
+
return currentOptions();
|
|
499
|
+
}
|
|
500
|
+
if (supplied && typeof supplied.harness === 'string') {
|
|
501
|
+
await enqueueSelection(supplied);
|
|
502
|
+
return currentOptions();
|
|
503
|
+
}
|
|
504
|
+
if (!controls) {
|
|
505
|
+
void vscode.window.showInformationMessage('This host does not expose chat configuration.');
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const selection = { ...controls.state.selection };
|
|
509
|
+
const harness = await vscode.window.showQuickPick(controls.state.harnesses.map(h => ({ label: h.name, id: h.id })), { title: 'New chat · Harness', placeHolder: 'Choose the harness for a new conversation. Existing chats keep their harness and history.' });
|
|
510
|
+
if (!harness)
|
|
511
|
+
return;
|
|
512
|
+
if (harness.id !== selection.harness) {
|
|
513
|
+
selection.harness = harness.id;
|
|
514
|
+
selection.model = '';
|
|
515
|
+
selection.effort = '';
|
|
516
|
+
}
|
|
517
|
+
if (['claude-code', 'codex'].includes(selection.harness)) {
|
|
518
|
+
const choices = controls.state.modelsByHarness?.[selection.harness] ?? [{ id: '', name: 'Harness default' }];
|
|
519
|
+
const picked = await vscode.window.showQuickPick([...choices.map(model => ({ label: model.name, id: model.id })), { label: 'Enter an exact model ID…', id: '__custom__' }], { title: 'New chat · Model', placeHolder: 'Model availability depends on your harness account.' });
|
|
520
|
+
if (!picked)
|
|
521
|
+
return;
|
|
522
|
+
if (picked.id === '__custom__') {
|
|
523
|
+
const model = await vscode.window.showInputBox({ title: 'New chat · Exact model', prompt: 'Model ID or CLI alias supported by this harness account.', value: selection.model });
|
|
524
|
+
if (model === undefined)
|
|
525
|
+
return;
|
|
526
|
+
selection.model = model.trim();
|
|
527
|
+
}
|
|
528
|
+
else
|
|
529
|
+
selection.model = picked.id;
|
|
530
|
+
const effort = await vscode.window.showQuickPick([{ label: 'Harness default', id: '' }, ...['low', 'medium', 'high'].map(id => ({ label: id, id }))], { title: 'New chat · Reasoning effort' });
|
|
531
|
+
if (!effort)
|
|
532
|
+
return;
|
|
533
|
+
selection.effort = effort.id;
|
|
534
|
+
}
|
|
535
|
+
await enqueueSelection(selection);
|
|
536
|
+
}));
|
|
537
|
+
context.subscriptions.push({ dispose: () => frontends.dispose() });
|
|
538
|
+
context.subscriptions.push(frontends.onDidChange(() => {
|
|
539
|
+
void vscode.commands.executeCommand('setContext', ATTACHED_CONTEXT_KEY, frontends.all.length > 0);
|
|
540
|
+
}));
|
|
541
|
+
// From the connections, not `false`: one made earlier in activation (the editor host
|
|
542
|
+
// attaches before this line) already fired its change, and `false` here outlived it.
|
|
543
|
+
await vscode.commands.executeCommand('setContext', ATTACHED_CONTEXT_KEY, frontends.all.length > 0);
|
|
544
|
+
const runTurnInto = async (connection, prompt, stream, token, approval) => {
|
|
545
|
+
if (approval === 'autoApprove' && (!connection.session.descriptor?.actions.respond || !connection.session.permissions.has('approve'))) {
|
|
546
|
+
throw new Error('This harness does not support automatic approval through Chat. Select Harness permissions.');
|
|
547
|
+
}
|
|
548
|
+
const answered = new Set();
|
|
549
|
+
const state = { toolInputs: new Map(), toolNames: new Map() };
|
|
550
|
+
const projection = {
|
|
551
|
+
connectionId: connection.id,
|
|
552
|
+
pendingRequest: (requestId) => connection.session.pendingRequests.get(requestId) ?? connection.session.resolvedRequests.get(requestId),
|
|
553
|
+
canRespond: connection.session.descriptor?.actions.respond !== false,
|
|
554
|
+
};
|
|
555
|
+
const cancellation = token.onCancellationRequested(() => {
|
|
556
|
+
void connection.session.interrupt().catch(() => {
|
|
557
|
+
// The runtime may already be idle; interrupting twice is not an error
|
|
558
|
+
// this view reports.
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
try {
|
|
562
|
+
await connection.session.runTurn(prompt, (event) => {
|
|
563
|
+
writeProjected(stream, projectEvent(event, projection), granted, state);
|
|
564
|
+
if (approval === 'autoApprove' && !token.isCancellationRequested) {
|
|
565
|
+
for (const pending of connection.session.pendingRequests.values()) {
|
|
566
|
+
if (pending.kind !== 'approval' || answered.has(pending.id))
|
|
567
|
+
continue;
|
|
568
|
+
const decisions = acceptedDecisions(pending);
|
|
569
|
+
if (decisions && !decisions.includes('allow'))
|
|
570
|
+
continue;
|
|
571
|
+
answered.add(pending.id);
|
|
572
|
+
void connection.session.respond({ kind: 'approval', request_id: pending.id, decision: 'allow' }).catch(error => {
|
|
573
|
+
stream.markdown(`\nApproval failed: ${String(error.message || error)}. Use the approval buttons to answer.\n`);
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
finally {
|
|
580
|
+
cancellation.dispose();
|
|
581
|
+
if (controls) {
|
|
582
|
+
if (connection.session.descriptor?.session_id)
|
|
583
|
+
await controls.remember(connection.id, connection.session.descriptor.session_id).catch(error => log.warn(`Could not save conversation identity: ${String(error)}`));
|
|
584
|
+
await controls.refresh().catch(error => log.warn(`Could not refresh chat identity: ${String(error)}`));
|
|
585
|
+
controlsChanged.fire();
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
const handler = async (request, chatContext, stream, token) => {
|
|
590
|
+
await applying;
|
|
591
|
+
const requestedResource = chatContext.chatSessionContext?.chatSessionItem?.resource;
|
|
592
|
+
const connection = requestedResource
|
|
593
|
+
? await connectionFor(requestedResource)
|
|
594
|
+
: controls ? undefined : frontends.get(undefined);
|
|
595
|
+
if (controls && connection && request.model?.vendor === 'supercode-runtime') {
|
|
596
|
+
const selection = controls.state.sessions.find(s => s.id === connection.id).selection;
|
|
597
|
+
const expected = `${selection.harness}/${encodeURIComponent(selection.model || '__default__')}`;
|
|
598
|
+
if (request.model.id !== expected || (typeof request.modelConfiguration?.reasoningEffort === 'string' && request.modelConfiguration.reasoningEffort !== selection.effort)) {
|
|
599
|
+
throw new Error('This conversation has fixed model settings. Start a new chat to choose different settings; no message was sent.');
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (!connection) {
|
|
603
|
+
stream.markdown(unavailableMarkdown(frontends.unavailable));
|
|
604
|
+
return {};
|
|
605
|
+
}
|
|
606
|
+
// While a request is pending, a typed message is its answer — this is the
|
|
607
|
+
// path for the answers no button can carry (`allow for session`, `cancel`,
|
|
608
|
+
// and an elicitation's free-text content).
|
|
609
|
+
const pending = pendingRequestOf(connection);
|
|
610
|
+
if (pending) {
|
|
611
|
+
const answer = typedAnswer(pending, request.prompt);
|
|
612
|
+
await connection.session.respond(answer);
|
|
613
|
+
stream.markdown(`_answered request #${pending.id}: ${opaqueText(answer, 256)}_`);
|
|
614
|
+
return {};
|
|
615
|
+
}
|
|
616
|
+
if (request.prompt.trim().length === 0) {
|
|
617
|
+
stream.markdown('_nothing to submit_');
|
|
618
|
+
return {};
|
|
619
|
+
}
|
|
620
|
+
const approval = turnPermission(controls ? permissions.get(connection.id) ?? 'ask' : request.permissionLevel);
|
|
621
|
+
const attached = [];
|
|
622
|
+
for (const reference of request.references) {
|
|
623
|
+
const value = reference.value;
|
|
624
|
+
const location = value instanceof vscode.Location ? value : undefined;
|
|
625
|
+
const uri = location?.uri ?? (value instanceof vscode.Uri ? value : undefined);
|
|
626
|
+
if (!uri || !['file', 'vscode-remote', 'untitled'].includes(uri.scheme))
|
|
627
|
+
throw new Error('This harness integration supports text files and selections only. Remove unsupported context before sending.');
|
|
628
|
+
const document = await vscode.workspace.openTextDocument(uri);
|
|
629
|
+
attached.push({ name: uri.toString(), text: document.getText(location?.range) });
|
|
630
|
+
}
|
|
631
|
+
const prompt = withTextContext(request.prompt, attached);
|
|
632
|
+
await runTurnInto(connection, prompt, stream, token, approval);
|
|
633
|
+
return {};
|
|
634
|
+
};
|
|
635
|
+
const participants = [];
|
|
636
|
+
for (const id of granted.has('defaultChatParticipant') ? [PARTICIPANT_ID, DEFAULT_PARTICIPANT_ID] : [PARTICIPANT_ID]) {
|
|
637
|
+
try {
|
|
638
|
+
const participant = vscode.chat.createChatParticipant(id, handler);
|
|
639
|
+
participant.iconPath = icon();
|
|
640
|
+
participants.push(participant);
|
|
641
|
+
context.subscriptions.push(participant);
|
|
642
|
+
}
|
|
643
|
+
catch (error) {
|
|
644
|
+
log.warn(`Volter Harness chat participant ${id} was not registered: ${String(error?.message ?? error)}`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// The runtime's sessions in the Chat view's own picker. Without the
|
|
648
|
+
// `chatSessionsProvider` grant the participant alone still works.
|
|
649
|
+
if (granted.has('chatSessionsProvider')) {
|
|
650
|
+
try {
|
|
651
|
+
// Requests route to the session type. Register its dynamic identity here;
|
|
652
|
+
// canDelegate is disabled to avoid a generic workbench-owned registration.
|
|
653
|
+
// A participant names the harness; the native model picker names the model.
|
|
654
|
+
// Keep the author stable when a model changes during request dispatch.
|
|
655
|
+
const participantName = () => harnessDisplayName(controls?.state.selection.harness ?? frontends.get(undefined)?.session.descriptor?.source_harness);
|
|
656
|
+
const participantKey = () => `${participantName()}/${identity().provider ?? ''}`;
|
|
657
|
+
const makeParticipant = () => {
|
|
658
|
+
const current = { name: participantName() };
|
|
659
|
+
const participant = granted.has('chatParticipantPrivate')
|
|
660
|
+
? vscode.chat.createDynamicChatParticipant(SESSION_SCHEME, { name: current.name.replace(/[^a-zA-Z0-9]/g, ''), fullName: current.name, publisherName: 'Volter', description: 'Ask a question or describe a task' }, handler)
|
|
661
|
+
: vscode.chat.createChatParticipant(SESSION_SCHEME, handler);
|
|
662
|
+
participant.iconPath = icon();
|
|
663
|
+
return participant;
|
|
664
|
+
};
|
|
665
|
+
let sessionParticipant = makeParticipant();
|
|
666
|
+
let previousIdentity = participantKey();
|
|
667
|
+
const updateIdentity = () => {
|
|
668
|
+
if (participantKey() === previousIdentity)
|
|
669
|
+
return;
|
|
670
|
+
previousIdentity = participantKey();
|
|
671
|
+
sessionParticipant.dispose();
|
|
672
|
+
sessionParticipant = makeParticipant();
|
|
673
|
+
for (const p of participants)
|
|
674
|
+
p.iconPath = icon();
|
|
675
|
+
};
|
|
676
|
+
context.subscriptions.push({ dispose: () => sessionParticipant.dispose() }, controlsChanged.event(updateIdentity), frontends.onDidChange(updateIdentity));
|
|
677
|
+
registerSessions(context, frontends, sessionParticipant, granted, handler, controls ? providerControls : undefined);
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
log.warn(`Volter Harness chat sessions were not registered: ${String(error?.message ?? error)}`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* The one door an answer takes, whether it came from the inline question or
|
|
685
|
+
* from a fallback command button. A button cannot be withdrawn once pushed,
|
|
686
|
+
* so a click on an already-resolved request must be a no-op that SAYS so —
|
|
687
|
+
* never a stale `respond`, and never a silent nothing.
|
|
688
|
+
*/
|
|
689
|
+
async function answerRequest(answer) {
|
|
690
|
+
if (!answer || typeof answer.requestId !== 'number')
|
|
691
|
+
return;
|
|
692
|
+
const connection = frontends.get(answer.connectionId);
|
|
693
|
+
if (!connection) {
|
|
694
|
+
void vscode.window.showWarningMessage('Volter Harness is no longer attached to that runtime.');
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const request = connection.session.pendingRequests.get(answer.requestId);
|
|
698
|
+
if (!request) {
|
|
699
|
+
void vscode.window.showInformationMessage(`Volter Harness: request #${answer.requestId} is already resolved.`);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
let response = answer.response;
|
|
703
|
+
if (answer.promptForContent) {
|
|
704
|
+
const content = await vscode.window.showInputBox({
|
|
705
|
+
title: 'Volter Harness runtime request',
|
|
706
|
+
prompt: 'The runtime asked for a value; a button cannot carry one.',
|
|
707
|
+
ignoreFocusOut: true,
|
|
708
|
+
});
|
|
709
|
+
if (content === undefined)
|
|
710
|
+
return;
|
|
711
|
+
response = { ...response, content };
|
|
712
|
+
}
|
|
713
|
+
try {
|
|
714
|
+
await connection.session.respond(response);
|
|
715
|
+
void vscode.window.setStatusBarMessage(`Volter Harness: ${resolutionSentence(response, request, answer.requestId)}`, 4000);
|
|
716
|
+
}
|
|
717
|
+
catch (error) {
|
|
718
|
+
if (error instanceof RequestNotPendingError) {
|
|
719
|
+
void vscode.window.showInformationMessage(`Volter Harness: request #${answer.requestId} is already resolved.`);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
void vscode.window.showErrorMessage(`Volter Harness could not answer request #${answer.requestId}: ${String(error?.message ?? error)}`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
const secretKey = (baseUrl) => `supercode.frontend.token:${baseUrl}`;
|
|
726
|
+
// The runtime's credential FILE is one-shot, and an extension host reloads.
|
|
727
|
+
// Remembering which runtime this window was handed lets the next activation
|
|
728
|
+
// reconnect from SecretStorage instead of losing the attachment.
|
|
729
|
+
const HANDED_OVER_KEY = 'supercode.frontend.handedOverBaseUrl';
|
|
730
|
+
context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.setToken', async () => {
|
|
731
|
+
const settings = vscode.workspace.getConfiguration('supercode.frontend');
|
|
732
|
+
const baseUrl = await vscode.window.showInputBox({
|
|
733
|
+
title: 'Volter Harness runtime',
|
|
734
|
+
prompt: 'Base URL of the already-running Volter Harness SDK runtime',
|
|
735
|
+
value: settings.get('baseUrl') ?? 'http://127.0.0.1:0',
|
|
736
|
+
ignoreFocusOut: true,
|
|
737
|
+
});
|
|
738
|
+
if (!baseUrl)
|
|
739
|
+
return;
|
|
740
|
+
const token = await vscode.window.showInputBox({
|
|
741
|
+
title: 'Volter Harness runtime token',
|
|
742
|
+
prompt: 'Scoped runtime credential — stored in SecretStorage, sent only in Authorization',
|
|
743
|
+
password: true,
|
|
744
|
+
ignoreFocusOut: true,
|
|
745
|
+
});
|
|
746
|
+
if (!token)
|
|
747
|
+
return;
|
|
748
|
+
await settings.update('baseUrl', baseUrl, vscode.ConfigurationTarget.Global);
|
|
749
|
+
await context.secrets.store(secretKey(baseUrl), token);
|
|
750
|
+
await connectFromSettings();
|
|
751
|
+
}), vscode.commands.registerCommand('supercode.frontend.connect', () => connectFromSettings()),
|
|
752
|
+
// The create command the core's own `chatSessions/newSession` menu invokes
|
|
753
|
+
// for this session type (`chatSessions.contribution.ts`
|
|
754
|
+
// `_resolveCreateSubMenuCommandId`). It opens a chat on the supercode
|
|
755
|
+
// session type through the editor's own action; nothing here is a second
|
|
756
|
+
// way to reach the runtime.
|
|
757
|
+
vscode.commands.registerCommand('supercode.frontend.newSession', async (position) => {
|
|
758
|
+
if (controls) {
|
|
759
|
+
await vscode.commands.executeCommand('supercode.frontend.configure');
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const where = position === 'editor' ? 'editor' : 'sidebar';
|
|
763
|
+
await vscode.commands.executeCommand(`workbench.action.chat.openNewChatSessionInPlace.${SESSION_SCHEME}`, where);
|
|
764
|
+
}), vscode.commands.registerCommand('supercode.frontend.disconnect', () => frontends.disconnect()),
|
|
765
|
+
// The one door an answer button uses. It answers the runtime's pending
|
|
766
|
+
// request through `frontend.v2.respond` and narrates itself, because a
|
|
767
|
+
// click whose effect is invisible is a click nobody can trust.
|
|
768
|
+
vscode.commands.registerCommand('supercode.frontend.respond', (answer) => answerRequest(answer)), vscode.commands.registerCommand('supercode.frontend.status', async (inspect) => {
|
|
769
|
+
if (controls)
|
|
770
|
+
await controls.refresh();
|
|
771
|
+
if (inspect === true)
|
|
772
|
+
return { connections: frontends.all.map(c => ({ ...frontends.info(c), permissions: [...c.session.permissions], pendingRequests: [...c.session.pendingRequests.values()] })), selection: controls?.state.selection, activeSession: controls?.state.activeSession, busy: Boolean(controls?.state.busy || frontends.all.some(c => c.session.descriptor?.turn_state === 'busy' || c.session.pendingRequests.size)), sessions: controls?.state.sessions, permission: currentOptions().permissions, identity: identity() };
|
|
773
|
+
const rows = frontends.all.map((connection) => {
|
|
774
|
+
const info = frontends.info(connection);
|
|
775
|
+
return `${info.label} · ${info.baseUrl} · ${info.model ?? 'unknown model'} · ${info.turnState ?? 'unattached'}`;
|
|
776
|
+
});
|
|
777
|
+
const proposals = [...granted];
|
|
778
|
+
await vscode.window.showInformationMessage(rows.length ? rows.join('\n') : 'Volter Harness is not attached to a runtime.', {
|
|
779
|
+
modal: true,
|
|
780
|
+
detail: proposals.length
|
|
781
|
+
? proposals.map((name) => `${name}: ${PROPOSAL_EFFECTS[name].grants}`).join('\n')
|
|
782
|
+
: REQUESTED_PROPOSALS.map((name) => `${name} not granted: ${PROPOSAL_EFFECTS[name].without}`).join('\n'),
|
|
783
|
+
});
|
|
784
|
+
}));
|
|
785
|
+
async function connectFromSettings() {
|
|
786
|
+
const settings = vscode.workspace.getConfiguration('supercode.frontend');
|
|
787
|
+
const baseUrl = settings.get('baseUrl')?.trim() || context.globalState.get(HANDED_OVER_KEY);
|
|
788
|
+
if (!baseUrl)
|
|
789
|
+
return;
|
|
790
|
+
const token = await context.secrets.get(secretKey(baseUrl));
|
|
791
|
+
if (!token) {
|
|
792
|
+
log.info('Volter Harness: a base URL is configured but no token is stored; run Volter Harness: Set Runtime Token.');
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
await frontends
|
|
796
|
+
.connect({ baseUrl, token, permissions: normalizePermissions(settings.get('permissions'), ['observe']) }, log)
|
|
797
|
+
.catch((error) => {
|
|
798
|
+
log.error(`Volter Harness could not attach to ${baseUrl}: ${String(error?.message ?? error)}`);
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* The handover path: a runtime (or a product that launched this editor's
|
|
803
|
+
* server) exports the endpoint and a scoped credential FILE, mirroring what
|
|
804
|
+
* `crates/cli/src/frontend_client.rs` already does for the pi frontend. No
|
|
805
|
+
* pasted token, no secret in argv or a setting.
|
|
806
|
+
*/
|
|
807
|
+
async function connectFromEnvironment() {
|
|
808
|
+
const env = controls?.state.connection ?? process.env;
|
|
809
|
+
const baseUrl = env[ENV.url]?.trim();
|
|
810
|
+
const credentialFile = env[ENV.credentialFile]?.trim();
|
|
811
|
+
// A host that could not start a runtime says so here, and that sentence is
|
|
812
|
+
// what the person reads instead of an empty chat that looks like a working
|
|
813
|
+
// one.
|
|
814
|
+
const unavailable = process.env[ENV.unavailable]?.trim();
|
|
815
|
+
if (unavailable)
|
|
816
|
+
frontends.setUnavailable(unavailable);
|
|
817
|
+
if (!baseUrl || !credentialFile)
|
|
818
|
+
return false;
|
|
819
|
+
try {
|
|
820
|
+
const token = await readCredentialFile(credentialFile);
|
|
821
|
+
// The file is read, never consumed: an extension host reloads and must be
|
|
822
|
+
// able to reconnect. SecretStorage is a convenience for the settings
|
|
823
|
+
// path, not a durable home in every host — see `credential.ts`.
|
|
824
|
+
await context.secrets.store(secretKey(baseUrl), token);
|
|
825
|
+
await context.globalState.update(HANDED_OVER_KEY, baseUrl);
|
|
826
|
+
await frontends.connect({
|
|
827
|
+
baseUrl,
|
|
828
|
+
token,
|
|
829
|
+
clientId: env[ENV.clientId]?.trim() || undefined,
|
|
830
|
+
connectionId: controls?.state.activeSession ?? undefined,
|
|
831
|
+
permissions: normalizePermissions(env[ENV.permissions], ['observe', 'interact', 'approve']),
|
|
832
|
+
label: identity().name,
|
|
833
|
+
}, log);
|
|
834
|
+
return true;
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
log.error(`Volter Harness could not use ${ENV.credentialFile}: ${String(error?.message ?? error)}`);
|
|
838
|
+
return false;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* A RESUMED runtime arrives with the person's conversation already in it —
|
|
843
|
+
* vgai reopens a project on `claude --resume <id>`. Opening the panel beside
|
|
844
|
+
* that conversation instead of on it is the same defect as not showing it at
|
|
845
|
+
* all: the history exists and nobody sees it. So an attachment that brings
|
|
846
|
+
* history opens its own session, once, on the surface the person is looking
|
|
847
|
+
* at. A runtime with no history is left alone; there is nothing to show and
|
|
848
|
+
* stealing the panel would be rude.
|
|
849
|
+
*/
|
|
850
|
+
async function openResumedSession() {
|
|
851
|
+
if (!granted.has('chatSessionsProvider'))
|
|
852
|
+
return;
|
|
853
|
+
if (controls) {
|
|
854
|
+
try {
|
|
855
|
+
await revealManaged();
|
|
856
|
+
}
|
|
857
|
+
catch (error) {
|
|
858
|
+
log.error(`Could not open the saved conversation: ${String(error)}`);
|
|
859
|
+
}
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
const connection = frontends.all.find((entry) => Boolean(controls) || entry.session.history.length > 0 ||
|
|
863
|
+
entry.session.replay.some((event) => event.kind === 'user_message'));
|
|
864
|
+
if (!connection)
|
|
865
|
+
return;
|
|
866
|
+
try {
|
|
867
|
+
// The editor's own action for this session type. `vscode.openWith` on
|
|
868
|
+
// the session resource opens a LOCAL chat instead — observed — because
|
|
869
|
+
// nothing routes a `supercode:` uri to the session provider's editor.
|
|
870
|
+
await vscode.commands.executeCommand(`workbench.action.chat.openNewChatSessionInPlace.${SESSION_SCHEME}`, 'sidebar');
|
|
871
|
+
log.info(`Volter Harness opened the resumed session ${connection.label} on its history`);
|
|
872
|
+
}
|
|
873
|
+
catch (error) {
|
|
874
|
+
log.warn(`Volter Harness could not open the resumed session: ${String(error?.message ?? error)}`);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
// Precedence: a host's own `connect()` wins, then the runtime's environment
|
|
878
|
+
// handover, then the configured base URL plus its stored secret.
|
|
879
|
+
await vscode.commands.executeCommand('setContext', DEFAULT_PARTICIPANT_CONTEXT_KEY, granted.has('defaultChatParticipant'));
|
|
880
|
+
if (!(await connectFromEnvironment()))
|
|
881
|
+
await connectFromSettings();
|
|
882
|
+
// NOT awaited: this opens an editor, and activation must return before the
|
|
883
|
+
// workbench will run one. Awaiting it here left activation hanging and the
|
|
884
|
+
// panel on "Loading…" — observed.
|
|
885
|
+
const opening = setTimeout(() => void openResumedSession(), 500);
|
|
886
|
+
context.subscriptions.push({ dispose: () => clearTimeout(opening) });
|
|
887
|
+
return {
|
|
888
|
+
connect: async (options) => {
|
|
889
|
+
const connection = await frontends.connect(options, log);
|
|
890
|
+
return connection ? frontends.info(connection) : undefined;
|
|
891
|
+
},
|
|
892
|
+
disconnect: (connectionId) => frontends.disconnect(connectionId),
|
|
893
|
+
get connections() {
|
|
894
|
+
return frontends.all.map((connection) => frontends.info(connection));
|
|
895
|
+
},
|
|
896
|
+
get grantedProposals() {
|
|
897
|
+
return [...granted];
|
|
898
|
+
},
|
|
899
|
+
onDidChangeConnections: frontends.onDidChange,
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
export function deactivate() {
|
|
903
|
+
// Every disposable is owned by the extension context.
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* One rendered part as the markdown a HISTORY turn carries. A replayed turn is
|
|
907
|
+
* built from `ChatResponseMarkdownPart`s only — the live part types belong to a
|
|
908
|
+
* running stream — so a tool row becomes its own one-line summary rather than
|
|
909
|
+
* the JSON of the object that would have drawn it.
|
|
910
|
+
*/
|
|
911
|
+
function historyText(part) {
|
|
912
|
+
switch (part.part) {
|
|
913
|
+
case 'markdown':
|
|
914
|
+
case 'progress':
|
|
915
|
+
return part.value;
|
|
916
|
+
case 'warning':
|
|
917
|
+
return `> ⚠ ${part.value}`;
|
|
918
|
+
case 'thinking':
|
|
919
|
+
return `> ${part.value}`;
|
|
920
|
+
case 'codeblock':
|
|
921
|
+
return part.value;
|
|
922
|
+
case 'reference':
|
|
923
|
+
return `\`${part.path}\``;
|
|
924
|
+
case 'tool': {
|
|
925
|
+
// No `<details>` here: a HISTORY turn's markdown is rendered without raw
|
|
926
|
+
// HTML, so the tags show up as text — observed. A replayed tool is one
|
|
927
|
+
// line plus its output, fenced.
|
|
928
|
+
const summary = part.pastTenseMessage ?? part.invocationMessage ?? part.toolName;
|
|
929
|
+
if (!summary)
|
|
930
|
+
return '';
|
|
931
|
+
const verb = part.isError ? 'Tool failed:' : 'Tool call:';
|
|
932
|
+
const result = part.resultText ? `\n\n${fence(part.resultText)}` : '';
|
|
933
|
+
return `${verb} \`${summary}\`${result}`;
|
|
934
|
+
}
|
|
935
|
+
case 'answers':
|
|
936
|
+
return `**${part.title}**\n\n${part.message}`;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* The editor's session list ALWAYS reads `timing.created` and renders the epoch
|
|
941
|
+
* ("57 yrs ago") when a provider supplies none — measured in
|
|
942
|
+
* `agentSessionsViewer`, which has no path that omits the time. `frontend.v2`
|
|
943
|
+
* carries no session timestamp of its own, so the true times this frontend
|
|
944
|
+
* knows are its own: when this window attached, and the turn boundaries it
|
|
945
|
+
* watched. Those are what the item gets.
|
|
946
|
+
*/
|
|
947
|
+
function applyTiming(item, connection) {
|
|
948
|
+
const created = connection.session.attachedAtMs;
|
|
949
|
+
if (created === null)
|
|
950
|
+
return;
|
|
951
|
+
item.timing = {
|
|
952
|
+
created,
|
|
953
|
+
lastRequestStarted: connection.session.lastTurnStartedMs ?? undefined,
|
|
954
|
+
lastRequestEnded: connection.session.lastTurnEndedMs ?? undefined,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
function registerSessions(context, frontends, participant, granted, handler, controls) {
|
|
958
|
+
const turns = vscode;
|
|
959
|
+
/**
|
|
960
|
+
* The session list is the door that actually SHOWS a reason on screen.
|
|
961
|
+
* `chatViewsWelcome` cannot: `ChatViewPane.shouldShowWelcome()` in
|
|
962
|
+
* `src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts`
|
|
963
|
+
* begins `const shouldShow = !hasCoreAgent && …`, so the whole welcome
|
|
964
|
+
* control is suppressed whenever ANY core agent is registered for the Chat
|
|
965
|
+
* location — which a Code-OSS derivative with an agent host always has. The
|
|
966
|
+
* `when` clause is never even consulted there.
|
|
967
|
+
*/
|
|
968
|
+
const unavailableItem = () => {
|
|
969
|
+
if (!frontends.unavailable || frontends.all.length > 0)
|
|
970
|
+
return undefined;
|
|
971
|
+
const item = controller.createChatSessionItem(unavailableResource(), 'Volter Harness · no runtime');
|
|
972
|
+
item.description = frontends.unavailable.split('\n')[0];
|
|
973
|
+
item.status = vscode.ChatSessionStatus.Failed;
|
|
974
|
+
return item;
|
|
975
|
+
};
|
|
976
|
+
const items = () => {
|
|
977
|
+
const unavailable = unavailableItem();
|
|
978
|
+
if (unavailable)
|
|
979
|
+
return [unavailable];
|
|
980
|
+
return (controls?.catalogue() ?? []).filter(entry => !frontends.get(entry.id)).map(entry => {
|
|
981
|
+
const item = controller.createChatSessionItem(vscode.Uri.from({ scheme: SESSION_SCHEME, authority: entry.id, path: '/conversation' }), entry.title);
|
|
982
|
+
item.description = runtimeIdentity(entry.selection.harness, entry.selection.model).name;
|
|
983
|
+
item.status = vscode.ChatSessionStatus.Completed;
|
|
984
|
+
item.timing = { created: entry.created };
|
|
985
|
+
return item;
|
|
986
|
+
});
|
|
987
|
+
};
|
|
988
|
+
const connectedItem = (connection) => {
|
|
989
|
+
const item = controller.createChatSessionItem(sessionResource(connection), connection.label);
|
|
990
|
+
item.description = connection.label;
|
|
991
|
+
applyTiming(item, connection);
|
|
992
|
+
item.status = vscode.ChatSessionStatus[sessionStatus(connection.session)];
|
|
993
|
+
return item;
|
|
994
|
+
};
|
|
995
|
+
const refreshItems = () => controller.items.replace(items().concat(frontends.all.map(connectedItem)));
|
|
996
|
+
const controller = vscode.chat.createChatSessionItemController(SESSION_SCHEME, async () => { refreshItems(); });
|
|
997
|
+
context.subscriptions.push(controller, frontends.onDidChange(refreshItems));
|
|
998
|
+
if (controls) {
|
|
999
|
+
const states = new Set();
|
|
1000
|
+
controller.getChatSessionInputState = (resource, { previousInputState }) => {
|
|
1001
|
+
const previous = previousInputState?.groups.find(group => group.id === 'permissions')?.selected?.id;
|
|
1002
|
+
// Saved conversation preferences survive extension-host reloads. Native input state
|
|
1003
|
+
// owns live changes; a blank conversation always starts with its own Ask default.
|
|
1004
|
+
const groups = controls.options(resource).map(group => ({ ...group,
|
|
1005
|
+
selected: group.items.find(item => item.id === (resource ? group.selected?.id : previous ?? group.selected?.id)) ?? group.selected,
|
|
1006
|
+
}));
|
|
1007
|
+
const state = controller.createChatSessionInputState(groups);
|
|
1008
|
+
states.add(state);
|
|
1009
|
+
const changed = state.onDidChange(() => {
|
|
1010
|
+
const target = state.sessionResource ?? resource;
|
|
1011
|
+
if (target)
|
|
1012
|
+
controls.setPermission(target, state.groups.find(group => group.id === 'permissions')?.selected?.id);
|
|
1013
|
+
});
|
|
1014
|
+
const disposed = state.onDidDispose(() => { states.delete(state); changed.dispose(); disposed.dispose(); });
|
|
1015
|
+
return state;
|
|
1016
|
+
};
|
|
1017
|
+
controller.newChatSessionItemHandler = async ({ inputState }, token) => {
|
|
1018
|
+
if (token.isCancellationRequested)
|
|
1019
|
+
throw new vscode.CancellationError();
|
|
1020
|
+
return connectedItem(await controls.create(inputState));
|
|
1021
|
+
};
|
|
1022
|
+
context.subscriptions.push(controls.changed(() => {
|
|
1023
|
+
refreshItems();
|
|
1024
|
+
for (const state of states) {
|
|
1025
|
+
if (state.sessionResource)
|
|
1026
|
+
state.groups = controls.options(state.sessionResource);
|
|
1027
|
+
}
|
|
1028
|
+
}));
|
|
1029
|
+
}
|
|
1030
|
+
context.subscriptions.push(vscode.chat.registerChatSessionContentProvider(SESSION_SCHEME, {
|
|
1031
|
+
provideChatSessionContent: async (resource, _token, { inputState }) => {
|
|
1032
|
+
const boundHandler = (request, chatContext, stream, token) => handler(request, { ...chatContext, chatSessionContext: { chatSessionItem: { resource, label: 'Conversation' } } }, stream, token);
|
|
1033
|
+
if (resource.path === UNAVAILABLE_PATH) {
|
|
1034
|
+
// As HISTORY, not `activeResponseCallback`: the editor only invokes
|
|
1035
|
+
// that callback for a session it believes is mid-response, and this
|
|
1036
|
+
// one is not running anything. A history turn renders the moment
|
|
1037
|
+
// the session opens, with no request behind it and nothing to wait
|
|
1038
|
+
// for — which is the whole point, since the person has not typed.
|
|
1039
|
+
return {
|
|
1040
|
+
title: 'Volter Harness · no runtime',
|
|
1041
|
+
history: [
|
|
1042
|
+
new turns.ChatRequestTurn(STATUS_MARKER_PROMPT, undefined, [], participant.id, []),
|
|
1043
|
+
new turns.ChatResponseTurn([new vscode.ChatResponseMarkdownPart(unavailableMarkdown(frontends.unavailable))], {}, participant.id),
|
|
1044
|
+
],
|
|
1045
|
+
requestHandler: boundHandler,
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
// Only a native untitled resource can create a conversation. Existing
|
|
1049
|
+
// resources must resolve their exact saved binding; there is no fallback.
|
|
1050
|
+
const connection = controls ? (resource.path.startsWith('/untitled-') && !controls.bindings(resource) ? undefined : await controls.resolve(resource)) : frontends.get(resource.authority);
|
|
1051
|
+
if (!connection) {
|
|
1052
|
+
return { history: [], requestHandler: boundHandler };
|
|
1053
|
+
}
|
|
1054
|
+
if (controls) {
|
|
1055
|
+
controls.setPermission(resource, inputState.groups.find(group => group.id === 'permissions')?.selected?.id);
|
|
1056
|
+
inputState.groups = controls.options(resource);
|
|
1057
|
+
}
|
|
1058
|
+
// Re-take the attachment snapshot: the runtime republishes its
|
|
1059
|
+
// canonical history at every turn boundary (`submit_claimed` writes
|
|
1060
|
+
// `state.history`), so the copy taken when this window attached is
|
|
1061
|
+
// stale as soon as a turn runs. The generated client reuses the
|
|
1062
|
+
// already-open event stream, so this is a snapshot, not a reconnect.
|
|
1063
|
+
await connection.session.attach().catch(() => {
|
|
1064
|
+
// An unreachable runtime replays what this window last saw.
|
|
1065
|
+
});
|
|
1066
|
+
// The attach snapshot IS the session's history: the runtime's own
|
|
1067
|
+
// canonical transcript, replayed as turns.
|
|
1068
|
+
const history = [];
|
|
1069
|
+
// A hosted harness runtime has no canonical ChatMessage history —
|
|
1070
|
+
// `HostedHarnessRuntime::attach` returns an empty one and puts the
|
|
1071
|
+
// conversation in `replay`. Read whichever the runtime actually
|
|
1072
|
+
// filled, or a resumed session opens blank beside the person's own
|
|
1073
|
+
// conversation.
|
|
1074
|
+
const canonicalHistory = connection.persistedHistory?.length ? connection.persistedHistory : connection.session.history;
|
|
1075
|
+
const pendingToolNames = new Set();
|
|
1076
|
+
if (connection.session.descriptor?.turn_state === 'busy') {
|
|
1077
|
+
for (const request of connection.session.pendingRequests.values()) {
|
|
1078
|
+
const payload = request.payload;
|
|
1079
|
+
if (request.kind !== 'approval' || !payload || typeof payload !== 'object' || Array.isArray(payload))
|
|
1080
|
+
continue;
|
|
1081
|
+
const name = payload.tool ?? payload.tool_name;
|
|
1082
|
+
if (typeof name === 'string')
|
|
1083
|
+
pendingToolNames.add(name);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
const projected = canonicalHistory.length > 0
|
|
1087
|
+
? projectHistory(canonicalHistory, granted, pendingToolNames)
|
|
1088
|
+
: projectReplayTurns(connection.session.replay, granted, {
|
|
1089
|
+
connectionId: connection.id,
|
|
1090
|
+
pendingRequest: (id) => connection.session.pendingRequests.get(id) ?? connection.session.resolvedRequests.get(id),
|
|
1091
|
+
canRespond: connection.session.descriptor?.actions.respond !== false,
|
|
1092
|
+
});
|
|
1093
|
+
if (connection.historyTruncated)
|
|
1094
|
+
history.push(new turns.ChatRequestTurn('[Earlier conversation history]', undefined, [], participant.id, []), new turns.ChatResponseTurn([new vscode.ChatResponseMarkdownPart('_Showing the latest 120 saved messages. Earlier context remains in the harness session._')], {}, participant.id));
|
|
1095
|
+
for (const turn of projected) {
|
|
1096
|
+
if (turn.role === 'user') {
|
|
1097
|
+
history.push(new turns.ChatRequestTurn(turn.prompt ?? '', undefined, [], participant.id, []));
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
const markdown = (turn.parts ?? [])
|
|
1101
|
+
.map(historyText)
|
|
1102
|
+
.filter(Boolean)
|
|
1103
|
+
.join('\n\n');
|
|
1104
|
+
history.push(new turns.ChatResponseTurn([new vscode.ChatResponseMarkdownPart(markdown)], {}, participant.id));
|
|
1105
|
+
}
|
|
1106
|
+
const busy = connection.session.descriptor?.turn_state === 'busy';
|
|
1107
|
+
return {
|
|
1108
|
+
title: connection.label,
|
|
1109
|
+
options: controls?.currentOptions(resource),
|
|
1110
|
+
history,
|
|
1111
|
+
requestHandler: boundHandler,
|
|
1112
|
+
activeResponseCallback: busy
|
|
1113
|
+
? async (stream, token) => {
|
|
1114
|
+
const state = { toolInputs: new Map(), toolNames: new Map() };
|
|
1115
|
+
writeProjected(stream, projectPendingRequests(connection.session.pendingRequests.values(), {
|
|
1116
|
+
connectionId: connection.id,
|
|
1117
|
+
canRespond: connection.session.descriptor?.actions.respond !== false,
|
|
1118
|
+
}), granted, state);
|
|
1119
|
+
await observeActiveResponse(connection.session, token, (event) => {
|
|
1120
|
+
writeProjected(stream, projectEvent(event, {
|
|
1121
|
+
connectionId: connection.id,
|
|
1122
|
+
pendingRequest: (id) => connection.session.pendingRequests.get(id) ?? connection.session.resolvedRequests.get(id),
|
|
1123
|
+
canRespond: connection.session.descriptor?.actions.respond !== false,
|
|
1124
|
+
}), granted, state);
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
: undefined,
|
|
1128
|
+
};
|
|
1129
|
+
},
|
|
1130
|
+
}, participant, { supportsInterruptions: true }));
|
|
1131
|
+
}
|