@superblocksteam/gateway 2.0.166 → 2.0.168-next.0
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/README.md +163 -28
- package/dist/agents/resolve-orchestrator-url.js +1 -2
- package/dist/agents/resolve-orchestrator-url.js.map +1 -1
- package/dist/capabilities/lifecycle.d.ts +3 -0
- package/dist/capabilities/lifecycle.js +330 -128
- package/dist/capabilities/lifecycle.js.map +1 -1
- package/dist/capabilities/live-state-attaches.d.ts +26 -0
- package/dist/capabilities/live-state-attaches.js +41 -0
- package/dist/capabilities/live-state-attaches.js.map +1 -0
- package/dist/capabilities/tool-permission-reply.d.ts +28 -0
- package/dist/capabilities/tool-permission-reply.js +55 -0
- package/dist/capabilities/tool-permission-reply.js.map +1 -0
- package/dist/capabilities/types.d.ts +19 -1
- package/dist/capabilities/types.js.map +1 -1
- package/dist/deadline.d.ts +6 -0
- package/dist/deadline.js +23 -0
- package/dist/deadline.js.map +1 -0
- package/dist/deps.d.ts +7 -0
- package/dist/health/readiness.js +1 -17
- package/dist/health/readiness.js.map +1 -1
- package/dist/integrations/filters.js +2 -2
- package/dist/integrations/filters.js.map +1 -1
- package/dist/orchestrator/client.d.ts +2 -1
- package/dist/orchestrator/client.js +4 -2
- package/dist/orchestrator/client.js.map +1 -1
- package/dist/sabs/app-state.d.ts +1 -0
- package/dist/sabs/app-state.js +9 -0
- package/dist/sabs/app-state.js.map +1 -1
- package/dist/sabs/awaited-decision.d.ts +24 -1
- package/dist/sabs/awaited-decision.js +40 -9
- package/dist/sabs/awaited-decision.js.map +1 -1
- package/dist/sabs/editor-client-methods.d.ts +21 -10
- package/dist/sabs/editor-client-methods.js +48 -24
- package/dist/sabs/editor-client-methods.js.map +1 -1
- package/dist/sabs/session-peer.d.ts +47 -14
- package/dist/sabs/session-peer.js.map +1 -1
- package/dist/sabs/turn-collector.d.ts +27 -1
- package/dist/sabs/turn-collector.js +38 -2
- package/dist/sabs/turn-collector.js.map +1 -1
- package/dist/sabs/websocket-session-peer.d.ts +27 -3
- package/dist/sabs/websocket-session-peer.js +466 -66
- package/dist/sabs/websocket-session-peer.js.map +1 -1
- package/dist/server/client.d.ts +3 -0
- package/dist/server/client.js +7 -3
- package/dist/server/client.js.map +1 -1
- package/dist/start.d.ts +13 -2
- package/dist/start.js +112 -12
- package/dist/start.js.map +1 -1
- package/dist/telemetry/metrics.js +8 -21
- package/dist/telemetry/metrics.js.map +1 -1
- package/dist/telemetry/runtime.d.ts +5 -0
- package/dist/telemetry/runtime.js +22 -8
- package/dist/telemetry/runtime.js.map +1 -1
- package/dist/transports/mcp/admin-tools.d.ts +2 -1
- package/dist/transports/mcp/admin-tools.js +19 -2
- package/dist/transports/mcp/admin-tools.js.map +1 -1
- package/dist/transports/mcp/client-presentation.d.ts +9 -1
- package/dist/transports/mcp/client-presentation.js +10 -0
- package/dist/transports/mcp/client-presentation.js.map +1 -1
- package/dist/transports/mcp/decision-card-html.d.ts +4 -2
- package/dist/transports/mcp/decision-card-html.js +107 -25
- package/dist/transports/mcp/decision-card-html.js.map +1 -1
- package/dist/transports/mcp/decision-elicitation.d.ts +3 -2
- package/dist/transports/mcp/decision-elicitation.js +67 -2
- package/dist/transports/mcp/decision-elicitation.js.map +1 -1
- package/dist/transports/mcp/format-tool-content.d.ts +7 -0
- package/dist/transports/mcp/format-tool-content.js +3 -3
- package/dist/transports/mcp/format-tool-content.js.map +1 -1
- package/dist/transports/mcp/instructions/result.js +1 -0
- package/dist/transports/mcp/instructions/result.js.map +1 -1
- package/dist/transports/mcp/instructions/tools/ask-user.d.ts +2 -1
- package/dist/transports/mcp/instructions/tools/ask-user.js +9 -2
- package/dist/transports/mcp/instructions/tools/ask-user.js.map +1 -1
- package/dist/transports/mcp/instructions/tools/copy.d.ts +5 -2
- package/dist/transports/mcp/instructions/tools/copy.js +20 -5
- package/dist/transports/mcp/instructions/tools/copy.js.map +1 -1
- package/dist/transports/mcp/instructions/tools/edit-app.d.ts +1 -1
- package/dist/transports/mcp/instructions/tools/edit-app.js +11 -1
- package/dist/transports/mcp/instructions/tools/edit-app.js.map +1 -1
- package/dist/transports/mcp/mount.d.ts +2 -3
- package/dist/transports/mcp/mount.js +395 -112
- package/dist/transports/mcp/mount.js.map +1 -1
- package/dist/transports/mcp/plan-approval.d.ts +23 -13
- package/dist/transports/mcp/plan-approval.js +39 -26
- package/dist/transports/mcp/plan-approval.js.map +1 -1
- package/package.json +5 -5
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { WebSocket } from "ws";
|
|
3
|
+
import { AiMode as SharedAiMode, } from "@superblocksteam/library-shared/types";
|
|
3
4
|
import { createISocketClient, ISocket, SocketHeartbeat, } from "@superblocksteam/shared";
|
|
4
5
|
import { canonicalizeOrchestratorBaseUrl, orchestratorUrlDependsOnProfile, pickOrchestratorBaseUrl, } from "../agents/resolve-orchestrator-url.js";
|
|
5
6
|
import { gatewayDebug } from "../debug.js";
|
|
@@ -10,7 +11,7 @@ import { recordCheckpointOutcome, recordIntegrationProfileResolution, } from "..
|
|
|
10
11
|
import { sanitizeAgentFacingText } from "./agent-facing-text.js";
|
|
11
12
|
import { AppState } from "./app-state.js";
|
|
12
13
|
import { buildBrowserContext } from "./browser-context.js";
|
|
13
|
-
import { createEditorClientMethods, loadIntegrationsForPrompt, } from "./editor-client-methods.js";
|
|
14
|
+
import { createEditorClientMethods, loadIntegrationsForPrompt, ToolPermissionListenerUnavailableError, } from "./editor-client-methods.js";
|
|
14
15
|
import { EditorSocket } from "./editor-socket.js";
|
|
15
16
|
import { appendRecentToolRun, buildRecentToolRun } from "./recent-tool-runs.js";
|
|
16
17
|
import { PromptNotSentError, } from "./session-peer.js";
|
|
@@ -185,6 +186,12 @@ const DEFAULT_RECONNECT_GRACE_MS = 20_000;
|
|
|
185
186
|
* on, so reconnect attempts are capped independently.
|
|
186
187
|
*/
|
|
187
188
|
const DEFAULT_RECONNECT_ATTEMPTS = 3;
|
|
189
|
+
/**
|
|
190
|
+
* How many times a takeover rereads a claim snapshot that browser events keep
|
|
191
|
+
* overtaking. Every read it made was answered, so running out keeps the claim
|
|
192
|
+
* instead of failing a working socket as unreachable.
|
|
193
|
+
*/
|
|
194
|
+
const MAX_CLAIM_REFRESHES = 3;
|
|
188
195
|
/**
|
|
189
196
|
* After the job settles, how long to wait for the dev server to finish flushing
|
|
190
197
|
* Clark's files before giving up on checkpointing them.
|
|
@@ -226,6 +233,106 @@ function isBrowserObserverEvent(event) {
|
|
|
226
233
|
(event.kind === "generation_state" &&
|
|
227
234
|
event.gatewayObserverSource === "browser"));
|
|
228
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* Names why a New Chat reset must be ignored, or returns undefined when it may
|
|
238
|
+
* clear chat state. A reset for another generation, or one that cannot be
|
|
239
|
+
* matched to a turn still running, would otherwise end a turn it was never
|
|
240
|
+
* about.
|
|
241
|
+
*/
|
|
242
|
+
function ignoredChatResetReason(held, reset, previous) {
|
|
243
|
+
if (held.pendingTurn) {
|
|
244
|
+
return "pending_turn";
|
|
245
|
+
}
|
|
246
|
+
if (previous &&
|
|
247
|
+
(!reset.generationId || !previous.generationId) &&
|
|
248
|
+
!previous.finished &&
|
|
249
|
+
!previous.collector.isSettling()) {
|
|
250
|
+
return "unkeyed_active_turn";
|
|
251
|
+
}
|
|
252
|
+
if (reset.generationId &&
|
|
253
|
+
previous?.generationId &&
|
|
254
|
+
reset.generationId !== previous.generationId) {
|
|
255
|
+
return "other_generation";
|
|
256
|
+
}
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Outcomes that leave the turn with the editor: one waiting on the user, and
|
|
261
|
+
* one that may still be running where only the editor can see it. An idle
|
|
262
|
+
* editor refutes both.
|
|
263
|
+
*/
|
|
264
|
+
function leavesTurnWithEditor(outcome) {
|
|
265
|
+
return outcome === "detached" || outcome === "needs_decision";
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Whether a snapshot shows nothing newer than the owned turn's outcome: an idle
|
|
269
|
+
* editor, the owned generation completing as the turn already reported, or
|
|
270
|
+
* that generation still waiting on the tool permission the turn asked for. A
|
|
271
|
+
* permission reaches the gateway over its own RPC and never appears as the
|
|
272
|
+
* snapshot's interactive message, so the editor waiting with none is that
|
|
273
|
+
* permission still pending.
|
|
274
|
+
*/
|
|
275
|
+
function agreesWithOwnedOutcome({ generationId, state }, { hasActiveInteractiveMessage, owned, }) {
|
|
276
|
+
const outcome = owned?.finished;
|
|
277
|
+
if (!outcome) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
if (state === "idle") {
|
|
281
|
+
return !leavesTurnWithEditor(outcome.generationStatus);
|
|
282
|
+
}
|
|
283
|
+
if (generationId !== owned.generationId) {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
if (state === "completed") {
|
|
287
|
+
return (outcome.generationStatus === "completed" ||
|
|
288
|
+
outcome.generationStatus === "no_changes");
|
|
289
|
+
}
|
|
290
|
+
return (state === "awaiting_user_input" &&
|
|
291
|
+
!hasActiveInteractiveMessage &&
|
|
292
|
+
outcome.decision?.kind === "tool_permission");
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Whether a snapshot read while taking a turn over is that turn's own state,
|
|
296
|
+
* to replay onto it. One naming the turn's generation is, unless it awaits a
|
|
297
|
+
* decision it does not carry. So is an idle one naming no generation: that is
|
|
298
|
+
* what the editor shows once nothing runs and no terminal state is left to
|
|
299
|
+
* show, and the turn's collector still ignores an idle before the turn was
|
|
300
|
+
* seen generating.
|
|
301
|
+
*/
|
|
302
|
+
function replaysOntoTurn(generationState, known) {
|
|
303
|
+
if (!generationState.generationId) {
|
|
304
|
+
return generationState.state === "idle";
|
|
305
|
+
}
|
|
306
|
+
return (generationState.generationId === known.turn.generationId &&
|
|
307
|
+
(generationState.state !== "awaiting_user_input" ||
|
|
308
|
+
known.hasActiveInteractiveMessage ||
|
|
309
|
+
known.turn.collector.decision() !== undefined));
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* What a chat snapshot read on attach says about the turn this peer knew of.
|
|
313
|
+
*
|
|
314
|
+
* `unchanged` when it shows nothing newer than a finished owned turn (see
|
|
315
|
+
* {@link agreesWithOwnedOutcome}). Clearing then would erase the turn's
|
|
316
|
+
* outcome, checkpoint, retry advice or pending permission while the editor
|
|
317
|
+
* agrees with them. `settled` when nothing is running or waiting on the user.
|
|
318
|
+
* `observable` when there is a generation to follow: one running, one waiting
|
|
319
|
+
* on an answer, or the watched generation completing.
|
|
320
|
+
*/
|
|
321
|
+
function readChatSnapshot(generationState, known) {
|
|
322
|
+
if (agreesWithOwnedOutcome(generationState, known)) {
|
|
323
|
+
return "unchanged";
|
|
324
|
+
}
|
|
325
|
+
const { generationId, state } = generationState;
|
|
326
|
+
const { owned } = known;
|
|
327
|
+
const watchedGenerationId = known.observedGenerationId ?? owned?.generationId;
|
|
328
|
+
if (state === "idle" ||
|
|
329
|
+
(state === "completed" &&
|
|
330
|
+
(!watchedGenerationId || watchedGenerationId !== generationId)) ||
|
|
331
|
+
(state === "awaiting_user_input" && !known.hasActiveInteractiveMessage)) {
|
|
332
|
+
return "settled";
|
|
333
|
+
}
|
|
334
|
+
return "observable";
|
|
335
|
+
}
|
|
229
336
|
export function buildSessionUrl(input) {
|
|
230
337
|
// liveEditUrl is server-sourced (connectLiveEdit), not caller-supplied, so a
|
|
231
338
|
// host allowlist would mostly duplicate the Superblocks API's own trust
|
|
@@ -505,6 +612,11 @@ async function attempt(url, timeoutMs, onSocketError) {
|
|
|
505
612
|
// emitter 'error' rather than reporting it to the caller. Recording it
|
|
506
613
|
// rather than discarding it is what makes a later 1006 explainable.
|
|
507
614
|
socket.on("error", onSocketError);
|
|
615
|
+
// The dev server flushes queued pushes the moment a peer binds, and
|
|
616
|
+
// those frames can share a read with the upgrade response. Without the
|
|
617
|
+
// pause they are emitted before ISocket is listening and lost. The
|
|
618
|
+
// caller resumes once its handlers are attached.
|
|
619
|
+
socket.pause();
|
|
508
620
|
resolve(socket);
|
|
509
621
|
});
|
|
510
622
|
const onError = (error) => settle(() => reject(error));
|
|
@@ -811,9 +923,12 @@ export class WebSocketSessionPeer {
|
|
|
811
923
|
...(failed.recoveryAction
|
|
812
924
|
? { recoveryAction: failed.recoveryAction }
|
|
813
925
|
: {}),
|
|
814
|
-
...(failed.retryable === false
|
|
926
|
+
...(failed.retryable === false || !failed.retryAction
|
|
927
|
+
? { reply: failed.reply }
|
|
928
|
+
: {}),
|
|
815
929
|
...(failed.retryAction ? { retryAction: failed.retryAction } : {}),
|
|
816
930
|
...(failed.retryable === false ? { retryable: false } : {}),
|
|
931
|
+
startFailed: true,
|
|
817
932
|
};
|
|
818
933
|
}
|
|
819
934
|
if (turn && !turn.finished) {
|
|
@@ -837,7 +952,11 @@ export class WebSocketSessionPeer {
|
|
|
837
952
|
? { checkpointWarning: turn.finished.checkpointWarning }
|
|
838
953
|
: {}),
|
|
839
954
|
generationStatus: turn.finished.generationStatus,
|
|
955
|
+
...(turn.mode ? { mode: turn.mode } : {}),
|
|
840
956
|
phase: "finished",
|
|
957
|
+
...(turn.finished.answered
|
|
958
|
+
? { answered: true, reply: turn.finished.reply }
|
|
959
|
+
: {}),
|
|
841
960
|
...(turn.retryAction ? { retryAction: turn.retryAction } : {}),
|
|
842
961
|
...(turn.finished.retryable === false
|
|
843
962
|
? { retryable: false }
|
|
@@ -860,7 +979,7 @@ export class WebSocketSessionPeer {
|
|
|
860
979
|
? { ...(entry.branchName ? { branch: entry.branchName } : {}) }
|
|
861
980
|
: undefined;
|
|
862
981
|
}
|
|
863
|
-
libraryBrowserContext(applicationId, requestedBranch) {
|
|
982
|
+
async libraryBrowserContext(applicationId, requestedBranch) {
|
|
864
983
|
const held = this.held.get(applicationId);
|
|
865
984
|
if (!held || requestedBranch !== held.branchName) {
|
|
866
985
|
return undefined;
|
|
@@ -894,8 +1013,100 @@ export class WebSocketSessionPeer {
|
|
|
894
1013
|
held.organizationId = input.organizationId;
|
|
895
1014
|
}
|
|
896
1015
|
held.sessionToken = input.sessionToken;
|
|
897
|
-
|
|
898
|
-
|
|
1016
|
+
try {
|
|
1017
|
+
await this.synchronizeChatState(held, input.sessionToken);
|
|
1018
|
+
await this.resolveHeldProfileKey(held);
|
|
1019
|
+
// TODO(AR-592): a discovery failure here fails the read even though the
|
|
1020
|
+
// chat state above was just synced.
|
|
1021
|
+
await this.resolveHeldOrchestrator(held);
|
|
1022
|
+
}
|
|
1023
|
+
finally {
|
|
1024
|
+
this.armReadRelease(held);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Bounds a socket held only to read the editor state. Nothing runs on it,
|
|
1029
|
+
* so it gets a finished turn's grace instead of keeping the sandbox awake
|
|
1030
|
+
* and contesting the editor's binding for the life of the process. A turn
|
|
1031
|
+
* seen on it clears this timer and arms its own.
|
|
1032
|
+
*/
|
|
1033
|
+
armReadRelease(held) {
|
|
1034
|
+
const observed = this.observedTurns.get(held.applicationId);
|
|
1035
|
+
if (this.held.get(held.applicationId) !== held ||
|
|
1036
|
+
held.idleTimer ||
|
|
1037
|
+
(observed?.held === held && !observed.finished)) {
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
held.idleTimer = setTimeout(() => {
|
|
1041
|
+
if (this.held.get(held.applicationId) === held) {
|
|
1042
|
+
this.drop(held, { preserveFinishedObserved: true });
|
|
1043
|
+
}
|
|
1044
|
+
}, this.options.releaseAfterReadMs ?? DEFAULT_RELEASE_AFTER_TURN_MS);
|
|
1045
|
+
held.idleTimer.unref?.();
|
|
1046
|
+
}
|
|
1047
|
+
async synchronizeChatState(held, sessionToken, retryDecision = true) {
|
|
1048
|
+
const revision = held.chatStateRevision;
|
|
1049
|
+
const owned = this.turns.get(held.applicationId);
|
|
1050
|
+
// Read before the snapshot call: ordinary browser text on the new socket
|
|
1051
|
+
// does not bump the revision, yet it replaces the watched turn with one
|
|
1052
|
+
// that has no generation, which would hide a matching completion.
|
|
1053
|
+
const observedGenerationId = this.observedTurns.get(held.applicationId)?.generationId;
|
|
1054
|
+
const isCurrent = () => this.held.get(held.applicationId) === held &&
|
|
1055
|
+
isSocketOpen(held.ws) &&
|
|
1056
|
+
!held.pendingTurn &&
|
|
1057
|
+
this.turns.get(held.applicationId) === owned;
|
|
1058
|
+
const snapshot = await editorSocket(held, sessionToken)
|
|
1059
|
+
.aiChatGetMessages({ force: false })
|
|
1060
|
+
.catch((error) => {
|
|
1061
|
+
const dropped = isCurrent() && held.chatStateRevision === revision;
|
|
1062
|
+
console.warn("gateway chat state sync failed", {
|
|
1063
|
+
applicationId: held.applicationId,
|
|
1064
|
+
dropped,
|
|
1065
|
+
message: describeSocketError(error),
|
|
1066
|
+
});
|
|
1067
|
+
if (dropped) {
|
|
1068
|
+
this.drop(held);
|
|
1069
|
+
}
|
|
1070
|
+
throw error;
|
|
1071
|
+
});
|
|
1072
|
+
if (!snapshot?.generationState || !isCurrent()) {
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
if (held.chatStateRevision !== revision) {
|
|
1076
|
+
const observed = this.observedTurns.get(held.applicationId);
|
|
1077
|
+
if (retryDecision &&
|
|
1078
|
+
observed?.finished?.generationStatus === "needs_decision") {
|
|
1079
|
+
await this.synchronizeChatState(held, sessionToken, false);
|
|
1080
|
+
}
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
const reading = readChatSnapshot(snapshot.generationState, {
|
|
1084
|
+
hasActiveInteractiveMessage: Boolean(snapshot.activeInteractiveMessage),
|
|
1085
|
+
observedGenerationId,
|
|
1086
|
+
owned,
|
|
1087
|
+
});
|
|
1088
|
+
if (reading === "unchanged") {
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
this.clearChatSessionState(held.applicationId);
|
|
1092
|
+
if (reading === "settled") {
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
if (snapshot.generationState.state === "awaiting_user_input" &&
|
|
1096
|
+
snapshot.activeInteractiveMessage) {
|
|
1097
|
+
this.observeEditorEvent(held, {
|
|
1098
|
+
kind: "message",
|
|
1099
|
+
message: {
|
|
1100
|
+
...snapshot.activeInteractiveMessage,
|
|
1101
|
+
gatewayObserverSource: "browser",
|
|
1102
|
+
},
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
this.observeEditorEvent(held, {
|
|
1106
|
+
...snapshot.generationState,
|
|
1107
|
+
gatewayObserverSource: "browser",
|
|
1108
|
+
kind: "generation_state",
|
|
1109
|
+
});
|
|
899
1110
|
}
|
|
900
1111
|
release(applicationId) {
|
|
901
1112
|
const entry = this.held.get(applicationId);
|
|
@@ -904,14 +1115,18 @@ export class WebSocketSessionPeer {
|
|
|
904
1115
|
}
|
|
905
1116
|
const turn = this.turns.get(applicationId);
|
|
906
1117
|
if (turn?.held === entry && !turn.finished) {
|
|
907
|
-
this.cancelTurn(turn,
|
|
1118
|
+
this.cancelTurn(turn, {
|
|
1119
|
+
reply: "This turn was cancelled because its live-edit session was released.",
|
|
1120
|
+
});
|
|
908
1121
|
}
|
|
909
1122
|
this.drop(entry);
|
|
910
1123
|
}
|
|
911
1124
|
closeAll() {
|
|
912
1125
|
for (const turn of this.turns.values()) {
|
|
913
1126
|
if (!turn.finished) {
|
|
914
|
-
this.cancelTurn(turn,
|
|
1127
|
+
this.cancelTurn(turn, {
|
|
1128
|
+
reply: "This turn was cancelled because the gateway shut down.",
|
|
1129
|
+
});
|
|
915
1130
|
}
|
|
916
1131
|
}
|
|
917
1132
|
for (const applicationId of [...this.observedTurns.keys()]) {
|
|
@@ -921,6 +1136,14 @@ export class WebSocketSessionPeer {
|
|
|
921
1136
|
this.release(applicationId);
|
|
922
1137
|
}
|
|
923
1138
|
}
|
|
1139
|
+
resetForLogin() {
|
|
1140
|
+
this.closeAll();
|
|
1141
|
+
this.promptStarts.clear();
|
|
1142
|
+
this.connecting.clear();
|
|
1143
|
+
this.turns.clear();
|
|
1144
|
+
this.editorStructureByScope.clear();
|
|
1145
|
+
this.inboundMethods.clear();
|
|
1146
|
+
}
|
|
924
1147
|
/**
|
|
925
1148
|
* Sends the prompt and waits for Clark's whole turn.
|
|
926
1149
|
*
|
|
@@ -947,7 +1170,7 @@ export class WebSocketSessionPeer {
|
|
|
947
1170
|
throw new Error("Superblocks turn wait aborted");
|
|
948
1171
|
}
|
|
949
1172
|
if (update.status === "finished") {
|
|
950
|
-
const { activity: _activity, elapsedMs: _elapsedMs, status: _status, ...finished } = update;
|
|
1173
|
+
const { activity: _activity, elapsedMs: _elapsedMs, mode: _mode, status: _status, ...finished } = update;
|
|
951
1174
|
return {
|
|
952
1175
|
...finished,
|
|
953
1176
|
// See startPrompt, which resolved this: the turn may have run on a
|
|
@@ -1081,6 +1304,7 @@ export class WebSocketSessionPeer {
|
|
|
1081
1304
|
}, input.idempotencyKey, input.pendingStartToken))) {
|
|
1082
1305
|
throw new PromptNotSentError("A newer start attempt replaced this one before its prompt was sent");
|
|
1083
1306
|
}
|
|
1307
|
+
const mode = this.turnMode(input);
|
|
1084
1308
|
const turn = {
|
|
1085
1309
|
abort: new AbortController(),
|
|
1086
1310
|
activity: [],
|
|
@@ -1093,6 +1317,7 @@ export class WebSocketSessionPeer {
|
|
|
1093
1317
|
? {}
|
|
1094
1318
|
: { idempotencyKey: input.idempotencyKey }),
|
|
1095
1319
|
listener: () => undefined,
|
|
1320
|
+
...(mode ? { mode } : {}),
|
|
1096
1321
|
...(input.pendingStartToken === undefined
|
|
1097
1322
|
? {}
|
|
1098
1323
|
: { pendingStartToken: input.pendingStartToken }),
|
|
@@ -1193,7 +1418,7 @@ export class WebSocketSessionPeer {
|
|
|
1193
1418
|
async sendPrompt(args) {
|
|
1194
1419
|
const { held, input, integrations, provisional, turn } = args;
|
|
1195
1420
|
const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1196
|
-
const browserContext = this.browserContextFor(held);
|
|
1421
|
+
const browserContext = await this.browserContextFor(held);
|
|
1197
1422
|
let acknowledgment;
|
|
1198
1423
|
try {
|
|
1199
1424
|
// The editor and the smoke client both send the credential twice: once
|
|
@@ -1223,7 +1448,11 @@ export class WebSocketSessionPeer {
|
|
|
1223
1448
|
thinkingEnabled: false,
|
|
1224
1449
|
unifiedProviderEnabled: true,
|
|
1225
1450
|
},
|
|
1226
|
-
|
|
1451
|
+
...(input.responseMetadata?.type === "tool_permission_response"
|
|
1452
|
+
? {}
|
|
1453
|
+
: {
|
|
1454
|
+
mode: input.mode === "BUILD" ? SharedAiMode.BUILD : SharedAiMode.PLAN,
|
|
1455
|
+
}),
|
|
1227
1456
|
prompt: input.prompt,
|
|
1228
1457
|
promptContext: {
|
|
1229
1458
|
attachments: input.attachments ?? [],
|
|
@@ -1303,6 +1532,7 @@ export class WebSocketSessionPeer {
|
|
|
1303
1532
|
: new Error(`Prompt forward failed: ${rejection.message ?? "unknown socket error"}`);
|
|
1304
1533
|
}
|
|
1305
1534
|
turn.revision = this.appState.markTurnStarted();
|
|
1535
|
+
turn.generationId = acknowledgment?.generationId;
|
|
1306
1536
|
args.acceptBufferedEvents(acknowledgment?.generationId);
|
|
1307
1537
|
input.onProgress?.("Superblocks accepted the prompt");
|
|
1308
1538
|
// Only now, with the prompt accepted and a live turn about to go in the
|
|
@@ -1350,9 +1580,32 @@ export class WebSocketSessionPeer {
|
|
|
1350
1580
|
status: "accepted",
|
|
1351
1581
|
};
|
|
1352
1582
|
}
|
|
1583
|
+
/**
|
|
1584
|
+
* The mode a new turn runs in. A reply to a tool permission carries no mode
|
|
1585
|
+
* to Superblocks, which carries on in the mode of the turn that asked, so the
|
|
1586
|
+
* reply takes that turn's mode here. When this peer did not run the turn that
|
|
1587
|
+
* asked, the mode is unknown rather than guessed.
|
|
1588
|
+
*/
|
|
1589
|
+
turnMode(input) {
|
|
1590
|
+
const response = input.responseMetadata;
|
|
1591
|
+
if (response?.type !== "tool_permission_response") {
|
|
1592
|
+
return input.mode ?? "PLAN";
|
|
1593
|
+
}
|
|
1594
|
+
const asking = this.turns.get(input.applicationId);
|
|
1595
|
+
const decision = asking?.finished?.decision;
|
|
1596
|
+
return decision?.kind === "tool_permission" &&
|
|
1597
|
+
decision.messageId === response.responseToMessageId
|
|
1598
|
+
? asking?.mode
|
|
1599
|
+
: undefined;
|
|
1600
|
+
}
|
|
1353
1601
|
latestTurn(applicationId) {
|
|
1354
1602
|
return (this.observedTurns.get(applicationId) ?? this.turns.get(applicationId));
|
|
1355
1603
|
}
|
|
1604
|
+
clearChatSessionState(applicationId) {
|
|
1605
|
+
this.clearPendingDecision(applicationId);
|
|
1606
|
+
this.forgetObservedTurn(applicationId);
|
|
1607
|
+
this.supersedeTurn(applicationId);
|
|
1608
|
+
}
|
|
1356
1609
|
observeEditorEvent(held, event) {
|
|
1357
1610
|
if (held.dropping || this.held.get(held.applicationId) !== held) {
|
|
1358
1611
|
return;
|
|
@@ -1376,6 +1629,63 @@ export class WebSocketSessionPeer {
|
|
|
1376
1629
|
this.clearPendingDecision(held.applicationId);
|
|
1377
1630
|
}
|
|
1378
1631
|
const owned = this.turns.get(held.applicationId);
|
|
1632
|
+
const previous = observed ?? owned;
|
|
1633
|
+
if (event.kind === "generation_state" &&
|
|
1634
|
+
event.state === "idle" &&
|
|
1635
|
+
event.chatSessionReset) {
|
|
1636
|
+
const ignored = ignoredChatResetReason(held, event, previous);
|
|
1637
|
+
if (ignored) {
|
|
1638
|
+
console.warn("gateway chat session reset ignored", {
|
|
1639
|
+
applicationId: held.applicationId,
|
|
1640
|
+
reason: ignored,
|
|
1641
|
+
resetHasGenerationId: Boolean(event.generationId),
|
|
1642
|
+
turnHasGenerationId: Boolean(previous?.generationId),
|
|
1643
|
+
});
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
this.clearChatSessionState(held.applicationId);
|
|
1647
|
+
this.armReadRelease(held);
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
if (previous?.finished &&
|
|
1651
|
+
event.kind === "generation_state" &&
|
|
1652
|
+
event.generationId &&
|
|
1653
|
+
event.generationId === previous.generationId &&
|
|
1654
|
+
previous.finished.generationStatus !== "needs_decision") {
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
if (previous?.finished &&
|
|
1658
|
+
previous.held === held &&
|
|
1659
|
+
!userContent &&
|
|
1660
|
+
!activeGeneration) {
|
|
1661
|
+
if (previous.finished.generationStatus === "needs_decision" &&
|
|
1662
|
+
event.kind === "generation_state" &&
|
|
1663
|
+
(event.state === "cancelled" ||
|
|
1664
|
+
event.state === "completed" ||
|
|
1665
|
+
event.state === "idle") &&
|
|
1666
|
+
(!event.generationId ||
|
|
1667
|
+
!previous.generationId ||
|
|
1668
|
+
event.generationId === previous.generationId)) {
|
|
1669
|
+
previous.finished.generationStatus =
|
|
1670
|
+
event.state === "cancelled"
|
|
1671
|
+
? "cancelled"
|
|
1672
|
+
: event.hasSuggestions ||
|
|
1673
|
+
previous.collector.workSignals().length > 0
|
|
1674
|
+
? "completed"
|
|
1675
|
+
: "no_changes";
|
|
1676
|
+
delete previous.finished.decision;
|
|
1677
|
+
this.clearPendingDecision(held.applicationId);
|
|
1678
|
+
this.wake(previous);
|
|
1679
|
+
if (observed) {
|
|
1680
|
+
this.armObservedTurnRelease(held.applicationId, observed);
|
|
1681
|
+
}
|
|
1682
|
+
else if (owned) {
|
|
1683
|
+
this.armPostTurnRelease(held, owned);
|
|
1684
|
+
this.armKeepalive(held);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1379
1689
|
if (owned) {
|
|
1380
1690
|
if (!owned.finished && !userContent && !activeGeneration) {
|
|
1381
1691
|
return;
|
|
@@ -1399,12 +1709,6 @@ export class WebSocketSessionPeer {
|
|
|
1399
1709
|
if (!activeGeneration) {
|
|
1400
1710
|
return;
|
|
1401
1711
|
}
|
|
1402
|
-
if (event.kind === "generation_state" &&
|
|
1403
|
-
event.generationId &&
|
|
1404
|
-
event.generationId === observed.generationId &&
|
|
1405
|
-
observed.finished.generationStatus !== "needs_decision") {
|
|
1406
|
-
return;
|
|
1407
|
-
}
|
|
1408
1712
|
observed = undefined;
|
|
1409
1713
|
this.clearPendingDecision(held.applicationId);
|
|
1410
1714
|
}
|
|
@@ -1495,7 +1799,7 @@ export class WebSocketSessionPeer {
|
|
|
1495
1799
|
}
|
|
1496
1800
|
}
|
|
1497
1801
|
turn.finished = {
|
|
1498
|
-
|
|
1802
|
+
...turn.collector.replyFields(),
|
|
1499
1803
|
generationStatus,
|
|
1500
1804
|
viewStatus: turn.collector.viewableStatus(),
|
|
1501
1805
|
...decisionFields(decision),
|
|
@@ -1504,13 +1808,15 @@ export class WebSocketSessionPeer {
|
|
|
1504
1808
|
this.wake(turn);
|
|
1505
1809
|
this.armObservedTurnRelease(applicationId, turn);
|
|
1506
1810
|
}
|
|
1507
|
-
settleObservedTurn(turn, generationStatus,
|
|
1811
|
+
settleObservedTurn(turn, generationStatus, gatewayReply) {
|
|
1508
1812
|
if (turn.finished) {
|
|
1509
1813
|
return;
|
|
1510
1814
|
}
|
|
1511
1815
|
tagRetryableLiveEditDeath(turn, { generationStatus });
|
|
1512
1816
|
turn.finished = {
|
|
1513
|
-
|
|
1817
|
+
...(gatewayReply === undefined
|
|
1818
|
+
? turn.collector.replyFields()
|
|
1819
|
+
: { reply: gatewayReply }),
|
|
1514
1820
|
generationStatus,
|
|
1515
1821
|
viewStatus: turn.collector.viewableStatus(),
|
|
1516
1822
|
...decisionFields(turn.collector.decision()),
|
|
@@ -1538,7 +1844,7 @@ export class WebSocketSessionPeer {
|
|
|
1538
1844
|
this.clearTimers(held);
|
|
1539
1845
|
const delayMs = this.options.releaseAfterTurnMs ?? DEFAULT_RELEASE_AFTER_TURN_MS;
|
|
1540
1846
|
held.idleTimer = setTimeout(() => {
|
|
1541
|
-
if (this.observedTurns.get(held.applicationId) === turn &&
|
|
1847
|
+
if ((this.observedTurns.get(held.applicationId) ?? turn) === turn &&
|
|
1542
1848
|
this.held.get(held.applicationId) === held) {
|
|
1543
1849
|
this.drop(held, { preserveFinishedObserved: true });
|
|
1544
1850
|
}
|
|
@@ -1671,6 +1977,7 @@ export class WebSocketSessionPeer {
|
|
|
1671
1977
|
activity,
|
|
1672
1978
|
...(turn.branch === undefined ? {} : { branch: turn.branch }),
|
|
1673
1979
|
elapsedMs,
|
|
1980
|
+
...(turn.mode ? { mode: turn.mode } : {}),
|
|
1674
1981
|
status: "finished",
|
|
1675
1982
|
...turn.finished,
|
|
1676
1983
|
viewStatus: this.currentViewStatus(input.applicationId, turn.branch, turn.finished.viewStatus),
|
|
@@ -1732,7 +2039,7 @@ export class WebSocketSessionPeer {
|
|
|
1732
2039
|
inboundMethods: [...this.inboundMethods].sort(),
|
|
1733
2040
|
});
|
|
1734
2041
|
turn.finished ??= {
|
|
1735
|
-
|
|
2042
|
+
...collector.replyFields(),
|
|
1736
2043
|
generationStatus: "detached",
|
|
1737
2044
|
viewStatus: collector.viewableStatus(),
|
|
1738
2045
|
...decisionFields(collector.decision()),
|
|
@@ -1830,7 +2137,7 @@ export class WebSocketSessionPeer {
|
|
|
1830
2137
|
...(checkpointOutcome?.status === "saved" && checkpointOutcome.warning
|
|
1831
2138
|
? { checkpointWarning: checkpointOutcome.warning }
|
|
1832
2139
|
: {}),
|
|
1833
|
-
|
|
2140
|
+
...collector.replyFields(),
|
|
1834
2141
|
generationStatus,
|
|
1835
2142
|
...(turn.recentRuns.length > 0
|
|
1836
2143
|
? { recentRuns: [...turn.recentRuns] }
|
|
@@ -1849,7 +2156,7 @@ export class WebSocketSessionPeer {
|
|
|
1849
2156
|
// A disconnect / supersede already settled the turn with the reason; the
|
|
1850
2157
|
// abort it caused says less than what is already recorded.
|
|
1851
2158
|
turn.finished ??= {
|
|
1852
|
-
|
|
2159
|
+
...collector.replyFields(),
|
|
1853
2160
|
generationStatus: "timeout",
|
|
1854
2161
|
viewStatus: collector.viewableStatus(),
|
|
1855
2162
|
...decisionFields(collector.decision()),
|
|
@@ -2031,13 +2338,38 @@ export class WebSocketSessionPeer {
|
|
|
2031
2338
|
fresh.listeners.add(turn.listener);
|
|
2032
2339
|
previous.listeners.delete(turn.listener);
|
|
2033
2340
|
fresh.heartbeat.start();
|
|
2034
|
-
|
|
2035
|
-
? "refused"
|
|
2341
|
+
let claim = fresh.sessionToken === undefined
|
|
2342
|
+
? { kind: "refused" }
|
|
2036
2343
|
: await this.proveIdentity({
|
|
2037
2344
|
held: fresh,
|
|
2345
|
+
deadline: input.deadline,
|
|
2038
2346
|
sessionToken: fresh.sessionToken,
|
|
2039
2347
|
turnId: turn.turnId,
|
|
2040
2348
|
});
|
|
2349
|
+
let refreshes = 0;
|
|
2350
|
+
while (claim.kind === "claimed" &&
|
|
2351
|
+
fresh.browserChatStateRevision !== claim.browserRevision &&
|
|
2352
|
+
!turn.finished &&
|
|
2353
|
+
turn.held === previous &&
|
|
2354
|
+
this.turns.get(fresh.applicationId) === turn &&
|
|
2355
|
+
fresh.sessionToken !== undefined) {
|
|
2356
|
+
if (refreshes === MAX_CLAIM_REFRESHES || Date.now() >= input.deadline) {
|
|
2357
|
+
console.warn("gateway clark reattach snapshot kept changing", {
|
|
2358
|
+
applicationId: fresh.applicationId,
|
|
2359
|
+
reason: refreshes === MAX_CLAIM_REFRESHES ? "refresh_cap" : "deadline",
|
|
2360
|
+
refreshes,
|
|
2361
|
+
turnId: turn.turnId,
|
|
2362
|
+
});
|
|
2363
|
+
break;
|
|
2364
|
+
}
|
|
2365
|
+
refreshes++;
|
|
2366
|
+
claim = await this.proveIdentity({
|
|
2367
|
+
held: fresh,
|
|
2368
|
+
deadline: input.deadline,
|
|
2369
|
+
sessionToken: fresh.sessionToken,
|
|
2370
|
+
turnId: turn.turnId,
|
|
2371
|
+
});
|
|
2372
|
+
}
|
|
2041
2373
|
// Opening and claiming take seconds, and the turn can settle inside them:
|
|
2042
2374
|
// a relayed terminal event, the turn being cancelled, or a newer prompt
|
|
2043
2375
|
// taking the app. runTurn arms the post-turn timers once, against the
|
|
@@ -2046,6 +2378,11 @@ export class WebSocketSessionPeer {
|
|
|
2046
2378
|
// this point but the listener, so there is nothing to undo beyond letting
|
|
2047
2379
|
// the replacement go.
|
|
2048
2380
|
if (turn.finished || turn.held !== previous) {
|
|
2381
|
+
if (fresh.pendingChatSessionReset &&
|
|
2382
|
+
turn.held === previous &&
|
|
2383
|
+
this.turns.get(fresh.applicationId) === turn) {
|
|
2384
|
+
this.clearChatSessionState(fresh.applicationId);
|
|
2385
|
+
}
|
|
2049
2386
|
delete fresh.pendingTurn;
|
|
2050
2387
|
fresh.listeners.delete(turn.listener);
|
|
2051
2388
|
if (fresh.listeners.size === 0 &&
|
|
@@ -2054,7 +2391,7 @@ export class WebSocketSessionPeer {
|
|
|
2054
2391
|
}
|
|
2055
2392
|
return { kind: "stale" };
|
|
2056
2393
|
}
|
|
2057
|
-
if (claim === "unreachable") {
|
|
2394
|
+
if (claim.kind === "unreachable") {
|
|
2058
2395
|
delete fresh.pendingTurn;
|
|
2059
2396
|
fresh.listeners.delete(turn.listener);
|
|
2060
2397
|
this.drop(fresh);
|
|
@@ -2063,7 +2400,7 @@ export class WebSocketSessionPeer {
|
|
|
2063
2400
|
message: "the live-edit session stopped answering on a fresh socket",
|
|
2064
2401
|
};
|
|
2065
2402
|
}
|
|
2066
|
-
if (claim === "refused") {
|
|
2403
|
+
if (claim.kind === "refused") {
|
|
2067
2404
|
delete fresh.pendingTurn;
|
|
2068
2405
|
fresh.listeners.delete(turn.listener);
|
|
2069
2406
|
// Go back to the socket the turn was on, but only while it is still
|
|
@@ -2084,6 +2421,28 @@ export class WebSocketSessionPeer {
|
|
|
2084
2421
|
this.setHeld(fresh);
|
|
2085
2422
|
fresh.heartbeat.start();
|
|
2086
2423
|
this.drop(previous);
|
|
2424
|
+
if (fresh.pendingChatSessionReset) {
|
|
2425
|
+
this.observeEditorEvent(fresh, fresh.pendingChatSessionReset);
|
|
2426
|
+
delete fresh.pendingChatSessionReset;
|
|
2427
|
+
}
|
|
2428
|
+
const snapshot = claim.snapshot;
|
|
2429
|
+
if (!turn.finished &&
|
|
2430
|
+
this.turns.get(fresh.applicationId) === turn &&
|
|
2431
|
+
fresh.chatStateRevision === claim.revision &&
|
|
2432
|
+
snapshot?.generationState &&
|
|
2433
|
+
replaysOntoTurn(snapshot.generationState, {
|
|
2434
|
+
hasActiveInteractiveMessage: Boolean(snapshot.activeInteractiveMessage),
|
|
2435
|
+
turn,
|
|
2436
|
+
})) {
|
|
2437
|
+
if (snapshot.generationState.state === "awaiting_user_input" &&
|
|
2438
|
+
snapshot.activeInteractiveMessage) {
|
|
2439
|
+
turn.listener({
|
|
2440
|
+
kind: "message",
|
|
2441
|
+
message: snapshot.activeInteractiveMessage,
|
|
2442
|
+
});
|
|
2443
|
+
}
|
|
2444
|
+
turn.listener({ ...snapshot.generationState, kind: "generation_state" });
|
|
2445
|
+
}
|
|
2087
2446
|
return { kind: "took_over" };
|
|
2088
2447
|
}
|
|
2089
2448
|
/**
|
|
@@ -2124,12 +2483,17 @@ export class WebSocketSessionPeer {
|
|
|
2124
2483
|
* between two sockets on it.
|
|
2125
2484
|
*/
|
|
2126
2485
|
async proveIdentity(input) {
|
|
2486
|
+
if (Date.now() >= input.deadline) {
|
|
2487
|
+
return { kind: "unreachable" };
|
|
2488
|
+
}
|
|
2489
|
+
const revision = input.held.chatStateRevision;
|
|
2490
|
+
const browserRevision = input.held.browserChatStateRevision;
|
|
2127
2491
|
try {
|
|
2128
|
-
await editorSocket(input.held, input.sessionToken).aiChatGetMessages({
|
|
2492
|
+
const snapshot = await beforePromptDeadline(editorSocket(input.held, input.sessionToken).aiChatGetMessages({
|
|
2129
2493
|
force: false,
|
|
2130
2494
|
...this.turnCorrelator(input.turnId),
|
|
2131
|
-
});
|
|
2132
|
-
return "claimed";
|
|
2495
|
+
}), input.deadline);
|
|
2496
|
+
return { kind: "claimed", snapshot, revision, browserRevision };
|
|
2133
2497
|
}
|
|
2134
2498
|
catch (error) {
|
|
2135
2499
|
const message = describeSocketError(error);
|
|
@@ -2140,7 +2504,7 @@ export class WebSocketSessionPeer {
|
|
|
2140
2504
|
// A call that went unanswered says the socket opened onto something that
|
|
2141
2505
|
// is not servicing requests, which is the same fact a lost liveness beat
|
|
2142
2506
|
// reports and nothing like a session declining to hand a turn over.
|
|
2143
|
-
return isTimeout({ message }) ? "unreachable" : "refused";
|
|
2507
|
+
return { kind: isTimeout({ message }) ? "unreachable" : "refused" };
|
|
2144
2508
|
}
|
|
2145
2509
|
}
|
|
2146
2510
|
async reconnect(previous, deadline) {
|
|
@@ -2166,6 +2530,15 @@ export class WebSocketSessionPeer {
|
|
|
2166
2530
|
this.held.set(held.applicationId, held);
|
|
2167
2531
|
this.publishEditorStructure(held);
|
|
2168
2532
|
}
|
|
2533
|
+
hasStoredToolPermission(held, messageId) {
|
|
2534
|
+
if (held.dropping || this.held.get(held.applicationId) !== held) {
|
|
2535
|
+
return false;
|
|
2536
|
+
}
|
|
2537
|
+
const pending = this.appState.pendingDecision(held.applicationId);
|
|
2538
|
+
return (pending?.branch === held.branchName &&
|
|
2539
|
+
pending?.decision.kind === "tool_permission" &&
|
|
2540
|
+
pending.decision.messageId === messageId);
|
|
2541
|
+
}
|
|
2169
2542
|
recordEditorStructure(held, hasDefaultStructure) {
|
|
2170
2543
|
held.editorHasDefaultStructure = hasDefaultStructure;
|
|
2171
2544
|
this.publishEditorStructure(held);
|
|
@@ -2483,6 +2856,44 @@ export class WebSocketSessionPeer {
|
|
|
2483
2856
|
return undefined;
|
|
2484
2857
|
}
|
|
2485
2858
|
return (event) => {
|
|
2859
|
+
if (isBrowserObserverEvent(event) &&
|
|
2860
|
+
event.kind === "generation_state" &&
|
|
2861
|
+
event.state === "idle" &&
|
|
2862
|
+
event.chatSessionReset &&
|
|
2863
|
+
held.pendingTurn &&
|
|
2864
|
+
this.turns.get(held.applicationId) === held.pendingTurn) {
|
|
2865
|
+
// Whether or not the reset names the pending turn, a claim
|
|
2866
|
+
// snapshot read before it is stale and must be read again.
|
|
2867
|
+
held.browserChatStateRevision++;
|
|
2868
|
+
held.chatStateRevision++;
|
|
2869
|
+
if (event.generationId &&
|
|
2870
|
+
event.generationId === held.pendingTurn.generationId) {
|
|
2871
|
+
held.pendingChatSessionReset = event;
|
|
2872
|
+
}
|
|
2873
|
+
else {
|
|
2874
|
+
console.warn("gateway chat session reset ignored", {
|
|
2875
|
+
applicationId: held.applicationId,
|
|
2876
|
+
reason: "uncorrelated_during_takeover",
|
|
2877
|
+
resetHasGenerationId: Boolean(event.generationId),
|
|
2878
|
+
turnHasGenerationId: Boolean(held.pendingTurn.generationId),
|
|
2879
|
+
});
|
|
2880
|
+
}
|
|
2881
|
+
return;
|
|
2882
|
+
}
|
|
2883
|
+
const browserText = isBrowserObserverEvent(event) &&
|
|
2884
|
+
event.kind === "message" &&
|
|
2885
|
+
event.message.role !== "user" &&
|
|
2886
|
+
(event.message.type === undefined ||
|
|
2887
|
+
event.message.type === "text" ||
|
|
2888
|
+
event.message.type === "text-delta" ||
|
|
2889
|
+
event.message.type === "reasoning");
|
|
2890
|
+
if (isBrowserObserverEvent(event) && !browserText) {
|
|
2891
|
+
held.browserChatStateRevision++;
|
|
2892
|
+
}
|
|
2893
|
+
if (!browserText &&
|
|
2894
|
+
(event.kind === "message" || event.kind === "generation_state")) {
|
|
2895
|
+
held.chatStateRevision++;
|
|
2896
|
+
}
|
|
2486
2897
|
if (event.kind === "sync_status" && event.status) {
|
|
2487
2898
|
held.syncStatus = event.status;
|
|
2488
2899
|
}
|
|
@@ -2499,6 +2910,10 @@ export class WebSocketSessionPeer {
|
|
|
2499
2910
|
}
|
|
2500
2911
|
return;
|
|
2501
2912
|
}
|
|
2913
|
+
if (event.kind === "tool_permission_request" &&
|
|
2914
|
+
!this.hasStoredToolPermission(held, event.messageId)) {
|
|
2915
|
+
throw new ToolPermissionListenerUnavailableError();
|
|
2916
|
+
}
|
|
2502
2917
|
};
|
|
2503
2918
|
}, (method) => {
|
|
2504
2919
|
this.inboundMethods.add(method);
|
|
@@ -2545,32 +2960,6 @@ export class WebSocketSessionPeer {
|
|
|
2545
2960
|
turn.activity.push(line);
|
|
2546
2961
|
this.wake(turn);
|
|
2547
2962
|
},
|
|
2548
|
-
respondToToolPermission: async (payload) => {
|
|
2549
|
-
const current = heldRef.current;
|
|
2550
|
-
if (!current) {
|
|
2551
|
-
return;
|
|
2552
|
-
}
|
|
2553
|
-
const turn = current.pendingTurn ?? this.turns.get(current.applicationId);
|
|
2554
|
-
if (!turn ||
|
|
2555
|
-
turn.finished ||
|
|
2556
|
-
(turn.held !== current && current.pendingTurn !== turn)) {
|
|
2557
|
-
return;
|
|
2558
|
-
}
|
|
2559
|
-
try {
|
|
2560
|
-
await editorSocket(current, current.sessionToken).aiRespondToToolPermission({
|
|
2561
|
-
approved: payload.approved,
|
|
2562
|
-
...this.turnCorrelator(turn.turnId),
|
|
2563
|
-
messageId: payload.messageId,
|
|
2564
|
-
requestId: payload.requestId ?? payload.messageId,
|
|
2565
|
-
});
|
|
2566
|
-
}
|
|
2567
|
-
catch (error) {
|
|
2568
|
-
console.warn("gateway auto-approve tool permission failed", {
|
|
2569
|
-
applicationId: current.applicationId,
|
|
2570
|
-
message: error instanceof Error ? error.message : String(error),
|
|
2571
|
-
});
|
|
2572
|
-
}
|
|
2573
|
-
},
|
|
2574
2963
|
}), [], {
|
|
2575
2964
|
onClose: () => {
|
|
2576
2965
|
const current = this.held.get(input.applicationId);
|
|
@@ -2599,7 +2988,7 @@ export class WebSocketSessionPeer {
|
|
|
2599
2988
|
const applicationEnded = isApplicationClose(close);
|
|
2600
2989
|
this.settleObservedTurn(observed, applicationEnded ? "live_edit_terminated" : "detached", applicationEnded
|
|
2601
2990
|
? LIVE_EDIT_TERMINATED_REPLY + describeClose(close)
|
|
2602
|
-
:
|
|
2991
|
+
: undefined);
|
|
2603
2992
|
return;
|
|
2604
2993
|
}
|
|
2605
2994
|
// An application code is the live edit stating its own end, so there
|
|
@@ -2624,6 +3013,8 @@ export class WebSocketSessionPeer {
|
|
|
2624
3013
|
const held = {
|
|
2625
3014
|
applicationId: input.applicationId,
|
|
2626
3015
|
branchName: input.branchName,
|
|
3016
|
+
chatStateRevision: 0,
|
|
3017
|
+
browserChatStateRevision: 0,
|
|
2627
3018
|
client,
|
|
2628
3019
|
heartbeat,
|
|
2629
3020
|
idleTimer: undefined,
|
|
@@ -2647,6 +3038,9 @@ export class WebSocketSessionPeer {
|
|
|
2647
3038
|
ws,
|
|
2648
3039
|
};
|
|
2649
3040
|
heldRef.current = held;
|
|
3041
|
+
// Paused by `attempt` so no frame arrives before ISocket is listening and
|
|
3042
|
+
// `heldRef` can route it. Frames resume on a later tick, after this block.
|
|
3043
|
+
ws.resume();
|
|
2650
3044
|
if (publish) {
|
|
2651
3045
|
try {
|
|
2652
3046
|
this.assertReadOnlyHoldCurrent(input.applicationId, expectedTurn);
|
|
@@ -3122,7 +3516,7 @@ export class WebSocketSessionPeer {
|
|
|
3122
3516
|
closeCode: input.close.code,
|
|
3123
3517
|
});
|
|
3124
3518
|
this.settleTurn(turn, {
|
|
3125
|
-
|
|
3519
|
+
...turn.collector.replyFields(),
|
|
3126
3520
|
generationStatus: "detached",
|
|
3127
3521
|
viewStatus: turn.collector.viewableStatus(),
|
|
3128
3522
|
...decisionFields(turn.collector.decision()),
|
|
@@ -3140,11 +3534,14 @@ export class WebSocketSessionPeer {
|
|
|
3140
3534
|
if (!prior) {
|
|
3141
3535
|
return;
|
|
3142
3536
|
}
|
|
3143
|
-
this.cancelTurn(prior
|
|
3537
|
+
this.cancelTurn(prior, {
|
|
3538
|
+
replaced: true,
|
|
3539
|
+
reply: "This turn was replaced by a newer prompt on the same app, so its work was cancelled.",
|
|
3540
|
+
});
|
|
3144
3541
|
}
|
|
3145
|
-
cancelTurn(turn,
|
|
3542
|
+
cancelTurn(turn, outcome) {
|
|
3146
3543
|
turn.finished ??= {
|
|
3147
|
-
|
|
3544
|
+
...outcome,
|
|
3148
3545
|
generationStatus: "cancelled",
|
|
3149
3546
|
viewStatus: turn.collector.viewableStatus(),
|
|
3150
3547
|
};
|
|
@@ -3294,7 +3691,7 @@ export class WebSocketSessionPeer {
|
|
|
3294
3691
|
* the app shell cannot boot without — Clark then runs the turn with its
|
|
3295
3692
|
* ordinary tools rather than failing it.
|
|
3296
3693
|
*/
|
|
3297
|
-
browserContextFor(held) {
|
|
3694
|
+
async browserContextFor(held) {
|
|
3298
3695
|
if (!this.options.testingEnabled) {
|
|
3299
3696
|
return undefined;
|
|
3300
3697
|
}
|
|
@@ -3304,7 +3701,7 @@ export class WebSocketSessionPeer {
|
|
|
3304
3701
|
* Library iframe bootstrap from a held session. Used for Gateway's own
|
|
3305
3702
|
* live-canvas screenshot even when Clark's in-sandbox Playwright is off.
|
|
3306
3703
|
*/
|
|
3307
|
-
buildHeldBrowserContext(held) {
|
|
3704
|
+
async buildHeldBrowserContext(held) {
|
|
3308
3705
|
if (!held.orchestratorUrl) {
|
|
3309
3706
|
console.error("gateway live canvas skipped: missing_orchestrator_url", {
|
|
3310
3707
|
applicationId: held.applicationId,
|
|
@@ -3323,8 +3720,11 @@ export class WebSocketSessionPeer {
|
|
|
3323
3720
|
});
|
|
3324
3721
|
return undefined;
|
|
3325
3722
|
}
|
|
3723
|
+
const apiKey = this.options.getAccessToken
|
|
3724
|
+
? await this.options.getAccessToken()
|
|
3725
|
+
: held.apiKey;
|
|
3326
3726
|
return buildBrowserContext({
|
|
3327
|
-
...(
|
|
3727
|
+
...(apiKey ? { apiKey } : {}),
|
|
3328
3728
|
applicationId: held.applicationId,
|
|
3329
3729
|
liveEditUrl: held.liveEdit.url,
|
|
3330
3730
|
orchestratorUrl: held.orchestratorUrl,
|
|
@@ -3503,7 +3903,7 @@ export class WebSocketSessionPeer {
|
|
|
3503
3903
|
profileKey,
|
|
3504
3904
|
});
|
|
3505
3905
|
held.orchestratorUrl = orchestratorUrl;
|
|
3506
|
-
held.orchestrator = new HttpOrchestratorClient(orchestratorUrl);
|
|
3906
|
+
held.orchestrator = new HttpOrchestratorClient(orchestratorUrl, undefined, this.options.getAccessToken);
|
|
3507
3907
|
held.orchestratorAgentType = inventory.agentType;
|
|
3508
3908
|
delete held.orchestratorStale;
|
|
3509
3909
|
console.error("gateway resolved orchestrator url", {
|