dsh-ntr 0.3.2 → 0.3.3
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.en.md +2 -0
- package/README.md +2 -0
- package/lib/bridge.js +1819 -54
- package/lib/client/activity-pill.js +156 -0
- package/lib/client/branch-action.js +16 -0
- package/lib/client/index.js +44 -4
- package/lib/client/model-seat.js +8 -2
- package/lib/client/store.js +32 -0
- package/lib/client.js +228 -5
- package/lib/client.js.map +1 -1
- package/lib/impl.js +1175 -7
- package/lib/index.js +27 -0
- package/lib/tps.js +32 -0
- package/lib/types/bridge.d.ts +621 -12
- package/lib/types/client/activity-pill.d.ts +81 -0
- package/lib/types/client/branch-action.d.ts +8 -1
- package/lib/types/client/model-seat.d.ts +21 -0
- package/lib/types/client/store.d.ts +10 -0
- package/lib/types/impl.d.ts +53 -6
- package/lib/types/protocol.d.ts +22 -0
- package/lib/types/tps.d.ts +17 -0
- package/package.json +1 -1
- package/src/bridge.ts +2103 -53
- package/src/client/activity-pill.tsx +201 -0
- package/src/client/branch-action.tsx +19 -1
- package/src/client/index.tsx +36 -4
- package/src/client/model-seat.tsx +28 -3
- package/src/client/store.ts +37 -0
- package/src/impl.ts +1167 -11
- package/src/index.ts +25 -0
- package/src/protocol.ts +24 -0
- package/src/tps.ts +40 -0
package/lib/bridge.js
CHANGED
|
@@ -12,8 +12,9 @@ import { delimiter, dirname, join } from 'node:path';
|
|
|
12
12
|
import { Writable, Readable } from 'node:stream';
|
|
13
13
|
import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { client, methods, ndJsonStream, PROTOCOL_VERSION, RequestError, } from '@agentclientprotocol/sdk';
|
|
15
|
-
import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, permissionRequestFrame, pickAutoOption, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, validatePermissionTimeoutMode, normalizeApprovalWindow, } from "./impl.js";
|
|
15
|
+
import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, permissionRequestFrame, pickAutoOption, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, isAbsolutePath, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, validatePermissionTimeoutMode, normalizeApprovalWindow, } from "./impl.js";
|
|
16
16
|
import { DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_TAG_HALO, IDLE_RELEASE_MS } from "./protocol.js";
|
|
17
|
+
import { OUTPUT_CHARS_PER_TOKEN, TurnTpsTracker } from "./tps.js";
|
|
17
18
|
/**
|
|
18
19
|
* Version of this installed package, read from the shipped `package.json` —
|
|
19
20
|
* one level above both `src/` at test time and `lib/` at runtime. The settings
|
|
@@ -106,6 +107,11 @@ const DEFAULT_TUNING = {
|
|
|
106
107
|
turnStallTickMs: 30_000,
|
|
107
108
|
idleTimeoutMs: DEFAULT_IDLE_TIMEOUT_MS,
|
|
108
109
|
probeIdleTimeoutMs: 300_000,
|
|
110
|
+
ambientQuietMs: 10_000,
|
|
111
|
+
ambientMaxMs: 120_000,
|
|
112
|
+
backgroundTaskMaxMs: 600_000,
|
|
113
|
+
backgroundParkedGraceMs: 30_000,
|
|
114
|
+
ambientStaleMs: 300_000,
|
|
109
115
|
};
|
|
110
116
|
export class AgentBridge {
|
|
111
117
|
def;
|
|
@@ -384,11 +390,16 @@ export class AgentBridge {
|
|
|
384
390
|
* same session — forbidden by the protocol and rejected (or worse, mishandled)
|
|
385
391
|
* by the agent. Waiting turns a protocol error into a short delay.
|
|
386
392
|
*
|
|
393
|
+
* The gate is {@link LiveSession.promptInFlight} + the settle promise, NOT
|
|
394
|
+
* {@link LiveSession.running}: an ambient turn keeps `running` true for the
|
|
395
|
+
* whole drain while its prompt slot is already free, so `running` would
|
|
396
|
+
* refuse every message the user sends during a background task.
|
|
397
|
+
*
|
|
387
398
|
* @param session - the session asking for the slot.
|
|
388
399
|
* @returns whether the slot was claimed, and why not when it was not.
|
|
389
400
|
*/
|
|
390
401
|
async claimTurn(session) {
|
|
391
|
-
if (session
|
|
402
|
+
if (this.promptSlotHeld(session))
|
|
392
403
|
return { ok: false, reason: 'busy' };
|
|
393
404
|
const pending = session.settled;
|
|
394
405
|
if (pending === undefined)
|
|
@@ -411,13 +422,20 @@ export class AgentBridge {
|
|
|
411
422
|
// so report it honestly instead of handing the agent two prompts at once.
|
|
412
423
|
if (session.settled !== undefined)
|
|
413
424
|
return { ok: false, reason: 'unsettled' };
|
|
414
|
-
|
|
425
|
+
// Re-checked after the wait: another turn may have claimed the slot while
|
|
426
|
+
// this one was waiting on the gate.
|
|
427
|
+
if (this.promptSlotHeld(session))
|
|
415
428
|
return { ok: false, reason: 'busy' };
|
|
416
429
|
return { ok: true };
|
|
417
430
|
}
|
|
431
|
+
/** Whether a turn currently holds the agent prompt slot (see `promptInFlight`). */
|
|
432
|
+
promptSlotHeld(session) {
|
|
433
|
+
return session.promptInFlight === true;
|
|
434
|
+
}
|
|
418
435
|
/**
|
|
419
|
-
* Mark the agent-side turn as in flight
|
|
420
|
-
*
|
|
436
|
+
* Mark the agent-side turn as in flight — claim the agent prompt slot and
|
|
437
|
+
* open the settle gate — and hand back the settler its stream owner must
|
|
438
|
+
* call once the agent is actually done with it.
|
|
421
439
|
*
|
|
422
440
|
* @param session - the session whose turn is starting.
|
|
423
441
|
* @returns the settler (idempotent; also used by connection loss).
|
|
@@ -427,11 +445,16 @@ export class AgentBridge {
|
|
|
427
445
|
const settled = new Promise(resolve => { settle = resolve; });
|
|
428
446
|
session.settled = settled;
|
|
429
447
|
session.settledResolve = settle;
|
|
448
|
+
session.promptInFlight = true;
|
|
430
449
|
this.touch();
|
|
431
450
|
return () => {
|
|
451
|
+
// Identity-guarded: only THIS turn's settler may release the slot. A
|
|
452
|
+
// superseded turn's late settler must not clear the flag a NEWER turn
|
|
453
|
+
// has already claimed (its settle gate was replaced with a new promise).
|
|
432
454
|
if (session.settled === settled) {
|
|
433
455
|
session.settled = undefined;
|
|
434
456
|
session.settledResolve = undefined;
|
|
457
|
+
session.promptInFlight = false;
|
|
435
458
|
}
|
|
436
459
|
settle();
|
|
437
460
|
// The turn is over: a fresh window starts here rather than at the prompt
|
|
@@ -442,12 +465,14 @@ export class AgentBridge {
|
|
|
442
465
|
/**
|
|
443
466
|
* Open a session's settle gate without owning its turn stream: the turn's
|
|
444
467
|
* stream is gone (a resume, a release) but the next prompt still waits on
|
|
445
|
-
* this promise, and nothing is left that could ever resolve it.
|
|
468
|
+
* this promise, and nothing is left that could ever resolve it. The prompt
|
|
469
|
+
* slot itself goes with it: whatever held it is gone too.
|
|
446
470
|
*/
|
|
447
471
|
openSettleGate(session) {
|
|
448
472
|
const settle = session.settledResolve;
|
|
449
473
|
session.settled = undefined;
|
|
450
474
|
session.settledResolve = undefined;
|
|
475
|
+
session.promptInFlight = false;
|
|
451
476
|
settle?.();
|
|
452
477
|
}
|
|
453
478
|
view() {
|
|
@@ -939,28 +964,69 @@ export class AgentBridge {
|
|
|
939
964
|
* {@link BridgeTuning.staleCancelRetryDelayMs}, out-waiting the cleanup
|
|
940
965
|
* window, bounded by {@link BridgeTuning.staleCancelMaxRetries}; a genuinely
|
|
941
966
|
* requested cancel (`cancelRequestedAtTurn` still names this generation)
|
|
942
|
-
* returns as-is
|
|
967
|
+
* returns as-is — checked BOTH before the delay and after it, so a second
|
|
968
|
+
* stop click inside the retry window does not resend the prompt the user
|
|
969
|
+
* just stopped.
|
|
970
|
+
*
|
|
971
|
+
* `turnGen` is the {@link LiveSession.turnSeq} this prompt was sent for. A
|
|
972
|
+
* stale-cancel resend is only allowed while that generation still owns the
|
|
973
|
+
* session: once a successor turn began (the cancel fallback finalized this
|
|
974
|
+
* one and the user moved on), resending would race a second `session/prompt`
|
|
975
|
+
* into the successor's turn. The late answer is returned as-is instead.
|
|
976
|
+
*
|
|
977
|
+
* The RESPONSE's `_meta` travels with the stop reason: it is the only place
|
|
978
|
+
* an agent says whether the prompt actually queued a conversation turn (see
|
|
979
|
+
* `CodebuddyMeta.conversationRequestId`), which decides whether an instant
|
|
980
|
+
* answer was the turn ending or a fold into a background drain.
|
|
943
981
|
*/
|
|
944
|
-
async runPrompt(session,
|
|
982
|
+
async runPrompt(session, prompt, turnId, turnGen) {
|
|
945
983
|
const conn = this.conn;
|
|
946
984
|
if (conn === undefined || session.closed || session.released)
|
|
947
985
|
throw new AcpError('session_closed', 'acp: session is closed');
|
|
948
986
|
this.touch();
|
|
987
|
+
const blocks = typeof prompt === 'string'
|
|
988
|
+
? [{ type: 'text', text: prompt }]
|
|
989
|
+
: prompt;
|
|
990
|
+
if (blocks.length === 0) {
|
|
991
|
+
throw new AcpError('empty_prompt', 'acp: prompt cannot be empty');
|
|
992
|
+
}
|
|
993
|
+
const hasImages = blocks.some(b => b.type === 'image');
|
|
994
|
+
if (hasImages && this.initResult?.agentCapabilities?.promptCapabilities?.image !== true) {
|
|
995
|
+
throw new AcpError('image_unsupported', `acp: agent "${this.def.id}" does not support image input`);
|
|
996
|
+
}
|
|
949
997
|
for (let attempt = 0;; attempt += 1) {
|
|
950
998
|
// The response is activity too: the idle window must start at the turn's
|
|
951
999
|
// END, not at the prompt that may have started a long time ago.
|
|
952
1000
|
const result = await conn.agent.request(methods.agent.session.prompt, {
|
|
953
1001
|
sessionId: session.acpSessionId,
|
|
954
|
-
prompt:
|
|
1002
|
+
prompt: blocks,
|
|
955
1003
|
}).finally(() => { this.touch(); });
|
|
1004
|
+
const answered = () => ({
|
|
1005
|
+
stopReason: result.stopReason,
|
|
1006
|
+
...(result._meta !== undefined && result._meta !== null ? { meta: result._meta } : {}),
|
|
1007
|
+
});
|
|
956
1008
|
if (result.stopReason !== 'cancelled' || session.cancelRequestedAtTurn === session.turnSeq) {
|
|
957
|
-
return
|
|
1009
|
+
return answered();
|
|
958
1010
|
}
|
|
1011
|
+
// The turn this answer belongs to no longer owns the session (a cancel
|
|
1012
|
+
// fallback finalized it and a successor began): resending would fire a
|
|
1013
|
+
// second `session/prompt` into the successor's turn.
|
|
1014
|
+
if (session.turnSeq !== turnGen)
|
|
1015
|
+
return answered();
|
|
959
1016
|
if (attempt >= this.staleCancelMaxRetries)
|
|
960
|
-
return
|
|
1017
|
+
return answered();
|
|
961
1018
|
await new Promise(resolve => setTimeout(resolve, this.staleCancelRetryDelayMs));
|
|
962
1019
|
if (session.closed || this.conn !== conn)
|
|
963
1020
|
throw new AcpError('session_closed', 'acp: session is closed');
|
|
1021
|
+
// Re-check after the delay: the successor may have started while waiting.
|
|
1022
|
+
if (session.turnSeq !== turnGen)
|
|
1023
|
+
return answered();
|
|
1024
|
+
// ...and a SECOND stop during the delay names this same generation. The
|
|
1025
|
+
// user already said stop, so the stale-cancel answer is not a swallow to
|
|
1026
|
+
// out-wait anymore: resending would make the agent run the prompt the
|
|
1027
|
+
// user just stopped (the double-click case). Return as-is.
|
|
1028
|
+
if (session.cancelRequestedAtTurn === session.turnSeq)
|
|
1029
|
+
return answered();
|
|
964
1030
|
this.touch();
|
|
965
1031
|
}
|
|
966
1032
|
}
|
|
@@ -1006,10 +1072,19 @@ export class AgentBridge {
|
|
|
1006
1072
|
}
|
|
1007
1073
|
/**
|
|
1008
1074
|
* Force the local end of one turn whose agent never acknowledged it: clear
|
|
1009
|
-
* `running`, open the settle gate, and
|
|
1010
|
-
* ''` (there is no agent response to attribute it to). Shared by the cancel
|
|
1075
|
+
* `running`, open the settle gate, and end the turn. Shared by the cancel
|
|
1011
1076
|
* fallback and the stall watchdog.
|
|
1012
1077
|
*
|
|
1078
|
+
* A live controller owns the turn's streaming face — its segment queue, its
|
|
1079
|
+
* ambient clocks and its `turn/end` id. Ending the turn behind its back
|
|
1080
|
+
* would leave the driver step parked on an open queue, and the controller's
|
|
1081
|
+
* own timers would later broadcast a SECOND `turn/end` for a turn already
|
|
1082
|
+
* closed. So when the session still points at a current controller, the
|
|
1083
|
+
* force goes THROUGH it (`forceFinalize`), which runs the same closing work
|
|
1084
|
+
* as a normal settle exactly once. Only a turn with no live controller (an
|
|
1085
|
+
* `/acp`-RPC turn, or one already retired) falls back to the local broadcast
|
|
1086
|
+
* with `turnId: ''` — there is no stream whose id could be named.
|
|
1087
|
+
*
|
|
1013
1088
|
* Generation-guarded: a turn that already ended — or that a later turn
|
|
1014
1089
|
* superseded — is not this call's to finalize. Returns whether it acted; a
|
|
1015
1090
|
* `false` means the caller was late and must leave the session alone.
|
|
@@ -1017,6 +1092,11 @@ export class AgentBridge {
|
|
|
1017
1092
|
finalizeTurn(session, turnGen, message) {
|
|
1018
1093
|
if (session.closed || session.turnSeq !== turnGen)
|
|
1019
1094
|
return false;
|
|
1095
|
+
const controller = session.turnController;
|
|
1096
|
+
if (controller !== undefined && !controller.stale) {
|
|
1097
|
+
controller.forceFinalize({ kind: 'error', message });
|
|
1098
|
+
return true;
|
|
1099
|
+
}
|
|
1020
1100
|
// Nothing is in flight anymore (the turn settled on its own): nothing to
|
|
1021
1101
|
// finalize. A still-pending settle gate counts as in flight even though
|
|
1022
1102
|
// the stream is gone — that gate is what the next prompt waits on.
|
|
@@ -1085,10 +1165,12 @@ export class AgentBridge {
|
|
|
1085
1165
|
session.closed = false;
|
|
1086
1166
|
session.released = false;
|
|
1087
1167
|
// A resume is a fresh agent-side state: nothing from the dead process's
|
|
1088
|
-
// turn survives it, including the settle gate and the stall markers.
|
|
1168
|
+
// turn survives it, including the settle gate and the stall markers. A
|
|
1169
|
+
// background drain belonged to that process too.
|
|
1089
1170
|
session.orphanPromptTurnSeq = undefined;
|
|
1090
1171
|
session.reclaimedTurnSeq = undefined;
|
|
1091
1172
|
session.activeToolCalls.clear();
|
|
1173
|
+
clearAmbientState(session);
|
|
1092
1174
|
session.lastAgentActivityAt = Date.now();
|
|
1093
1175
|
this.openSettleGate(session);
|
|
1094
1176
|
// Route replay updates into the fresh transcript while the load request
|
|
@@ -1150,8 +1232,10 @@ export class AgentBridge {
|
|
|
1150
1232
|
// would make the next prompt wait for an answer that can no longer come.
|
|
1151
1233
|
this.openSettleGate(session);
|
|
1152
1234
|
// No call from the dead process can still be running: drop the tool set
|
|
1153
|
-
// so a resumed turn starts on the plain window.
|
|
1235
|
+
// so a resumed turn starts on the plain window. A background drain died
|
|
1236
|
+
// with it, and a stale ambient would misclassify the NEXT prompt.
|
|
1154
1237
|
session.activeToolCalls.clear();
|
|
1238
|
+
clearAmbientState(session);
|
|
1155
1239
|
session.released = true;
|
|
1156
1240
|
this.settlePermissions(session);
|
|
1157
1241
|
this.released.set(session.sessionId, session);
|
|
@@ -1259,6 +1343,12 @@ export class AgentBridge {
|
|
|
1259
1343
|
if (session.closed)
|
|
1260
1344
|
return;
|
|
1261
1345
|
session.closed = true;
|
|
1346
|
+
// The session is going away: whatever background drain it announced ends
|
|
1347
|
+
// with it (a re-created session must not inherit a stale ambient).
|
|
1348
|
+
clearAmbientState(session);
|
|
1349
|
+
// ...and so does its prompt slot: nothing may keep rejecting a new prompt
|
|
1350
|
+
// for a record that is being deleted.
|
|
1351
|
+
session.promptInFlight = false;
|
|
1262
1352
|
// Closing a session is activity of its own: restart the idle window here
|
|
1263
1353
|
// rather than at the prompt that opened it.
|
|
1264
1354
|
this.touch();
|
|
@@ -1331,6 +1421,7 @@ export class AgentBridge {
|
|
|
1331
1421
|
this.settlePermissions(session);
|
|
1332
1422
|
this.openSettleGate(session);
|
|
1333
1423
|
session.activeToolCalls.clear();
|
|
1424
|
+
clearAmbientState(session);
|
|
1334
1425
|
this.sessions.delete(session.sessionId);
|
|
1335
1426
|
this.acpIndex.delete(session.acpSessionId);
|
|
1336
1427
|
this.hub.broadcast({ type: 'session/closed', sessionId: session.sessionId, reason });
|
|
@@ -1602,6 +1693,61 @@ export class AgentBridge {
|
|
|
1602
1693
|
// tool, which earns the wider watchdog window.
|
|
1603
1694
|
session.lastAgentActivityAt = Date.now();
|
|
1604
1695
|
const kind = params.update.sessionUpdate;
|
|
1696
|
+
const meta = codebuddyMetaOf(params.update);
|
|
1697
|
+
// A RETIRED drain that speaks again is alive, not retired. Un-retire it
|
|
1698
|
+
// BEFORE this update is classified and rebuild the ambient record: the run
|
|
1699
|
+
// may never re-announce itself, yet the next prompt folded into it must
|
|
1700
|
+
// still be recognised as steered and receive the drain's text. A
|
|
1701
|
+
// re-announcement below does the same for its id.
|
|
1702
|
+
if (meta?.requestId !== undefined && isRetiredAmbientId(session, meta.requestId)) {
|
|
1703
|
+
reviveAmbientId(session, meta.requestId);
|
|
1704
|
+
session.ambient = {
|
|
1705
|
+
requestId: meta.requestId,
|
|
1706
|
+
startedAt: session.ambient?.startedAt ?? Date.now(),
|
|
1707
|
+
lastActivityAt: Date.now(),
|
|
1708
|
+
sawOutput: session.ambient?.sawOutput ?? false,
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
if (meta?.unsolicitedTurn !== undefined) {
|
|
1712
|
+
// The agent started a background turn on its own: remember it so a
|
|
1713
|
+
// prompt the agent folds into it (answered instantly, no conversation
|
|
1714
|
+
// request id) can be kept open until the drain speaks. Announced once per
|
|
1715
|
+
// request id — a repeated announcement is the same run, not news.
|
|
1716
|
+
const run = meta.unsolicitedTurn;
|
|
1717
|
+
// A re-announcement also revives a drain whose wait already closed: the
|
|
1718
|
+
// same run is alive, and the notice de-dupe set keeps it silent.
|
|
1719
|
+
reviveAmbientId(session, run.requestId);
|
|
1720
|
+
session.ambient = { requestId: run.requestId, startedAt: Date.now(), lastActivityAt: Date.now(), sawOutput: false };
|
|
1721
|
+
const announced = session.ambientNotices ?? new Set();
|
|
1722
|
+
session.ambientNotices = announced;
|
|
1723
|
+
if (!announced.has(run.requestId)) {
|
|
1724
|
+
announced.add(run.requestId);
|
|
1725
|
+
// The de-dupe set outlives individual turns (a hard ceiling keeps the
|
|
1726
|
+
// drain record), so it is bounded: evict the oldest ids first.
|
|
1727
|
+
while (announced.size > AMBIENT_NOTICE_MAX) {
|
|
1728
|
+
const oldest = announced.values().next().value;
|
|
1729
|
+
if (oldest === undefined)
|
|
1730
|
+
break;
|
|
1731
|
+
announced.delete(oldest);
|
|
1732
|
+
}
|
|
1733
|
+
this.hub.broadcast({
|
|
1734
|
+
type: 'agent/notice',
|
|
1735
|
+
agentId: session.agentId,
|
|
1736
|
+
level: 'info',
|
|
1737
|
+
text: `acp: agent "${this.def.id}" started a background task turn (${run.reason ?? 'unsolicited'}); its output will appear in the ACP transcript view, or in the turn that was waiting for it.`,
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
if (session.ambient !== undefined
|
|
1742
|
+
&& meta?.requestId === session.ambient.requestId) {
|
|
1743
|
+
// The run is alive: refresh the staleness clock on ANY update it owns,
|
|
1744
|
+
// not just prose (a tool-heavy stretch is still the drain working).
|
|
1745
|
+
session.ambient.lastActivityAt = Date.now();
|
|
1746
|
+
if ((kind === 'agent_message_chunk' || kind === 'agent_thought_chunk')
|
|
1747
|
+
&& chunkTextOf(params.update.content) !== '') {
|
|
1748
|
+
session.ambient.sawOutput = true;
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1605
1751
|
if (kind === 'tool_call' || kind === 'tool_call_update') {
|
|
1606
1752
|
const { toolCallId, status } = params.update;
|
|
1607
1753
|
if (status === 'completed' || status === 'failed')
|
|
@@ -1662,6 +1808,129 @@ export class AgentBridge {
|
|
|
1662
1808
|
}
|
|
1663
1809
|
}
|
|
1664
1810
|
}
|
|
1811
|
+
/** Read the CodeBuddy `_meta` keys off an update or a `{ _meta }` wrapper. */
|
|
1812
|
+
function codebuddyMetaOf(source) {
|
|
1813
|
+
if (typeof source !== 'object' || source === null)
|
|
1814
|
+
return undefined;
|
|
1815
|
+
const row = source;
|
|
1816
|
+
const rawMeta = row._meta;
|
|
1817
|
+
if (typeof rawMeta !== 'object' || rawMeta === null)
|
|
1818
|
+
return undefined;
|
|
1819
|
+
const meta = rawMeta;
|
|
1820
|
+
const out = {};
|
|
1821
|
+
const requestId = meta['codebuddy.ai/requestId'];
|
|
1822
|
+
if (typeof requestId === 'string' && requestId !== '')
|
|
1823
|
+
out.requestId = requestId;
|
|
1824
|
+
const conversationRequestId = meta['codebuddy.ai/conversationRequestId'];
|
|
1825
|
+
if (typeof conversationRequestId === 'string' && conversationRequestId !== '') {
|
|
1826
|
+
out.conversationRequestId = conversationRequestId;
|
|
1827
|
+
}
|
|
1828
|
+
const toolName = meta['codebuddy.ai/toolName'];
|
|
1829
|
+
if (typeof toolName === 'string' && toolName !== '')
|
|
1830
|
+
out.toolName = toolName;
|
|
1831
|
+
const parentToolCallId = meta['codebuddy.ai/parentToolCallId'];
|
|
1832
|
+
if (typeof parentToolCallId === 'string' && parentToolCallId !== '')
|
|
1833
|
+
out.parentToolCallId = parentToolCallId;
|
|
1834
|
+
// Booleans, read as booleans only: a string "true" is not the declared shape
|
|
1835
|
+
// and guessing from it would make another agent's unrelated key look detached.
|
|
1836
|
+
if (meta['codebuddy.ai/isBackground'] === true)
|
|
1837
|
+
out.isBackground = true;
|
|
1838
|
+
if (meta['codebuddy.ai/isSubagent'] === true)
|
|
1839
|
+
out.isSubagent = true;
|
|
1840
|
+
const unsolicited = meta['codebuddy.ai/unsolicitedTurn'];
|
|
1841
|
+
if (typeof unsolicited === 'object' && unsolicited !== null) {
|
|
1842
|
+
const run = unsolicited;
|
|
1843
|
+
if (typeof run.requestId === 'string' && run.requestId !== '') {
|
|
1844
|
+
out.unsolicitedTurn = {
|
|
1845
|
+
requestId: run.requestId,
|
|
1846
|
+
...(typeof run.reason === 'string' && run.reason !== '' ? { reason: run.reason } : {}),
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
const phase = meta['codebuddy.ai/agentPhase'];
|
|
1851
|
+
if (typeof phase === 'object' && phase !== null) {
|
|
1852
|
+
const tick = phase;
|
|
1853
|
+
if (typeof tick.phase === 'string' && tick.phase !== '')
|
|
1854
|
+
out.agentPhase = { phase: tick.phase };
|
|
1855
|
+
}
|
|
1856
|
+
return Object.keys(out).length === 0 ? undefined : out;
|
|
1857
|
+
}
|
|
1858
|
+
/**
|
|
1859
|
+
* Remember a background drain this session stopped waiting on. A quiet close
|
|
1860
|
+
* drops the drain record (so an ordinary next turn is not held open for it),
|
|
1861
|
+
* but the run may revive afterwards; keeping its id lets every later update be
|
|
1862
|
+
* recognized as the drain's (`isRetiredAmbientId`) instead of being adopted as
|
|
1863
|
+
* the next turn's own. Bounded, oldest first — an id this far back names a run
|
|
1864
|
+
* no late update can plausibly still carry. Re-retiring an id refreshes its
|
|
1865
|
+
* timestamp and moves it to the end.
|
|
1866
|
+
*/
|
|
1867
|
+
function retireAmbientId(session, requestId) {
|
|
1868
|
+
const retired = session.retiredAmbients ?? [];
|
|
1869
|
+
const existing = retired.findIndex(entry => entry.requestId === requestId);
|
|
1870
|
+
if (existing >= 0)
|
|
1871
|
+
retired.splice(existing, 1);
|
|
1872
|
+
retired.push({ requestId, retiredAt: Date.now() });
|
|
1873
|
+
while (retired.length > RETIRED_AMBIENT_MAX)
|
|
1874
|
+
retired.shift();
|
|
1875
|
+
session.retiredAmbients = retired;
|
|
1876
|
+
}
|
|
1877
|
+
/** Whether this request id names a drain the session already stopped waiting on. */
|
|
1878
|
+
function isRetiredAmbientId(session, requestId) {
|
|
1879
|
+
return requestId !== undefined
|
|
1880
|
+
&& session.retiredAmbients?.some(entry => entry.requestId === requestId) === true;
|
|
1881
|
+
}
|
|
1882
|
+
/**
|
|
1883
|
+
* Whether the session retired a drain recently enough that a prompt answered
|
|
1884
|
+
* with no ambient record and no conversation id may have been folded into it.
|
|
1885
|
+
* A quiet close is only evidence WE stopped waiting, so the next instant
|
|
1886
|
+
* answer is ambiguous; past this horizon the drain is treated as gone and the
|
|
1887
|
+
* ordinary zero-output warning applies.
|
|
1888
|
+
*/
|
|
1889
|
+
function hasRecentRetiredAmbient(session, withinMs) {
|
|
1890
|
+
const retired = session.retiredAmbients;
|
|
1891
|
+
if (retired === undefined || retired.length === 0)
|
|
1892
|
+
return false;
|
|
1893
|
+
const cutoff = Date.now() - withinMs;
|
|
1894
|
+
return retired.some(entry => entry.retiredAt >= cutoff);
|
|
1895
|
+
}
|
|
1896
|
+
/**
|
|
1897
|
+
* The retired drain spoke again (an update or a re-announcement carried its
|
|
1898
|
+
* id): it is alive, not retired. The caller rebuilds {@link LiveSession.ambient}
|
|
1899
|
+
* so the next prompt folded into it is recognised as steered and its text can
|
|
1900
|
+
* reach the user's turn; only while the id stays retired does its text remain
|
|
1901
|
+
* transcript-only.
|
|
1902
|
+
*/
|
|
1903
|
+
function reviveAmbientId(session, requestId) {
|
|
1904
|
+
const retired = session.retiredAmbients;
|
|
1905
|
+
if (retired === undefined)
|
|
1906
|
+
return;
|
|
1907
|
+
const index = retired.findIndex(entry => entry.requestId === requestId);
|
|
1908
|
+
if (index < 0)
|
|
1909
|
+
return;
|
|
1910
|
+
retired.splice(index, 1);
|
|
1911
|
+
if (retired.length === 0)
|
|
1912
|
+
session.retiredAmbients = undefined;
|
|
1913
|
+
}
|
|
1914
|
+
/**
|
|
1915
|
+
* Forget every background-drain trace a session carried. Called at each
|
|
1916
|
+
* lifecycle boundary (close / release / resume / force reset): a fresh
|
|
1917
|
+
* agent-side state neither inherits a stale drain nor the ids of drains that
|
|
1918
|
+
* belonged to the old one.
|
|
1919
|
+
*/
|
|
1920
|
+
function clearAmbientState(session) {
|
|
1921
|
+
session.ambient = undefined;
|
|
1922
|
+
session.ambientNotices?.clear();
|
|
1923
|
+
session.retiredAmbients = undefined;
|
|
1924
|
+
}
|
|
1925
|
+
/**
|
|
1926
|
+
* The closing line of a turn the agent deferred to its OWN background run: the
|
|
1927
|
+
* answer exists, but in the ACP transcript view rather than in this bubble.
|
|
1928
|
+
* Shared by the ambient wait and the detached-worker hold, so both say the
|
|
1929
|
+
* same thing in the same words.
|
|
1930
|
+
*/
|
|
1931
|
+
function backgroundDeferralLine(agentId) {
|
|
1932
|
+
return `\n[dsh-ntr] agent "${agentId}" deferred this turn to a background task run; its output is in the ACP transcript view. Send again after the background run finishes.\n`;
|
|
1933
|
+
}
|
|
1665
1934
|
/** Human-facing identity of an ACP tool call (the approval headline). */
|
|
1666
1935
|
/**
|
|
1667
1936
|
* Human-facing name of the tool an ask is about, for the chat line and the
|
|
@@ -1753,6 +2022,7 @@ function snapshot(session) {
|
|
|
1753
2022
|
...(session.availableCommands !== undefined ? { availableCommands: session.availableCommands } : {}),
|
|
1754
2023
|
...(session.plan !== undefined ? { plan: session.plan } : {}),
|
|
1755
2024
|
...(session.usage !== undefined ? { usage: session.usage } : {}),
|
|
2025
|
+
...(session.burst !== undefined ? { burst: session.burst } : {}),
|
|
1756
2026
|
running: session.running,
|
|
1757
2027
|
closed: session.closed,
|
|
1758
2028
|
...(session.released ? { released: true } : {}),
|
|
@@ -1789,6 +2059,7 @@ function persistedChatSession(session) {
|
|
|
1789
2059
|
...(session.reasoningEffort === undefined ? {} : { reasoningEffort: session.reasoningEffort }),
|
|
1790
2060
|
...(session.modes === undefined ? {} : { modes: session.modes }),
|
|
1791
2061
|
...(session.usage === undefined ? {} : { usage: session.usage }),
|
|
2062
|
+
...(session.burst === undefined ? {} : { burst: session.burst }),
|
|
1792
2063
|
...(session.hasHistory === true || session.transcript.since(0).length > 0 ? { hasHistory: true } : {}),
|
|
1793
2064
|
};
|
|
1794
2065
|
}
|
|
@@ -1806,6 +2077,14 @@ function isPersistedUsage(value) {
|
|
|
1806
2077
|
const usage = value;
|
|
1807
2078
|
return typeof usage.used === 'number' && typeof usage.size === 'number';
|
|
1808
2079
|
}
|
|
2080
|
+
/** Tolerant shape check for a `burst` aggregate read back from disk. */
|
|
2081
|
+
function isPersistedBurst(value) {
|
|
2082
|
+
if (value === null || typeof value !== 'object')
|
|
2083
|
+
return false;
|
|
2084
|
+
const burst = value;
|
|
2085
|
+
return typeof burst.outputTokens === 'number' && burst.outputTokens >= 0
|
|
2086
|
+
&& typeof burst.burstMs === 'number' && burst.burstMs >= 0;
|
|
2087
|
+
}
|
|
1809
2088
|
/**
|
|
1810
2089
|
* The DSH chat session's OWN workspace, when one is resolvable: the directory
|
|
1811
2090
|
* the user selected in the DSH sidebar, which the host reports back as the
|
|
@@ -2484,8 +2763,40 @@ export function releaseAllToolOutputWaiters() {
|
|
|
2484
2763
|
for (const waiter of [...set])
|
|
2485
2764
|
waiter(undefined);
|
|
2486
2765
|
}
|
|
2487
|
-
/**
|
|
2488
|
-
const
|
|
2766
|
+
/** How often a running turn's live burst aggregate is pushed to the browser. */
|
|
2767
|
+
const TPS_PUSH_INTERVAL_MS = 1000;
|
|
2768
|
+
/**
|
|
2769
|
+
* Bounds on the updates withheld while a turn's own request id is still
|
|
2770
|
+
* unknown (see `TurnStreamController.bufferAmbientUpdate`). The buffer exists
|
|
2771
|
+
* only for the response window of one prompt, but a chatty drain could stream
|
|
2772
|
+
* for a while inside it; over the bound the OLDEST entries are dropped — the
|
|
2773
|
+
* transcript still has every one of them.
|
|
2774
|
+
*/
|
|
2775
|
+
const PENDING_AMBIENT_MAX_ENTRIES = 200;
|
|
2776
|
+
const PENDING_AMBIENT_MAX_CHARS = 64 * 1024;
|
|
2777
|
+
/**
|
|
2778
|
+
* Bound on {@link LiveSession.ambientNotices}. The set now outlives the turn
|
|
2779
|
+
* that announced a drain (a hard ceiling keeps the record, and a repeated
|
|
2780
|
+
* announcement of the same run must stay silent), so it needs a ceiling of its
|
|
2781
|
+
* own for a chatty agent. Oldest ids are evicted first; an id that far back is
|
|
2782
|
+
* a run nobody is waiting on anymore.
|
|
2783
|
+
*/
|
|
2784
|
+
const AMBIENT_NOTICE_MAX = 64;
|
|
2785
|
+
/**
|
|
2786
|
+
* Bound on {@link LiveSession.retiredAmbients}. The ids only exist to catch a
|
|
2787
|
+
* drain that speaks again after its wait closed, and the timestamp lets the
|
|
2788
|
+
* next silent close recognise a recent fold — a short-horizon concern, so a
|
|
2789
|
+
* small ring is enough; the oldest is evicted first.
|
|
2790
|
+
*/
|
|
2791
|
+
const RETIRED_AMBIENT_MAX = 32;
|
|
2792
|
+
/**
|
|
2793
|
+
* Floor for the "recently retired" lookback the zero-output fallback uses
|
|
2794
|
+
* (twice `ambientQuietMs`). Only test-sized quiet windows fall below it; a
|
|
2795
|
+
* production window (10s) always wins. Without the floor a 250ms quiet window
|
|
2796
|
+
* would leave a 500ms lookback, too tight to survive a loaded CI scheduler
|
|
2797
|
+
* between one turn ending and the next prompt's response.
|
|
2798
|
+
*/
|
|
2799
|
+
const RETIRED_AMBIENT_RECENT_MIN_MS = 1_000;
|
|
2489
2800
|
/**
|
|
2490
2801
|
* The state of ONE ACP prompt, shared across every driver step of the turn.
|
|
2491
2802
|
*
|
|
@@ -2505,13 +2816,111 @@ class TurnStreamController {
|
|
|
2505
2816
|
rollbackFirstTurn;
|
|
2506
2817
|
segment;
|
|
2507
2818
|
settled = false;
|
|
2819
|
+
/** Whether the turn's closing work already ran (see `closeTurn`). */
|
|
2820
|
+
closedTurn = false;
|
|
2508
2821
|
disposed = false;
|
|
2509
2822
|
seenCalls = new Set();
|
|
2510
2823
|
announcedKinds = new Map();
|
|
2511
2824
|
/** Narrative lines (permission asks) that arrived while no segment was open. */
|
|
2512
2825
|
pendingProse = [];
|
|
2513
2826
|
sawVisibleBlock = false;
|
|
2827
|
+
/**
|
|
2828
|
+
* Whether THIS turn streamed its own prose (text or reasoning), i.e. an
|
|
2829
|
+
* answer that is not the background drain's. Tool cards do not count — a
|
|
2830
|
+
* drain that only ran tools delivered no words to the conversation.
|
|
2831
|
+
*/
|
|
2832
|
+
sawAgentProse = false;
|
|
2833
|
+
/**
|
|
2834
|
+
* Whether any `agent_message_chunk` text reached the plane. Reasoning does
|
|
2835
|
+
* not count: a steer whose drain only thought is still a turn with no answer
|
|
2836
|
+
* text, and must say so when it closes.
|
|
2837
|
+
*/
|
|
2838
|
+
sawVisibleText = false;
|
|
2839
|
+
/**
|
|
2840
|
+
* The CodeBuddy request id this turn has adopted as its own (from the
|
|
2841
|
+
* prompt RESPONSE's `conversationRequestId`, or from the first update that
|
|
2842
|
+
* named one). Used to keep a background drain's prose out of the user's
|
|
2843
|
+
* answer; `undefined` means "unknown" and nothing is split (fail-open).
|
|
2844
|
+
*/
|
|
2845
|
+
turnRequestId;
|
|
2846
|
+
/**
|
|
2847
|
+
* Updates the session's background drain already streamed while this turn's
|
|
2848
|
+
* OWN request id was still unknown. They are NOT folded on arrival: a drain
|
|
2849
|
+
* tick that lands before the prompt response could otherwise teach the turn
|
|
2850
|
+
* the drain's id (`noteTurnRequestId`) and mix the drain's text into the
|
|
2851
|
+
* user's answer. Withheld here (cursor already advanced — the transcript
|
|
2852
|
+
* keeps a copy) until the response decides what this turn is:
|
|
2853
|
+
* - a normal turn (response carries a conversation id) discards them — they
|
|
2854
|
+
* were never this turn's answer;
|
|
2855
|
+
* - a steered turn flushes them into the segment, in order, once
|
|
2856
|
+
* `awaitAmbient` marks the segment ambient (tool cards suppressed).
|
|
2857
|
+
*/
|
|
2858
|
+
pendingAmbientUpdates = [];
|
|
2859
|
+
pendingAmbientChars = 0;
|
|
2860
|
+
/**
|
|
2861
|
+
* Whether the bounded buffer dropped at least one update. The flush then
|
|
2862
|
+
* says so in the conversation instead of silently losing the front of the
|
|
2863
|
+
* drain's output (constraint 29: ACP payloads are never dropped silently).
|
|
2864
|
+
*/
|
|
2865
|
+
pendingAmbientTruncated = false;
|
|
2866
|
+
/** The ambient wait in progress, if the agent folded this prompt into a drain. */
|
|
2867
|
+
awaitingAmbient;
|
|
2868
|
+
/**
|
|
2869
|
+
* The prompt response named no conversation turn while a drain this session
|
|
2870
|
+
* retired RECENTLY may still be running without re-announcing. The turn still
|
|
2871
|
+
* settles at once (a drain record that no update revived is not waited on),
|
|
2872
|
+
* but its zero-output report says the turn may have been folded into that run
|
|
2873
|
+
* instead of blaming the agent and suggesting a resend.
|
|
2874
|
+
*/
|
|
2875
|
+
recentRetiredDrain = false;
|
|
2876
|
+
/** The outcome of a settle whose closing work is waiting one macrotask (see `settle`). */
|
|
2877
|
+
pendingOutcome;
|
|
2878
|
+
closeScheduled = false;
|
|
2514
2879
|
cursorSeq;
|
|
2880
|
+
/**
|
|
2881
|
+
* Burst tracker for this turn, fed from the LIVE hub frames only. Segment
|
|
2882
|
+
* replay must not touch it: every event is already counted once when it
|
|
2883
|
+
* arrived, and replay would double the numerator and the gaps.
|
|
2884
|
+
*/
|
|
2885
|
+
tps = new TurnTpsTracker();
|
|
2886
|
+
tpsSeq;
|
|
2887
|
+
lastTpsPushAt = 0;
|
|
2888
|
+
/**
|
|
2889
|
+
* The ambient quiet/ceiling windows, read once from the manager: they are
|
|
2890
|
+
* deployment tuning, not per-tick facts, and small test values must be stable
|
|
2891
|
+
* for the whole turn (see {@link BridgeTuning.ambientQuietMs} for the
|
|
2892
|
+
* measured CodeBuddy tick cadence these defaults come from).
|
|
2893
|
+
*/
|
|
2894
|
+
ambientTuning;
|
|
2895
|
+
/**
|
|
2896
|
+
* The detached workers this turn dispatched (see {@link BackgroundDispatch}),
|
|
2897
|
+
* by `toolCallId`, recorded from the wire while the turn is still streaming —
|
|
2898
|
+
* typically in the first step, before the prompt response lands. A turn may
|
|
2899
|
+
* dispatch several, and the hold waits for ALL of them.
|
|
2900
|
+
*/
|
|
2901
|
+
dispatches = new Map();
|
|
2902
|
+
/** The detached-worker hold in progress, once a segment carries it. */
|
|
2903
|
+
backgroundHold;
|
|
2904
|
+
/**
|
|
2905
|
+
* A hold parked because the prompt response landed while NO driver segment
|
|
2906
|
+
* was open (the host is between steps, about to execute the Agent card). It
|
|
2907
|
+
* begins when the next `startSegment` opens — or closes after a bounded
|
|
2908
|
+
* grace so a host that never opens one does not hang the turn.
|
|
2909
|
+
*/
|
|
2910
|
+
pendingBackgroundHold;
|
|
2911
|
+
/**
|
|
2912
|
+
* The hold's own windows, read once at construction like the ambient pair:
|
|
2913
|
+
* `taskMaxMs` is the worker phase's ceiling, `graceMs` the parked hold's
|
|
2914
|
+
* bounded wait for a driver step (see {@link BridgeTuning.backgroundTaskMaxMs}
|
|
2915
|
+
* and {@link BridgeTuning.backgroundParkedGraceMs}).
|
|
2916
|
+
*/
|
|
2917
|
+
backgroundTuning;
|
|
2918
|
+
/**
|
|
2919
|
+
* When this controller was built. Only a drain announced AFTER this moment
|
|
2920
|
+
* may be adopted by the hold: an `ambient` record that predates the turn is
|
|
2921
|
+
* some earlier run's, and folding its text would mix two answers.
|
|
2922
|
+
*/
|
|
2923
|
+
constructedAt;
|
|
2515
2924
|
constructor(manager, hub, session, turnId,
|
|
2516
2925
|
/** Releases the agent-side turn slot (the `beginTurn` settler; idempotent). */
|
|
2517
2926
|
endTurn, rollbackFirstTurn) {
|
|
@@ -2523,6 +2932,10 @@ class TurnStreamController {
|
|
|
2523
2932
|
this.rollbackFirstTurn = rollbackFirstTurn;
|
|
2524
2933
|
this.turnSeqAtStart = session.turnSeq;
|
|
2525
2934
|
this.cursorSeq = session.transcript.head;
|
|
2935
|
+
this.tpsSeq = session.transcript.head;
|
|
2936
|
+
this.constructedAt = Date.now();
|
|
2937
|
+
this.ambientTuning = manager.ambientTuning;
|
|
2938
|
+
this.backgroundTuning = manager.backgroundTuning;
|
|
2526
2939
|
this.unsubscribe = hub.subscribe(event => this.onEvent(event));
|
|
2527
2940
|
}
|
|
2528
2941
|
unsubscribe;
|
|
@@ -2552,6 +2965,12 @@ class TurnStreamController {
|
|
|
2552
2965
|
* consuming it, so the next segment replays it again.
|
|
2553
2966
|
*/
|
|
2554
2967
|
startSegment() {
|
|
2968
|
+
// A step opening on an already settled turn (the response landed a tick
|
|
2969
|
+
// ago and the tail drain is still scheduled) closes that turn FIRST: the
|
|
2970
|
+
// step must read a settled controller and the session must not stay
|
|
2971
|
+
// `running` across the macrotask boundary — the host's next prompt would be
|
|
2972
|
+
// refused as busy for no reason.
|
|
2973
|
+
this.flushScheduledClose();
|
|
2555
2974
|
// A step opening is the HOST handing the turn back to the agent — this
|
|
2556
2975
|
// runs for the first step and for each continuation after the host
|
|
2557
2976
|
// executed a card. The watchdog measures the stretch the AGENT holds the
|
|
@@ -2567,6 +2986,15 @@ class TurnStreamController {
|
|
|
2567
2986
|
outputChars: 0,
|
|
2568
2987
|
activeToolCall: undefined,
|
|
2569
2988
|
hasCard: false,
|
|
2989
|
+
// A segment opened DURING an ambient wait (never the steered first step,
|
|
2990
|
+
// which marks its own) inherits the suppression: the drain's tools still
|
|
2991
|
+
// stay transcript-only until the turn closes. A detached-worker hold
|
|
2992
|
+
// suppresses its cards for the same reason, and a PARKED hold must
|
|
2993
|
+
// suppress from the first replayed update on — the drain's TaskOutput
|
|
2994
|
+
// arrives in exactly that step.
|
|
2995
|
+
ambient: this.awaitingAmbient !== undefined
|
|
2996
|
+
|| this.backgroundHold !== undefined
|
|
2997
|
+
|| this.pendingBackgroundHold !== undefined,
|
|
2570
2998
|
finished: false,
|
|
2571
2999
|
};
|
|
2572
3000
|
this.segment = seg;
|
|
@@ -2574,7 +3002,16 @@ class TurnStreamController {
|
|
|
2574
3002
|
for (const entry of entries) {
|
|
2575
3003
|
if (seg.finished)
|
|
2576
3004
|
break;
|
|
2577
|
-
|
|
3005
|
+
const update = entry.update;
|
|
3006
|
+
// The same split the live path makes: a background drain's prose is not
|
|
3007
|
+
// this turn's answer, even when it landed while no step was open. The
|
|
3008
|
+
// record (tool outputs) still goes in; the cursor moves past it.
|
|
3009
|
+
if (this.isForeignAmbientText(update)) {
|
|
3010
|
+
this.recordWithoutSegment(update);
|
|
3011
|
+
this.cursorSeq = entry.seq;
|
|
3012
|
+
continue;
|
|
3013
|
+
}
|
|
3014
|
+
if (!this.foldInto(seg, update, entry.seq))
|
|
2578
3015
|
break;
|
|
2579
3016
|
this.cursorSeq = entry.seq;
|
|
2580
3017
|
}
|
|
@@ -2584,6 +3021,12 @@ class TurnStreamController {
|
|
|
2584
3021
|
const lines = this.pendingProse.splice(0, this.pendingProse.length).join('');
|
|
2585
3022
|
this.emitProse(seg, 'reasoning', lines);
|
|
2586
3023
|
}
|
|
3024
|
+
const parkedHold = this.pendingBackgroundHold;
|
|
3025
|
+
if (parkedHold !== undefined && !seg.finished) {
|
|
3026
|
+
// The host opened the continuation step for the Agent card: the hold
|
|
3027
|
+
// finally has a driver step to speak the drain's answer into.
|
|
3028
|
+
this.adoptBackgroundHold(seg, parkedHold);
|
|
3029
|
+
}
|
|
2587
3030
|
if (this.settled)
|
|
2588
3031
|
this.finishAfterSettle(seg);
|
|
2589
3032
|
return seg;
|
|
@@ -2593,11 +3036,770 @@ class TurnStreamController {
|
|
|
2593
3036
|
if (this.segment !== undefined)
|
|
2594
3037
|
this.finishSegment(this.segment, reason);
|
|
2595
3038
|
}
|
|
3039
|
+
/** Whether this turn already streamed prose of its own (not a drain's). */
|
|
3040
|
+
get hasAgentProse() {
|
|
3041
|
+
return this.sawAgentProse;
|
|
3042
|
+
}
|
|
3043
|
+
/**
|
|
3044
|
+
* Keep the turn open for a background drain the agent folded our prompt into.
|
|
3045
|
+
*
|
|
3046
|
+
* THE SHAPE: the agent answered `session/prompt` at once (CodeBuddy returns
|
|
3047
|
+
* `end_turn` in ~14ms when it merges the message into a drain it is already
|
|
3048
|
+
* running) and the real answer streams afterwards under the DRAIN's request
|
|
3049
|
+
* id. Closing on the response would cut the turn before its text exists —
|
|
3050
|
+
* which is exactly the bug this state exists for. So:
|
|
3051
|
+
*
|
|
3052
|
+
* - the agent-side prompt slot is released (`endTurn`: the response HAS
|
|
3053
|
+
* landed) but the turn is NOT settled: `settled` means "the turn is over",
|
|
3054
|
+
* and it is not — no `turn/end`, no `running = false`, no segment close;
|
|
3055
|
+
* - the segment keeps folding the session's updates, with cards suppressed
|
|
3056
|
+
* (see `SegmentState.ambient`): the host must not execute a background
|
|
3057
|
+
* tool for a turn whose answer has not begun;
|
|
3058
|
+
* - any update on the session resets the quiet clock, and the drain's end is
|
|
3059
|
+
* its silence (a `session_end` is rejected by the SDK's schema and never
|
|
3060
|
+
* reaches us — never wait for one); a hard ceiling closes what never ends.
|
|
3061
|
+
*/
|
|
3062
|
+
awaitAmbient(requestId, stopReason) {
|
|
3063
|
+
if (this.settled || this.disposed || this.awaitingAmbient !== undefined)
|
|
3064
|
+
return;
|
|
3065
|
+
const seg = this.segment;
|
|
3066
|
+
if (seg === undefined || seg.finished) {
|
|
3067
|
+
// No live driver step to speak into: nothing can be folded, so close the
|
|
3068
|
+
// turn the ordinary way rather than holding state that renders nothing.
|
|
3069
|
+
this.clearPendingAmbient();
|
|
3070
|
+
this.settle({ kind: 'stop', stopReason });
|
|
3071
|
+
return;
|
|
3072
|
+
}
|
|
3073
|
+
seg.ambient = true;
|
|
3074
|
+
// The response landed: the agent no longer holds a prompt for this turn.
|
|
3075
|
+
this.endTurn();
|
|
3076
|
+
// The drain may have spoken BEFORE the response was judged. Those updates
|
|
3077
|
+
// were withheld while this turn's own request id was unknowable (see
|
|
3078
|
+
// `pendingAmbientUpdates`); the turn IS the drain, so they are its answer —
|
|
3079
|
+
// folded in arrival order, now that `seg.ambient` suppresses their cards.
|
|
3080
|
+
this.flushPendingAmbient(seg);
|
|
3081
|
+
if (seg.finished) {
|
|
3082
|
+
this.settle({ kind: 'stop', stopReason });
|
|
3083
|
+
return;
|
|
3084
|
+
}
|
|
3085
|
+
const wait = {
|
|
3086
|
+
requestId,
|
|
3087
|
+
quietTimer: setTimeout(() => { this.onAmbientQuiet(wait); }, this.ambientTuning.quietMs),
|
|
3088
|
+
hardTimer: setTimeout(() => { this.onAmbientHard(wait); }, this.ambientTuning.maxMs),
|
|
3089
|
+
stopReason,
|
|
3090
|
+
};
|
|
3091
|
+
wait.quietTimer.unref?.();
|
|
3092
|
+
wait.hardTimer.unref?.();
|
|
3093
|
+
this.awaitingAmbient = wait;
|
|
3094
|
+
}
|
|
3095
|
+
/**
|
|
3096
|
+
* The prompt response named no conversation turn while a drain the session
|
|
3097
|
+
* retired recently may still be running (and did not re-announce): the turn
|
|
3098
|
+
* cannot be held open, but a zero-output report must not blame the agent.
|
|
3099
|
+
* See `hasRecentRetiredAmbient` for the window.
|
|
3100
|
+
*/
|
|
3101
|
+
noteRecentRetiredDrain() {
|
|
3102
|
+
this.recentRetiredDrain = true;
|
|
3103
|
+
}
|
|
3104
|
+
/**
|
|
3105
|
+
* Whether this turn dispatched a detached worker whose answer is still
|
|
3106
|
+
* outstanding. Read when the prompt response lands: such a turn is NOT over
|
|
3107
|
+
* even though the agent answered it (see {@link holdForBackgroundDispatch}).
|
|
3108
|
+
*
|
|
3109
|
+
* Two ways a dispatch keeps the turn open:
|
|
3110
|
+
* - it has not reported back yet (the worker is still running), or
|
|
3111
|
+
* - it DID report — before the response, even — but it was structurally
|
|
3112
|
+
* detached and emitted worker updates: the drain that carries its report
|
|
3113
|
+
* is still coming.
|
|
3114
|
+
* A synchronous `Agent` / `Task` call (completed, no `isBackground`, no
|
|
3115
|
+
* worker updates) matches neither and closes normally.
|
|
3116
|
+
*/
|
|
3117
|
+
get dispatchedDetachedWorker() {
|
|
3118
|
+
for (const dispatch of this.dispatches.values()) {
|
|
3119
|
+
if (!dispatch.completed)
|
|
3120
|
+
return true;
|
|
3121
|
+
if (dispatch.detached && dispatch.sawWorkerUpdate)
|
|
3122
|
+
return true;
|
|
3123
|
+
}
|
|
3124
|
+
return false;
|
|
3125
|
+
}
|
|
3126
|
+
/** The hold this turn is inside — adopted, or parked waiting for a step. */
|
|
3127
|
+
activeBackgroundHold() {
|
|
3128
|
+
return this.backgroundHold ?? this.pendingBackgroundHold;
|
|
3129
|
+
}
|
|
3130
|
+
/** Whether every KNOWN dispatch has reported (no dispatch at all counts as done). */
|
|
3131
|
+
allDispatchesCompleted() {
|
|
3132
|
+
for (const dispatch of this.dispatches.values()) {
|
|
3133
|
+
if (!dispatch.completed)
|
|
3134
|
+
return false;
|
|
3135
|
+
}
|
|
3136
|
+
return true;
|
|
3137
|
+
}
|
|
3138
|
+
/**
|
|
3139
|
+
* Hold the turn open for the detached worker(s) its `Agent` tool calls
|
|
3140
|
+
* dispatched — the second controlled ambient-family exception (constraint
|
|
3141
|
+
* 23), not a new idle watchdog.
|
|
3142
|
+
*
|
|
3143
|
+
* THE SHAPE (measured against CodeBuddy 2.179.0, multitask on): the
|
|
3144
|
+
* coordinator opens an `Agent` call that hands work to a background worker,
|
|
3145
|
+
* says "已派…", and the `session/prompt` RESPONSE arrives normally while the
|
|
3146
|
+
* worker keeps running. The worker's own updates (requestId B +
|
|
3147
|
+
* `parentToolCallId`) never touch this session's message; when it finishes,
|
|
3148
|
+
* the agent starts a background DRAIN (requestId A — the response's own
|
|
3149
|
+
* `conversationRequestId`) whose steps carry the report and the final answer.
|
|
3150
|
+
* Nothing tells the client the worker is running: no `session_end` (the SDK's
|
|
3151
|
+
* schema rejects it) and an `agentPhase` idle tick even fires BETWEEN the
|
|
3152
|
+
* worker and the drain. So the response cannot close the turn.
|
|
3153
|
+
*
|
|
3154
|
+
* The hold:
|
|
3155
|
+
* - releases the agent-side prompt slot (`endTurn`: the response landed)
|
|
3156
|
+
* while keeping `running` and the driver step open;
|
|
3157
|
+
* - marks the segment background, so EVERY tool call for the rest of the
|
|
3158
|
+
* hold — the workers', the drain's TaskOutput — stays transcript-only and
|
|
3159
|
+
* the final message carries text with no card in front of it;
|
|
3160
|
+
* - waits in two phases: the worker phase only has the
|
|
3161
|
+
* {@link BridgeTuning.backgroundTaskMaxMs} ceiling (a detached worker goes
|
|
3162
|
+
* silent for tens of seconds while it works) and ends only when ALL known
|
|
3163
|
+
* dispatches reported, after which the drain phase uses the ambient
|
|
3164
|
+
* windows.
|
|
3165
|
+
*/
|
|
3166
|
+
holdForBackgroundDispatch(stopReason, requestId) {
|
|
3167
|
+
if (this.settled || this.disposed || this.closedTurn || this.stale)
|
|
3168
|
+
return;
|
|
3169
|
+
if (this.activeBackgroundHold() !== undefined)
|
|
3170
|
+
return;
|
|
3171
|
+
const hold = {
|
|
3172
|
+
stopReason,
|
|
3173
|
+
requestId,
|
|
3174
|
+
acceptedIds: new Set(),
|
|
3175
|
+
phase: 'worker',
|
|
3176
|
+
answerChars: 0,
|
|
3177
|
+
floorSeq: this.session.transcript.head,
|
|
3178
|
+
retired: false,
|
|
3179
|
+
};
|
|
3180
|
+
if (requestId !== undefined)
|
|
3181
|
+
hold.acceptedIds.add(requestId);
|
|
3182
|
+
// A drain announced while this turn was ALREADY on air belongs to it (the
|
|
3183
|
+
// announcement may have named a different id than the response). One that
|
|
3184
|
+
// predates the turn is some earlier run's, and folding its text would mix
|
|
3185
|
+
// two answers.
|
|
3186
|
+
const announced = this.session.ambient;
|
|
3187
|
+
if (announced !== undefined && announced.startedAt >= this.constructedAt) {
|
|
3188
|
+
hold.acceptedIds.add(announced.requestId);
|
|
3189
|
+
if (hold.requestId === undefined)
|
|
3190
|
+
hold.requestId = announced.requestId;
|
|
3191
|
+
}
|
|
3192
|
+
// The response landed: the agent no longer holds a prompt for this turn.
|
|
3193
|
+
this.endTurn();
|
|
3194
|
+
hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold); }, this.backgroundTuning.taskMaxMs);
|
|
3195
|
+
hold.hardTimer.unref?.();
|
|
3196
|
+
const seg = this.segment;
|
|
3197
|
+
if (seg !== undefined && !seg.finished) {
|
|
3198
|
+
this.adoptBackgroundHold(seg, hold);
|
|
3199
|
+
}
|
|
3200
|
+
else {
|
|
3201
|
+
// The response landed BETWEEN driver steps: the host is about to execute
|
|
3202
|
+
// the Agent card and open its continuation step. Park the hold for that
|
|
3203
|
+
// step, with a bounded wait — a host that never opens one must not hang
|
|
3204
|
+
// the turn forever.
|
|
3205
|
+
hold.graceTimer = setTimeout(() => { this.onBackgroundGraceExpired(hold); }, this.backgroundTuning.graceMs);
|
|
3206
|
+
hold.graceTimer.unref?.();
|
|
3207
|
+
this.pendingBackgroundHold = hold;
|
|
3208
|
+
}
|
|
3209
|
+
// A FAST detached worker may already have reported (even before the
|
|
3210
|
+
// response): with every known dispatch complete there is no worker phase
|
|
3211
|
+
// left to wait out, so the drain phase starts now.
|
|
3212
|
+
if (this.allDispatchesCompleted())
|
|
3213
|
+
this.startBackgroundDrain(hold);
|
|
3214
|
+
}
|
|
3215
|
+
/** Give a (possibly parked) hold a driver segment: cards off for the rest of it. */
|
|
3216
|
+
adoptBackgroundHold(seg, hold) {
|
|
3217
|
+
if (hold.graceTimer !== undefined) {
|
|
3218
|
+
clearTimeout(hold.graceTimer);
|
|
3219
|
+
hold.graceTimer = undefined;
|
|
3220
|
+
}
|
|
3221
|
+
if (this.pendingBackgroundHold === hold)
|
|
3222
|
+
this.pendingBackgroundHold = undefined;
|
|
3223
|
+
seg.ambient = true;
|
|
3224
|
+
this.backgroundHold = hold;
|
|
3225
|
+
// The drain may have spoken BEFORE the response was judged. Those updates
|
|
3226
|
+
// were withheld while this turn's own id was unknown
|
|
3227
|
+
// (`pendingAmbientUpdates`); only the runs THIS hold owns are its answer —
|
|
3228
|
+
// another run's withheld text is dropped (the transcript keeps it).
|
|
3229
|
+
this.flushBackgroundHoldUpdates(seg, hold);
|
|
3230
|
+
// The parked grace owns the wait while no step is on air; now that one is,
|
|
3231
|
+
// a hold whose drain phase already began arms its quiet clock.
|
|
3232
|
+
if (hold.phase === 'drain')
|
|
3233
|
+
this.armBackgroundQuiet(hold);
|
|
3234
|
+
}
|
|
3235
|
+
/**
|
|
3236
|
+
* The worker phase is over once EVERY known dispatch reported: the drain
|
|
3237
|
+
* that carries their reports is (about to be) running, so the existing
|
|
3238
|
+
* ambient windows take over, measured from HERE.
|
|
3239
|
+
*
|
|
3240
|
+
* An announced run is accepted immediately, but while any dispatch is still
|
|
3241
|
+
* incomplete it only records the id and switches the hold's primary run —
|
|
3242
|
+
* arming the quiet clock there would let a drain that spoke early cut the
|
|
3243
|
+
* turn off before its other worker ever reports.
|
|
3244
|
+
*/
|
|
3245
|
+
startBackgroundDrain(hold, requestId) {
|
|
3246
|
+
if (hold !== this.backgroundHold && hold !== this.pendingBackgroundHold)
|
|
3247
|
+
return;
|
|
3248
|
+
if (requestId !== undefined) {
|
|
3249
|
+
hold.acceptedIds.add(requestId);
|
|
3250
|
+
hold.requestId = requestId;
|
|
3251
|
+
}
|
|
3252
|
+
if (!this.allDispatchesCompleted())
|
|
3253
|
+
return;
|
|
3254
|
+
if (hold.phase === 'drain') {
|
|
3255
|
+
if (this.backgroundHold === hold)
|
|
3256
|
+
this.armBackgroundQuiet(hold);
|
|
3257
|
+
return;
|
|
3258
|
+
}
|
|
3259
|
+
hold.phase = 'drain';
|
|
3260
|
+
if (hold.hardTimer !== undefined)
|
|
3261
|
+
clearTimeout(hold.hardTimer);
|
|
3262
|
+
hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold); }, this.ambientTuning.maxMs);
|
|
3263
|
+
hold.hardTimer.unref?.();
|
|
3264
|
+
// A parked hold belongs to the grace until a driver step opens; a hold
|
|
3265
|
+
// with a step arms its quiet clock now.
|
|
3266
|
+
if (this.backgroundHold === hold)
|
|
3267
|
+
this.armBackgroundQuiet(hold);
|
|
3268
|
+
}
|
|
3269
|
+
/**
|
|
3270
|
+
* A dispatch that lands while the drain phase already began means the
|
|
3271
|
+
* coordinator handed out NEW work: the worker phase is back, and with it the
|
|
3272
|
+
* absence of a quiet window.
|
|
3273
|
+
*/
|
|
3274
|
+
enterBackgroundWorkerPhase(hold) {
|
|
3275
|
+
if (hold.phase === 'worker')
|
|
3276
|
+
return;
|
|
3277
|
+
hold.phase = 'worker';
|
|
3278
|
+
if (hold.quietTimer !== undefined) {
|
|
3279
|
+
clearTimeout(hold.quietTimer);
|
|
3280
|
+
hold.quietTimer = undefined;
|
|
3281
|
+
}
|
|
3282
|
+
if (hold.hardTimer !== undefined)
|
|
3283
|
+
clearTimeout(hold.hardTimer);
|
|
3284
|
+
hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold); }, this.backgroundTuning.taskMaxMs);
|
|
3285
|
+
hold.hardTimer.unref?.();
|
|
3286
|
+
}
|
|
3287
|
+
/** Push the drain phase's quiet clock out: every update is evidence of work. */
|
|
3288
|
+
armBackgroundQuiet(hold) {
|
|
3289
|
+
if (hold.quietTimer !== undefined)
|
|
3290
|
+
clearTimeout(hold.quietTimer);
|
|
3291
|
+
hold.quietTimer = setTimeout(() => { this.onBackgroundQuiet(hold); }, this.ambientTuning.quietMs);
|
|
3292
|
+
hold.quietTimer.unref?.();
|
|
3293
|
+
}
|
|
3294
|
+
/**
|
|
3295
|
+
* Any session update restarts the drain phase's quiet clock. Only an
|
|
3296
|
+
* ADOPTED hold has one: a parked hold's wait belongs to its grace timer, and
|
|
3297
|
+
* the worker phase never has a clock at all.
|
|
3298
|
+
*/
|
|
3299
|
+
noteBackgroundHoldActivity() {
|
|
3300
|
+
const hold = this.backgroundHold;
|
|
3301
|
+
if (hold === undefined || hold.phase !== 'drain')
|
|
3302
|
+
return;
|
|
3303
|
+
this.armBackgroundQuiet(hold);
|
|
3304
|
+
}
|
|
3305
|
+
/** The drained run went quiet: the turn ends with the stop reason the response gave. */
|
|
3306
|
+
onBackgroundQuiet(hold) {
|
|
3307
|
+
if (this.backgroundHold !== hold || hold.phase !== 'drain')
|
|
3308
|
+
return;
|
|
3309
|
+
this.closeBackgroundHold(hold, { hard: false });
|
|
3310
|
+
}
|
|
3311
|
+
/** The hold outlived its ceiling (worker or drain): the run is still going. */
|
|
3312
|
+
onBackgroundHard(hold) {
|
|
3313
|
+
if (hold !== this.backgroundHold && hold !== this.pendingBackgroundHold)
|
|
3314
|
+
return;
|
|
3315
|
+
this.closeBackgroundHold(hold, { hard: true });
|
|
3316
|
+
}
|
|
3317
|
+
/**
|
|
3318
|
+
* No driver step opened inside the grace: the host is not coming back for
|
|
3319
|
+
* this turn's Agent card. Close it the way a quiet ambient ending does — the
|
|
3320
|
+
* deferral line, never the zero-output blame — and keep ONE drain record:
|
|
3321
|
+
* the worker may still report into a run no turn is waiting on.
|
|
3322
|
+
*
|
|
3323
|
+
* A parked hold has NO segment by definition, so the chat plane has nowhere
|
|
3324
|
+
* to put the deferral line: it goes to the settings log (`agent/notice`)
|
|
3325
|
+
* instead of being parked on prose nobody will read.
|
|
3326
|
+
*/
|
|
3327
|
+
onBackgroundGraceExpired(hold) {
|
|
3328
|
+
if (this.pendingBackgroundHold !== hold)
|
|
3329
|
+
return;
|
|
3330
|
+
this.pendingBackgroundHold = undefined;
|
|
3331
|
+
this.clearBackgroundHoldTimers(hold);
|
|
3332
|
+
const line = hold.answerChars > 0 ? undefined : backgroundDeferralLine(this.session.agentId);
|
|
3333
|
+
this.releaseBackgroundHoldIds(hold, { hard: true });
|
|
3334
|
+
if (line !== undefined)
|
|
3335
|
+
this.broadcastBackgroundDeferral(line);
|
|
3336
|
+
this.closeTurn({ kind: 'stop', stopReason: hold.stopReason }, { line, keepAmbient: true, requestId: hold.requestId });
|
|
3337
|
+
}
|
|
3338
|
+
/**
|
|
3339
|
+
* Close a detached-worker hold exactly once. A turn that folded NO answer
|
|
3340
|
+
* text from its held runs says where the rest of the output will land (the
|
|
3341
|
+
* deferral wording, never the silent-failure blame); the parent's interim
|
|
3342
|
+
* acknowledgement does not count as that answer.
|
|
3343
|
+
*
|
|
3344
|
+
* Ownership of the drain ids at close (`releaseBackgroundHoldIds`): a quiet
|
|
3345
|
+
* close retires every accepted id and drops the session record; a hard or
|
|
3346
|
+
* grace close keeps exactly ONE record, because the run is provably still
|
|
3347
|
+
* going and a later update or re-announcement must revive it normally.
|
|
3348
|
+
*/
|
|
3349
|
+
closeBackgroundHold(hold, options) {
|
|
3350
|
+
if (this.pendingBackgroundHold === hold)
|
|
3351
|
+
this.pendingBackgroundHold = undefined;
|
|
3352
|
+
else if (this.backgroundHold === hold)
|
|
3353
|
+
this.backgroundHold = undefined;
|
|
3354
|
+
else
|
|
3355
|
+
return;
|
|
3356
|
+
this.clearBackgroundHoldTimers(hold);
|
|
3357
|
+
const line = hold.answerChars > 0 ? undefined : backgroundDeferralLine(this.session.agentId);
|
|
3358
|
+
this.releaseBackgroundHoldIds(hold, { hard: options.hard });
|
|
3359
|
+
const onAir = this.segment !== undefined && !this.segment.finished;
|
|
3360
|
+
// With no segment on air the closing line has no chat bubble to land in:
|
|
3361
|
+
// log it, so the fact is recorded rather than silently dropped.
|
|
3362
|
+
if (line !== undefined && !onAir)
|
|
3363
|
+
this.broadcastBackgroundDeferral(line);
|
|
3364
|
+
this.closeTurn({ kind: 'stop', stopReason: hold.stopReason }, { line, keepAmbient: options.hard, requestId: hold.requestId });
|
|
3365
|
+
}
|
|
3366
|
+
/** Write a hold's deferral line into the settings log (`agent/notice`). */
|
|
3367
|
+
broadcastBackgroundDeferral(line) {
|
|
3368
|
+
this.hub.broadcast({
|
|
3369
|
+
type: 'agent/notice',
|
|
3370
|
+
agentId: this.session.agentId,
|
|
3371
|
+
level: 'info',
|
|
3372
|
+
text: line.trim(),
|
|
3373
|
+
});
|
|
3374
|
+
}
|
|
3375
|
+
/** Drop a hold's clocks. The hold object itself stays for its `retired` mark. */
|
|
3376
|
+
clearBackgroundHoldTimers(hold) {
|
|
3377
|
+
if (hold.quietTimer !== undefined)
|
|
3378
|
+
clearTimeout(hold.quietTimer);
|
|
3379
|
+
if (hold.hardTimer !== undefined)
|
|
3380
|
+
clearTimeout(hold.hardTimer);
|
|
3381
|
+
if (hold.graceTimer !== undefined)
|
|
3382
|
+
clearTimeout(hold.graceTimer);
|
|
3383
|
+
hold.quietTimer = undefined;
|
|
3384
|
+
hold.hardTimer = undefined;
|
|
3385
|
+
hold.graceTimer = undefined;
|
|
3386
|
+
}
|
|
3387
|
+
/**
|
|
3388
|
+
* Drop every hold clock (teardown / a forced ending). `retire` also gives up
|
|
3389
|
+
* every accepted run id — a SUCCESSOR turn must never fold the abandoned
|
|
3390
|
+
* hold's drain or worker text into its own answer.
|
|
3391
|
+
*/
|
|
3392
|
+
clearBackgroundHold(options) {
|
|
3393
|
+
const pending = this.pendingBackgroundHold;
|
|
3394
|
+
if (pending !== undefined) {
|
|
3395
|
+
this.pendingBackgroundHold = undefined;
|
|
3396
|
+
this.clearBackgroundHoldTimers(pending);
|
|
3397
|
+
if (options?.retire === true)
|
|
3398
|
+
this.releaseBackgroundHoldIds(pending, { hard: false });
|
|
3399
|
+
}
|
|
3400
|
+
const hold = this.backgroundHold;
|
|
3401
|
+
if (hold === undefined)
|
|
3402
|
+
return;
|
|
3403
|
+
this.backgroundHold = undefined;
|
|
3404
|
+
this.clearBackgroundHoldTimers(hold);
|
|
3405
|
+
if (options?.retire === true)
|
|
3406
|
+
this.releaseBackgroundHoldIds(hold, { hard: false });
|
|
3407
|
+
}
|
|
3408
|
+
/**
|
|
3409
|
+
* Settle what happens to the session's drain records when a hold closes.
|
|
3410
|
+
*
|
|
3411
|
+
* `hard: false` (quiet close, cancel, teardown): the run is no longer waited
|
|
3412
|
+
* on, so EVERY accepted id is retired — its late text stays transcript-only
|
|
3413
|
+
* for whatever turn runs next — and the session record is dropped if it
|
|
3414
|
+
* names one of them.
|
|
3415
|
+
*
|
|
3416
|
+
* `hard: true` (ceiling / parked grace): the run is provably still going.
|
|
3417
|
+
* Exactly ONE record survives — the session's current drain if it has one,
|
|
3418
|
+
* else the hold's primary run — with its activity clock refreshed; the other
|
|
3419
|
+
* accepted ids are retired. A hold whose primary id had no session record
|
|
3420
|
+
* CREATES one, so a successor's `noteTurnRequestId` cannot adopt that id.
|
|
3421
|
+
* Every accepted id therefore ends up either retired or owned by
|
|
3422
|
+
* `session.ambient`; never both unowned and unretired.
|
|
3423
|
+
*/
|
|
3424
|
+
releaseBackgroundHoldIds(hold, options) {
|
|
3425
|
+
if (hold.retired)
|
|
3426
|
+
return;
|
|
3427
|
+
hold.retired = true;
|
|
3428
|
+
if (!options.hard) {
|
|
3429
|
+
for (const id of hold.acceptedIds)
|
|
3430
|
+
retireAmbientId(this.session, id);
|
|
3431
|
+
if (this.session.ambient !== undefined && hold.acceptedIds.has(this.session.ambient.requestId)) {
|
|
3432
|
+
this.session.ambient = undefined;
|
|
3433
|
+
}
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3436
|
+
const survivor = this.session.ambient?.requestId ?? hold.requestId;
|
|
3437
|
+
for (const id of hold.acceptedIds) {
|
|
3438
|
+
if (id === survivor)
|
|
3439
|
+
continue;
|
|
3440
|
+
retireAmbientId(this.session, id);
|
|
3441
|
+
}
|
|
3442
|
+
if (survivor === undefined)
|
|
3443
|
+
return;
|
|
3444
|
+
const now = Date.now();
|
|
3445
|
+
if (this.session.ambient?.requestId === survivor) {
|
|
3446
|
+
this.session.ambient.lastActivityAt = now;
|
|
3447
|
+
}
|
|
3448
|
+
else {
|
|
3449
|
+
this.session.ambient = {
|
|
3450
|
+
requestId: survivor,
|
|
3451
|
+
startedAt: now,
|
|
3452
|
+
lastActivityAt: now,
|
|
3453
|
+
sawOutput: hold.answerChars > 0,
|
|
3454
|
+
};
|
|
3455
|
+
}
|
|
3456
|
+
// The surviving run is alive, not retired (an earlier wait of this turn
|
|
3457
|
+
// may have retired it before it spoke again).
|
|
3458
|
+
reviveAmbientId(this.session, survivor);
|
|
3459
|
+
}
|
|
3460
|
+
/**
|
|
3461
|
+
* Fold the withheld drain updates that belong to this hold into its segment.
|
|
3462
|
+
* The rest of the buffer was some OTHER run's text: it is dropped here (the
|
|
3463
|
+
* transcript keeps it), never leaked into this turn's answer.
|
|
3464
|
+
*/
|
|
3465
|
+
flushBackgroundHoldUpdates(seg, hold) {
|
|
3466
|
+
if (this.pendingAmbientUpdates.length === 0) {
|
|
3467
|
+
this.pendingAmbientTruncated = false;
|
|
3468
|
+
return;
|
|
3469
|
+
}
|
|
3470
|
+
const pending = this.pendingAmbientUpdates.splice(0, this.pendingAmbientUpdates.length);
|
|
3471
|
+
this.pendingAmbientChars = 0;
|
|
3472
|
+
const truncated = this.pendingAmbientTruncated;
|
|
3473
|
+
this.pendingAmbientTruncated = false;
|
|
3474
|
+
const accepted = pending.filter(entry => {
|
|
3475
|
+
const requestId = codebuddyMetaOf(entry.update)?.requestId;
|
|
3476
|
+
return requestId !== undefined && hold.acceptedIds.has(requestId);
|
|
3477
|
+
});
|
|
3478
|
+
if (accepted.length === 0)
|
|
3479
|
+
return;
|
|
3480
|
+
if (truncated && !seg.finished) {
|
|
3481
|
+
// The front of the drain's output was evicted from the bounded buffer:
|
|
3482
|
+
// say so rather than letting the turn quietly start mid-story.
|
|
3483
|
+
this.emitProse(seg, 'text', '\n[dsh-ntr] background output was truncated; the ACP transcript view still has every update.\n');
|
|
3484
|
+
}
|
|
3485
|
+
for (const entry of accepted) {
|
|
3486
|
+
if (seg.finished)
|
|
3487
|
+
break;
|
|
3488
|
+
this.foldInto(seg, entry.update, entry.seq);
|
|
3489
|
+
// The buffered drain text arrived BEFORE the hold, but it IS the held
|
|
3490
|
+
// run's answer: below the hold's floor it was not counted by `foldInto`,
|
|
3491
|
+
// so count it here (the floor exists to skip the parent's earlier ack,
|
|
3492
|
+
// not the drain's own words).
|
|
3493
|
+
if (entry.seq <= hold.floorSeq
|
|
3494
|
+
&& String(entry.update.sessionUpdate ?? '') === 'agent_message_chunk') {
|
|
3495
|
+
hold.answerChars += chunkTextOf(entry.update.content).length;
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
}
|
|
3499
|
+
/**
|
|
3500
|
+
* Follow the detached-worker signals of one live update.
|
|
3501
|
+
*
|
|
3502
|
+
* Only CodeBuddy sends them (`codebuddyMetaOf` answers `undefined` for every
|
|
3503
|
+
* other agent, so nothing here can fire for them):
|
|
3504
|
+
* - an `Agent` / `Task` `tool_call` records the dispatch — the FIRST send
|
|
3505
|
+
* needs that CodeBuddy shape;
|
|
3506
|
+
* - the SAME call re-sent with `isBackground: true`, or any update naming it
|
|
3507
|
+
* as its `parentToolCallId`, marks the work DETACHED;
|
|
3508
|
+
* - that call reporting completed / failed is read BY ID: the real
|
|
3509
|
+
* completion frame carries NO `_meta` at all, so requiring the CodeBuddy
|
|
3510
|
+
* shape here would miss every real completion;
|
|
3511
|
+
* - any update carrying `parentToolCallId` is the worker's OWN turn — it
|
|
3512
|
+
* proves the worker alive but never belongs to this session's answer;
|
|
3513
|
+
* - an `unsolicitedTurn` announcement is the drain that carries the report.
|
|
3514
|
+
*/
|
|
3515
|
+
trackBackgroundDispatch(update) {
|
|
3516
|
+
if (this.closedTurn)
|
|
3517
|
+
return;
|
|
3518
|
+
const meta = codebuddyMetaOf(update);
|
|
3519
|
+
const kind = String(update.sessionUpdate ?? '');
|
|
3520
|
+
const row = update;
|
|
3521
|
+
const toolCallId = typeof row.toolCallId === 'string' ? row.toolCallId.trim() : '';
|
|
3522
|
+
const status = typeof row.status === 'string' ? row.status : '';
|
|
3523
|
+
const completedStatus = status === 'completed' || status === 'failed';
|
|
3524
|
+
if ((kind === 'tool_call' || kind === 'tool_call_update') && toolCallId !== '') {
|
|
3525
|
+
const known = this.dispatches.get(toolCallId);
|
|
3526
|
+
if (known !== undefined) {
|
|
3527
|
+
// A known dispatch: the id is the whole evidence (see the doc above).
|
|
3528
|
+
if (status !== '')
|
|
3529
|
+
known.status = status;
|
|
3530
|
+
if (meta?.isBackground === true)
|
|
3531
|
+
known.detached = true;
|
|
3532
|
+
if (completedStatus && !known.completed) {
|
|
3533
|
+
known.completed = true;
|
|
3534
|
+
this.noteDispatchProgress();
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
else if (meta !== undefined
|
|
3538
|
+
&& (meta.toolName === 'Agent' || meta.toolName === 'Task')
|
|
3539
|
+
&& (kind === 'tool_call' || meta.isBackground === true)) {
|
|
3540
|
+
// A NEW dispatch needs the CodeBuddy shape. A turn may dispatch more
|
|
3541
|
+
// than one worker; the hold waits for all of them.
|
|
3542
|
+
const dispatch = {
|
|
3543
|
+
toolCallId,
|
|
3544
|
+
status,
|
|
3545
|
+
completed: completedStatus,
|
|
3546
|
+
detached: meta.isBackground === true,
|
|
3547
|
+
sawWorkerUpdate: false,
|
|
3548
|
+
};
|
|
3549
|
+
this.dispatches.set(toolCallId, dispatch);
|
|
3550
|
+
if (!dispatch.completed) {
|
|
3551
|
+
const held = this.activeBackgroundHold();
|
|
3552
|
+
// A dispatch registered after the drain phase began means new work:
|
|
3553
|
+
// the worker phase (and its absence of a quiet clock) is back.
|
|
3554
|
+
if (held !== undefined)
|
|
3555
|
+
this.enterBackgroundWorkerPhase(held);
|
|
3556
|
+
}
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
if (meta?.parentToolCallId !== undefined) {
|
|
3560
|
+
const parent = this.dispatches.get(meta.parentToolCallId);
|
|
3561
|
+
if (parent !== undefined) {
|
|
3562
|
+
parent.detached = true;
|
|
3563
|
+
parent.sawWorkerUpdate = true;
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
if (meta?.unsolicitedTurn !== undefined) {
|
|
3567
|
+
const held = this.activeBackgroundHold();
|
|
3568
|
+
if (held !== undefined)
|
|
3569
|
+
this.startBackgroundDrain(held, meta.unsolicitedTurn.requestId);
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
/**
|
|
3573
|
+
* A dispatch just reported: once the LAST one has, the drain phase starts.
|
|
3574
|
+
* Until then the turn stays in its worker phase, with no quiet clock.
|
|
3575
|
+
*/
|
|
3576
|
+
noteDispatchProgress() {
|
|
3577
|
+
if (!this.allDispatchesCompleted())
|
|
3578
|
+
return;
|
|
3579
|
+
const held = this.activeBackgroundHold();
|
|
3580
|
+
if (held !== undefined)
|
|
3581
|
+
this.startBackgroundDrain(held);
|
|
3582
|
+
}
|
|
3583
|
+
/** Any session update is evidence the drain still works: push the quiet clock out. */
|
|
3584
|
+
noteAmbientActivity() {
|
|
3585
|
+
const wait = this.awaitingAmbient;
|
|
3586
|
+
if (wait === undefined)
|
|
3587
|
+
return;
|
|
3588
|
+
clearTimeout(wait.quietTimer);
|
|
3589
|
+
wait.quietTimer = setTimeout(() => { this.onAmbientQuiet(wait); }, this.ambientTuning.quietMs);
|
|
3590
|
+
wait.quietTimer.unref?.();
|
|
3591
|
+
}
|
|
3592
|
+
/** The drain went quiet: the turn ends with the stop reason the response gave. */
|
|
3593
|
+
onAmbientQuiet(wait) {
|
|
3594
|
+
if (this.awaitingAmbient !== wait)
|
|
3595
|
+
return;
|
|
3596
|
+
this.finishAmbient(wait);
|
|
3597
|
+
}
|
|
3598
|
+
/** The drain outlived the ceiling: say where the rest will land, then close. */
|
|
3599
|
+
onAmbientHard(wait) {
|
|
3600
|
+
if (this.awaitingAmbient !== wait)
|
|
3601
|
+
return;
|
|
3602
|
+
const seg = this.segment;
|
|
3603
|
+
if (seg !== undefined && !seg.finished) {
|
|
3604
|
+
this.emitProse(seg, 'text', `\n[dsh-ntr] agent "${this.session.agentId}" is still running a background task; the rest of its output will appear in the ACP transcript view.\n`);
|
|
3605
|
+
}
|
|
3606
|
+
// The ceiling is evidence the drain is STILL RUNNING (unlike a quiet close),
|
|
3607
|
+
// so the ending keeps the session's drain record; see `closeTurn`.
|
|
3608
|
+
wait.hard = true;
|
|
3609
|
+
// The hard explanation already says the turn was cut short; the deferred
|
|
3610
|
+
// line would only repeat it in different words.
|
|
3611
|
+
this.finishAmbient(wait, { announceDeferral: false });
|
|
3612
|
+
}
|
|
3613
|
+
/**
|
|
3614
|
+
* Close an ambient wait exactly once. A turn that produced no answer TEXT
|
|
3615
|
+
* says so: the drain's output is in the ACP transcript view, and this
|
|
3616
|
+
* bubble must not be empty (the zero-output report, reworded for a turn that
|
|
3617
|
+
* was deferred rather than failed).
|
|
3618
|
+
*/
|
|
3619
|
+
finishAmbient(wait, options) {
|
|
3620
|
+
if (this.awaitingAmbient !== wait)
|
|
3621
|
+
return;
|
|
3622
|
+
const line = options?.announceDeferral === false || this.sawVisibleText
|
|
3623
|
+
? undefined
|
|
3624
|
+
: backgroundDeferralLine(this.session.agentId);
|
|
3625
|
+
this.closeTurn({ kind: 'stop', stopReason: wait.stopReason }, { line, keepAmbient: wait.hard === true, requestId: wait.requestId });
|
|
3626
|
+
}
|
|
3627
|
+
/** Drop the ambient wait's clocks (the turn is being closed by someone else). */
|
|
3628
|
+
clearAmbientWait() {
|
|
3629
|
+
const wait = this.awaitingAmbient;
|
|
3630
|
+
if (wait === undefined)
|
|
3631
|
+
return;
|
|
3632
|
+
this.awaitingAmbient = undefined;
|
|
3633
|
+
clearTimeout(wait.quietTimer);
|
|
3634
|
+
clearTimeout(wait.hardTimer);
|
|
3635
|
+
}
|
|
3636
|
+
/**
|
|
3637
|
+
* Withhold one background-drain update until the prompt response says what
|
|
3638
|
+
* this turn is. The cursor advances past it anyway: it is already in the
|
|
3639
|
+
* transcript, and replaying it into a later segment would emit it twice.
|
|
3640
|
+
* Bounded (see `PENDING_AMBIENT_MAX_*`) — the transcript still keeps every
|
|
3641
|
+
* entry the buffer drops.
|
|
3642
|
+
*/
|
|
3643
|
+
bufferAmbientUpdate(seq, update) {
|
|
3644
|
+
const chars = JSON.stringify(update).length;
|
|
3645
|
+
this.pendingAmbientUpdates.push({ seq, update, chars });
|
|
3646
|
+
this.pendingAmbientChars += chars;
|
|
3647
|
+
while (this.pendingAmbientUpdates.length > PENDING_AMBIENT_MAX_ENTRIES
|
|
3648
|
+
|| (this.pendingAmbientChars > PENDING_AMBIENT_MAX_CHARS && this.pendingAmbientUpdates.length > 1)) {
|
|
3649
|
+
const dropped = this.pendingAmbientUpdates.shift();
|
|
3650
|
+
if (dropped === undefined)
|
|
3651
|
+
break;
|
|
3652
|
+
this.pendingAmbientChars -= dropped.chars;
|
|
3653
|
+
this.pendingAmbientTruncated = true;
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
/** Fold the withheld drain updates into the now-ambient segment, once, in order. */
|
|
3657
|
+
flushPendingAmbient(seg) {
|
|
3658
|
+
if (this.pendingAmbientUpdates.length === 0) {
|
|
3659
|
+
this.pendingAmbientTruncated = false;
|
|
3660
|
+
return;
|
|
3661
|
+
}
|
|
3662
|
+
const truncated = this.pendingAmbientTruncated;
|
|
3663
|
+
this.pendingAmbientTruncated = false;
|
|
3664
|
+
const pending = this.pendingAmbientUpdates.splice(0, this.pendingAmbientUpdates.length);
|
|
3665
|
+
this.pendingAmbientChars = 0;
|
|
3666
|
+
if (truncated && !seg.finished) {
|
|
3667
|
+
// The front of the drain's output was evicted from the bounded buffer:
|
|
3668
|
+
// say so rather than letting the turn quietly start mid-story.
|
|
3669
|
+
this.emitProse(seg, 'text', '\n[dsh-ntr] background output was truncated; the ACP transcript view still has every update.\n');
|
|
3670
|
+
}
|
|
3671
|
+
for (const entry of pending) {
|
|
3672
|
+
if (seg.finished)
|
|
3673
|
+
break;
|
|
3674
|
+
this.foldInto(seg, entry.update, entry.seq);
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3677
|
+
/** Drop withheld drain updates: this turn is not the drain, or it is closing. */
|
|
3678
|
+
clearPendingAmbient() {
|
|
3679
|
+
this.pendingAmbientUpdates.length = 0;
|
|
3680
|
+
this.pendingAmbientChars = 0;
|
|
3681
|
+
this.pendingAmbientTruncated = false;
|
|
3682
|
+
}
|
|
2596
3683
|
/** The agent settled its `session/prompt`. Ends the turn's streaming face. */
|
|
2597
3684
|
settle(outcome) {
|
|
2598
3685
|
if (this.settled)
|
|
2599
3686
|
return;
|
|
3687
|
+
// `settled` is claimed NOW (a second settle, a cancel or a watchdog that
|
|
3688
|
+
// races the tail drain must find the turn already spoken for), but the
|
|
3689
|
+
// CLOSING work waits one macrotask: the SDK dispatches `session/update`
|
|
3690
|
+
// notifications independently of the `session/prompt` RESPONSE, so the
|
|
3691
|
+
// agent's last chunks can still be queued when this lands (see
|
|
3692
|
+
// `settleTranscript`). Only what already reached the transcript is read —
|
|
3693
|
+
// one macrotask, never an open-ended wait.
|
|
2600
3694
|
this.settled = true;
|
|
3695
|
+
this.clearAmbientWait();
|
|
3696
|
+
this.clearPendingAmbient();
|
|
3697
|
+
// An outside ending (watchdog, cancel) abandons a detached-worker hold: its
|
|
3698
|
+
// run is no longer this turn's, so its id is retired before the close.
|
|
3699
|
+
this.clearBackgroundHold({ retire: true });
|
|
3700
|
+
this.pendingOutcome = outcome;
|
|
3701
|
+
this.closeScheduled = true;
|
|
3702
|
+
setImmediate(() => { this.flushScheduledClose(); });
|
|
3703
|
+
}
|
|
3704
|
+
/**
|
|
3705
|
+
* Force the local end of this turn — the cancel fallback and any watchdog
|
|
3706
|
+
* that reached the controller. Runs the SAME closing work as a normal settle
|
|
3707
|
+
* (`closeTurn`): the ambient clocks stop, the on-air segment closes (so the
|
|
3708
|
+
* driver step parked on its queue ends), exactly one `turn/end` goes out
|
|
3709
|
+
* with THIS turn's id, the session stops running and the prompt slot is
|
|
3710
|
+
* released. Idempotent: a turn that already closed ignores the call.
|
|
3711
|
+
*/
|
|
3712
|
+
forceFinalize(outcome) {
|
|
3713
|
+
if (this.stale || this.closedTurn)
|
|
3714
|
+
return;
|
|
3715
|
+
this.clearAmbientWait();
|
|
3716
|
+
// A forced ending abandons a detached-worker hold: retire its run's id so
|
|
3717
|
+
// its late words stay transcript-only for the next turn.
|
|
3718
|
+
this.clearBackgroundHold({ retire: true });
|
|
3719
|
+
// A settle's deferred tail read is moot once this close runs: the turn is
|
|
3720
|
+
// over now, and the pending macrotask must not drain anything into it.
|
|
3721
|
+
this.pendingOutcome = undefined;
|
|
3722
|
+
this.closeScheduled = false;
|
|
3723
|
+
this.closeTurn(outcome);
|
|
3724
|
+
}
|
|
3725
|
+
/**
|
|
3726
|
+
* Run the deferred closing work now, when a driver step is about to open on
|
|
3727
|
+
* an already settled turn: the continuation step must see the closed turn
|
|
3728
|
+
* (and the session NOT left `running` across the macrotask boundary).
|
|
3729
|
+
* Idempotent — the scheduled tick then finds nothing left to do.
|
|
3730
|
+
*/
|
|
3731
|
+
flushScheduledClose() {
|
|
3732
|
+
if (!this.closeScheduled)
|
|
3733
|
+
return;
|
|
3734
|
+
this.closeScheduled = false;
|
|
3735
|
+
const outcome = this.pendingOutcome;
|
|
3736
|
+
this.pendingOutcome = undefined;
|
|
3737
|
+
if (outcome === undefined)
|
|
3738
|
+
return;
|
|
3739
|
+
// A DISPOSE that raced this queued close cleared it (see `dispose`), so a
|
|
3740
|
+
// close still scheduled here belongs to a settle that landed AFTER the
|
|
3741
|
+
// stream was retired (a crash rejects its prompt only after the teardown).
|
|
3742
|
+
// That turn still ends through its own queue and real id — the teardown's
|
|
3743
|
+
// releaseAll frame carried an empty id.
|
|
3744
|
+
this.drainTail();
|
|
3745
|
+
this.closeTurn(outcome);
|
|
3746
|
+
}
|
|
3747
|
+
/**
|
|
3748
|
+
* Fold the notifications that reached the transcript but not the segment:
|
|
3749
|
+
* the tail a response raced past. Stops AT a cutting event without consuming
|
|
3750
|
+
* it (same rule as `startSegment`) — its next segment is not this turn's.
|
|
3751
|
+
*/
|
|
3752
|
+
drainTail() {
|
|
3753
|
+
// The deferred read belongs to the turn that scheduled it. If the turn was
|
|
3754
|
+
// retired or a NEWER turn already began (turnSeq moved on) in this one
|
|
3755
|
+
// macrotask window, everything past the cursor is the successor's: folding
|
|
3756
|
+
// it here would leak its text into this turn's driver step.
|
|
3757
|
+
if (this.stale)
|
|
3758
|
+
return;
|
|
3759
|
+
const seg = this.segment;
|
|
3760
|
+
if (seg === undefined || seg.finished)
|
|
3761
|
+
return;
|
|
3762
|
+
for (const entry of this.session.transcript.since(this.cursorSeq)) {
|
|
3763
|
+
if (seg.finished)
|
|
3764
|
+
break;
|
|
3765
|
+
const update = entry.update;
|
|
3766
|
+
// The same split the live path makes: a background drain's text in the
|
|
3767
|
+
// tail is not this turn's answer. Record what the registry needs (tool
|
|
3768
|
+
// outputs), move the cursor past it, and do NOT fold it — otherwise the
|
|
3769
|
+
// deferred read smuggles the drain's prose into a normal turn.
|
|
3770
|
+
if (this.isForeignAmbientText(update)) {
|
|
3771
|
+
this.recordWithoutSegment(update);
|
|
3772
|
+
this.cursorSeq = entry.seq;
|
|
3773
|
+
continue;
|
|
3774
|
+
}
|
|
3775
|
+
if (!this.foldInto(seg, update, entry.seq))
|
|
3776
|
+
break;
|
|
3777
|
+
this.cursorSeq = entry.seq;
|
|
3778
|
+
}
|
|
3779
|
+
}
|
|
3780
|
+
/**
|
|
3781
|
+
* End the turn exactly once: burst metrics, ONE `turn/end`, the provisional
|
|
3782
|
+
* lock, the closing text, the segment, the session flags, the prompt slot.
|
|
3783
|
+
*
|
|
3784
|
+
* `ambient.line` marks a controlled ambient ending: the given line closes
|
|
3785
|
+
* the segment when the turn carried no answer text, instead of the generic
|
|
3786
|
+
* zero-output warning (which would blame the agent for a turn the drain
|
|
3787
|
+
* simply had not finished yet). `ambient.keepAmbient` says the ending was the
|
|
3788
|
+
* CEILING, not silence: see the drain-record comment below.
|
|
3789
|
+
*/
|
|
3790
|
+
closeTurn(outcome, ambient) {
|
|
3791
|
+
if (this.closedTurn)
|
|
3792
|
+
return;
|
|
3793
|
+
this.closedTurn = true;
|
|
3794
|
+
// A closed turn is a settled turn, whichever path got here first (an
|
|
3795
|
+
// ambient ending does not go through `settle`).
|
|
3796
|
+
this.settled = true;
|
|
3797
|
+
this.clearAmbientWait();
|
|
3798
|
+
this.clearPendingAmbient();
|
|
3799
|
+
// Whoever closed this turn already decided what happens to the held
|
|
3800
|
+
// run's record (`closeBackgroundHold` retired or kept it); only the clocks
|
|
3801
|
+
// are still ours to drop.
|
|
3802
|
+
this.clearBackgroundHold();
|
|
2601
3803
|
// A late answer to a SUPERSEDED turn still has to close this controller's
|
|
2602
3804
|
// queue (a continuation step may be parked on it) and its settle gate —
|
|
2603
3805
|
// but it must not touch the session the NEWER turn now owns, nor release
|
|
@@ -2609,6 +3811,41 @@ class TurnStreamController {
|
|
|
2609
3811
|
? { kind: 'error', failure: { message: outcome.message, code: outcome.code ?? 'INTERNAL' } }
|
|
2610
3812
|
: { kind: 'stop' };
|
|
2611
3813
|
if (current) {
|
|
3814
|
+
// An ambient ending that came from SILENCE retires the drain record with
|
|
3815
|
+
// its turn: a record that outlives the wait would make the NEXT instant
|
|
3816
|
+
// answer with no conversation request id look steered (see
|
|
3817
|
+
// `awaitAmbient`), holding a normal turn open for a drain this one
|
|
3818
|
+
// already finished waiting on. A HARD ceiling ending is the opposite
|
|
3819
|
+
// evidence — the drain was still ticking when the turn was cut — so the
|
|
3820
|
+
// record SURVIVES (activity clock refreshed; `ambientStaleMs` expires it
|
|
3821
|
+
// lazily) precisely so the next prompt folded into the same drain is
|
|
3822
|
+
// still recognised as steered. `ambientNotices` is never cleared here:
|
|
3823
|
+
// a repeated announcement of the same run must stay silent, and the set
|
|
3824
|
+
// is bounded where entries are added.
|
|
3825
|
+
if (ambient !== undefined) {
|
|
3826
|
+
if (ambient.keepAmbient === true) {
|
|
3827
|
+
if (this.session.ambient !== undefined)
|
|
3828
|
+
this.session.ambient.lastActivityAt = Date.now();
|
|
3829
|
+
}
|
|
3830
|
+
else {
|
|
3831
|
+
// Silence closed this wait, but silence is not proof the run ended: a
|
|
3832
|
+
// queued message or a long tool can make the drain speak again. So
|
|
3833
|
+
// RETIRE its id before dropping the record — a revived run's late
|
|
3834
|
+
// text must stay in the ACP transcript view instead of teaching the
|
|
3835
|
+
// NEXT turn the drain's id and mixing the drain's answer into the
|
|
3836
|
+
// user's (a double answer). The trade-off is deliberate: a next
|
|
3837
|
+
// prompt the agent folds into that same revived drain may not receive
|
|
3838
|
+
// the drain's text on the chat plane (transcript-only); a double
|
|
3839
|
+
// answer is worse.
|
|
3840
|
+
if (ambient.requestId !== undefined)
|
|
3841
|
+
retireAmbientId(this.session, ambient.requestId);
|
|
3842
|
+
this.session.ambient = undefined;
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
// The turn's burst aggregate must be visible before the turn is marked
|
|
3846
|
+
// over: the final `turn/metrics` lands first, then `turn/end` flips the
|
|
3847
|
+
// session/pill state. Persisting and broadcasting share one value.
|
|
3848
|
+
this.finalizeTps();
|
|
2612
3849
|
if (outcome.kind === 'cancelled') {
|
|
2613
3850
|
this.hub.broadcast({ type: 'turn/end', sessionId: this.session.sessionId, turnId: this.turnId, stopReason: 'cancelled' });
|
|
2614
3851
|
}
|
|
@@ -2620,20 +3857,31 @@ class TurnStreamController {
|
|
|
2620
3857
|
}
|
|
2621
3858
|
// The provisional lock settles BEFORE any synthesized warning below: the
|
|
2622
3859
|
// warning is our text, not evidence that the first turn delivered anything.
|
|
3860
|
+
//
|
|
3861
|
+
// An AMBIENT ending ends the provisional phase WITHOUT a rollback: a
|
|
3862
|
+
// deferral to a background drain is not a failed first turn, the drain's
|
|
3863
|
+
// text still lands in the ACP transcript view, and rolling the lock back
|
|
3864
|
+
// would close the session under it — dropping exactly the output the
|
|
3865
|
+
// deferred turn promised. The lock therefore becomes permanent.
|
|
2623
3866
|
if (this.session.provisionalLock === true) {
|
|
2624
3867
|
this.session.provisionalLock = false;
|
|
2625
|
-
if (!this.sawVisibleBlock)
|
|
3868
|
+
if (!this.sawVisibleBlock && ambient === undefined)
|
|
2626
3869
|
this.rollbackFirstTurn(this.session);
|
|
2627
3870
|
}
|
|
2628
3871
|
}
|
|
2629
3872
|
const seg = this.segment;
|
|
2630
3873
|
if (seg !== undefined) {
|
|
2631
|
-
if (
|
|
3874
|
+
if (ambient !== undefined) {
|
|
3875
|
+
// The ambient line is OUR report, so it only appears when the drain
|
|
3876
|
+
// delivered no answer text of its own; reasoning alone does not count.
|
|
3877
|
+
if (ambient.line !== undefined && current)
|
|
3878
|
+
this.emitProse(seg, 'text', ambient.line);
|
|
3879
|
+
}
|
|
3880
|
+
else if (current && outcome.kind === 'stop' && !this.sawVisibleBlock) {
|
|
2632
3881
|
// The silent-failure report: a normal stop with nothing the chat
|
|
2633
3882
|
// plane renders. Aborted or failing turns need no extra hint; this
|
|
2634
3883
|
// is specifically "protocol says success, the bubble is empty".
|
|
2635
|
-
|
|
2636
|
-
this.emitProse(seg, 'text', line);
|
|
3884
|
+
this.emitProse(seg, 'text', this.zeroOutputLine(outcome.stopReason));
|
|
2637
3885
|
}
|
|
2638
3886
|
this.finishSegment(seg, reason);
|
|
2639
3887
|
}
|
|
@@ -2648,25 +3896,60 @@ class TurnStreamController {
|
|
|
2648
3896
|
}
|
|
2649
3897
|
this.endTurn();
|
|
2650
3898
|
// The controller stays indexed only while a continuation step can still
|
|
2651
|
-
// come for its cards. A cancelled, failed, or card-less turn has none:
|
|
2652
|
-
//
|
|
3899
|
+
// come for its cards. A cancelled, failed, or card-less turn has none: retire
|
|
3900
|
+
// it now; the next turn's `rememberTurnStream` would anyway. An ambient turn
|
|
3901
|
+
// ALWAYS retires: its suppressed calls never open cards, so keeping the index
|
|
3902
|
+
// would pin the controller (and its session id) to a turn nobody can continue.
|
|
2653
3903
|
if (!current)
|
|
2654
3904
|
this.retire();
|
|
2655
|
-
else if (outcome.kind !== 'stop' || this.seenCalls.size === 0)
|
|
3905
|
+
else if (ambient !== undefined || outcome.kind !== 'stop' || this.seenCalls.size === 0)
|
|
2656
3906
|
this.dispose();
|
|
2657
3907
|
}
|
|
2658
|
-
/**
|
|
3908
|
+
/**
|
|
3909
|
+
* Cancelled by the user: finish the on-air segment and release the turn's
|
|
3910
|
+
* slots. An AMBIENT turn has already released the prompt slot (its response
|
|
3911
|
+
* landed) and holds no segment the cancel fallback could close, so the
|
|
3912
|
+
* cancel closes the turn itself with the cancelled stop.
|
|
3913
|
+
*/
|
|
2659
3914
|
abortActiveSegment() {
|
|
2660
3915
|
if (this.stale)
|
|
2661
3916
|
return;
|
|
3917
|
+
this.clearPendingAmbient();
|
|
3918
|
+
if (this.closeScheduled) {
|
|
3919
|
+
// `settle` claimed the turn but queued its closing work for the next
|
|
3920
|
+
// macrotask. An abort landing inside that window must not let the
|
|
3921
|
+
// scheduled close broadcast the RESPONSE's stop reason while the segment
|
|
3922
|
+
// already finished `aborted`: drop the queued outcome and close NOW with
|
|
3923
|
+
// the cancelled one, so `turn/end` and the segment's finish agree.
|
|
3924
|
+
this.closeScheduled = false;
|
|
3925
|
+
this.pendingOutcome = undefined;
|
|
3926
|
+
this.closeTurn({ kind: 'cancelled' });
|
|
3927
|
+
return;
|
|
3928
|
+
}
|
|
3929
|
+
if (this.awaitingAmbient !== undefined) {
|
|
3930
|
+
this.settle({ kind: 'cancelled' });
|
|
3931
|
+
return;
|
|
3932
|
+
}
|
|
3933
|
+
if (this.backgroundHold !== undefined || this.pendingBackgroundHold !== undefined) {
|
|
3934
|
+
// A detached-worker hold is a turn whose prompt response already landed
|
|
3935
|
+
// (the slot is free) and whose segment is still open on purpose: the
|
|
3936
|
+
// cancel ends OUR face of it now, once, and the held run's id is retired
|
|
3937
|
+
// so its late text cannot join the next turn.
|
|
3938
|
+
this.clearBackgroundHold({ retire: true });
|
|
3939
|
+
this.settle({ kind: 'cancelled' });
|
|
3940
|
+
return;
|
|
3941
|
+
}
|
|
2662
3942
|
this.finishActive({ kind: 'aborted', failure: { message: 'aborted', code: 'ABORTED' } });
|
|
2663
3943
|
// The agent may take its time acknowledging the cancel, but OUR face of
|
|
2664
|
-
// the turn ends now: the session stops running
|
|
2665
|
-
//
|
|
2666
|
-
//
|
|
3944
|
+
// the turn ends now: the session stops running, the HOST's prompt slot is
|
|
3945
|
+
// released (so a new prompt may claim it), and a provisional first lock
|
|
3946
|
+
// settles against what was actually shown. The AGENT-side gate (`settled`)
|
|
3947
|
+
// stays claimed: the prompt is still in flight over there, and a second
|
|
2667
3948
|
// prompt sent into that window would be a concurrent `session/prompt` the
|
|
2668
|
-
// protocol forbids — `settle` (or the cancel
|
|
3949
|
+
// protocol forbids — `claimTurn` waits it out, `settle` (or the cancel
|
|
3950
|
+
// fallback) opens it.
|
|
2669
3951
|
this.session.running = false;
|
|
3952
|
+
this.session.promptInFlight = false;
|
|
2670
3953
|
if (this.session.provisionalLock === true) {
|
|
2671
3954
|
this.session.provisionalLock = false;
|
|
2672
3955
|
if (!this.sawVisibleBlock)
|
|
@@ -2674,10 +3957,30 @@ class TurnStreamController {
|
|
|
2674
3957
|
}
|
|
2675
3958
|
releaseAllToolOutputWaiters();
|
|
2676
3959
|
}
|
|
2677
|
-
/**
|
|
3960
|
+
/**
|
|
3961
|
+
* Stop observing and release the manager's index. Idempotent.
|
|
3962
|
+
*
|
|
3963
|
+
* The segment is CLOSED here (aborted) before unsubscribing: a driver step
|
|
3964
|
+
* parked on its queue (`yield* segment.queue`) only ends when the queue
|
|
3965
|
+
* closes, and a controller replaced by the conversation's next turn would
|
|
3966
|
+
* otherwise leave that step hanging forever. An already finished segment is a
|
|
3967
|
+
* no-op, and the session's flags are left alone — a superseded turn must not
|
|
3968
|
+
* mark the newer turn's session idle.
|
|
3969
|
+
*/
|
|
2678
3970
|
dispose() {
|
|
2679
3971
|
if (this.disposed)
|
|
2680
3972
|
return;
|
|
3973
|
+
// A settle may have queued its closing work for the next macrotask: this
|
|
3974
|
+
// close owns the turn now, so that tick must not broadcast a second
|
|
3975
|
+
// `turn/end` (with this turn's real id) over the teardown.
|
|
3976
|
+
this.closeScheduled = false;
|
|
3977
|
+
this.pendingOutcome = undefined;
|
|
3978
|
+
this.finishActive({ kind: 'aborted', failure: { message: 'turn stream retired', code: 'RETIRED' } });
|
|
3979
|
+
this.clearAmbientWait();
|
|
3980
|
+
this.clearPendingAmbient();
|
|
3981
|
+
// A successor turn must never fold this hold's drain or worker text: the
|
|
3982
|
+
// abandoned run's id is retired before the subscription goes away.
|
|
3983
|
+
this.clearBackgroundHold({ retire: true });
|
|
2681
3984
|
this.retire();
|
|
2682
3985
|
releaseAllToolOutputWaiters();
|
|
2683
3986
|
}
|
|
@@ -2741,12 +4044,24 @@ class TurnStreamController {
|
|
|
2741
4044
|
else {
|
|
2742
4045
|
// A settled turn that never showed anything: the zero-output warning the
|
|
2743
4046
|
// plane would have gotten from a single-stream turn (constraint 23).
|
|
2744
|
-
const line =
|
|
4047
|
+
const line = this.zeroOutputLine();
|
|
2745
4048
|
if (seg.index < 0)
|
|
2746
4049
|
this.emitProse(seg, 'text', line);
|
|
2747
4050
|
this.finishSegment(seg, { kind: 'stop' });
|
|
2748
4051
|
}
|
|
2749
4052
|
}
|
|
4053
|
+
/**
|
|
4054
|
+
* The "protocol says success, the bubble is empty" line. When a recently
|
|
4055
|
+
* retired drain may have taken the prompt without re-announcing itself, say
|
|
4056
|
+
* that instead of blaming the agent: the output exists in the transcript.
|
|
4057
|
+
*/
|
|
4058
|
+
zeroOutputLine(stopReason) {
|
|
4059
|
+
if (this.recentRetiredDrain) {
|
|
4060
|
+
return `\n[dsh-ntr] agent "${this.session.agentId}" produced no visible output this turn; it may have been folded into a background run; its output is in the ACP transcript view.\n`;
|
|
4061
|
+
}
|
|
4062
|
+
const reason = stopReason === undefined ? '' : ` (stop reason: ${stopReason})`;
|
|
4063
|
+
return `\n[dsh-ntr] agent "${this.session.agentId}" ended the turn with no visible output${reason}. Some agents fold upstream failures — auth, billing, rate limits — into a normal stop; try resending, and check the agent process logs if it repeats.\n`;
|
|
4064
|
+
}
|
|
2750
4065
|
onEvent(event) {
|
|
2751
4066
|
if (this.disposed)
|
|
2752
4067
|
return;
|
|
@@ -2755,9 +4070,50 @@ class TurnStreamController {
|
|
|
2755
4070
|
return;
|
|
2756
4071
|
if (row.type === 'session/update') {
|
|
2757
4072
|
const typed = event;
|
|
4073
|
+
// Any update — a `usage_update` / phase tick included — says the
|
|
4074
|
+
// background drain is still working, so the ambient quiet clock restarts.
|
|
4075
|
+
this.noteAmbientActivity();
|
|
4076
|
+
// A detached-worker hold's drain phase has the same rule; its worker
|
|
4077
|
+
// phase never does (see `BridgeTuning.backgroundTaskMaxMs`).
|
|
4078
|
+
this.noteBackgroundHoldActivity();
|
|
4079
|
+
const update = typed.update;
|
|
4080
|
+
// The dispatch / worker / announcement bookkeeping runs BEFORE the cursor
|
|
4081
|
+
// guard: those are turn-level facts, not segment content, and an update
|
|
4082
|
+
// withheld by the ambient buffer must still keep them.
|
|
4083
|
+
this.trackBackgroundDispatch(update);
|
|
2758
4084
|
if (typed.seq <= this.cursorSeq)
|
|
2759
4085
|
return;
|
|
2760
|
-
|
|
4086
|
+
// A background drain's prose is not this turn's answer: keep it out of
|
|
4087
|
+
// the plane (the transcript still has it). Fail-open — nothing is split
|
|
4088
|
+
// until this turn has a request id of its own to compare against.
|
|
4089
|
+
this.noteTurnRequestId(update);
|
|
4090
|
+
if (this.isForeignAmbientText(update)) {
|
|
4091
|
+
this.recordWithoutSegment(update);
|
|
4092
|
+
// Advance past it only when nothing is left unconsumed behind it: the
|
|
4093
|
+
// event that CUT the previous segment is parked for the next one, and
|
|
4094
|
+
// jumping the cursor over it would lose its text for good. The replay
|
|
4095
|
+
// applies the same foreign split, so leaving this one behind is safe.
|
|
4096
|
+
if (typed.seq === this.cursorSeq + 1)
|
|
4097
|
+
this.cursorSeq = typed.seq;
|
|
4098
|
+
return;
|
|
4099
|
+
}
|
|
4100
|
+
// The turn's OWN request id is still unknown and this update belongs to
|
|
4101
|
+
// the background drain: folding it now could make the drain's tick teach
|
|
4102
|
+
// `noteTurnRequestId` the drain's id, mixing the drain's text into the
|
|
4103
|
+
// user's answer. Withhold it until the prompt response decides what this
|
|
4104
|
+
// turn is (see `pendingAmbientUpdates`); the cursor moves on because the
|
|
4105
|
+
// transcript already holds the entry.
|
|
4106
|
+
if (this.turnRequestId === undefined
|
|
4107
|
+
&& this.awaitingAmbient === undefined
|
|
4108
|
+
&& this.isAmbientOwned(update)) {
|
|
4109
|
+
this.bufferAmbientUpdate(typed.seq, update);
|
|
4110
|
+
this.cursorSeq = typed.seq;
|
|
4111
|
+
return;
|
|
4112
|
+
}
|
|
4113
|
+
// Live-only burst accounting runs BEFORE the segment cursor: prose that
|
|
4114
|
+
// arrives between segments still belongs to this turn's output, and its
|
|
4115
|
+
// gaps are real. Replay never reaches this path.
|
|
4116
|
+
this.trackTps(typed);
|
|
2761
4117
|
const seg = this.segment;
|
|
2762
4118
|
if (seg === undefined || seg.finished) {
|
|
2763
4119
|
// Between segments: tool outputs still reach the shared registry (the
|
|
@@ -2770,18 +4126,81 @@ class TurnStreamController {
|
|
|
2770
4126
|
}
|
|
2771
4127
|
// The cursor advances only past events fully consumed into this
|
|
2772
4128
|
// segment; the event that CUTS the segment belongs to the next one.
|
|
2773
|
-
if (this.foldInto(seg, update))
|
|
4129
|
+
if (this.foldInto(seg, update, typed.seq))
|
|
2774
4130
|
this.cursorSeq = typed.seq;
|
|
2775
4131
|
}
|
|
2776
4132
|
else if (row.type === 'permission/request') {
|
|
4133
|
+
this.tps.onBreak();
|
|
2777
4134
|
const typed = event;
|
|
2778
4135
|
this.pushProse(this.permissionLine(typed));
|
|
2779
4136
|
}
|
|
2780
4137
|
else if (row.type === 'permission/expired') {
|
|
4138
|
+
this.tps.onBreak();
|
|
2781
4139
|
const typed = event;
|
|
2782
4140
|
this.pushProse(this.expiredLine(typed));
|
|
2783
4141
|
}
|
|
2784
4142
|
}
|
|
4143
|
+
/** Feed one live update into the turn's burst tracker. Never called by replay. */
|
|
4144
|
+
trackTps(typed) {
|
|
4145
|
+
if (typed.seq <= this.tpsSeq)
|
|
4146
|
+
return;
|
|
4147
|
+
this.tpsSeq = typed.seq;
|
|
4148
|
+
const update = typed.update;
|
|
4149
|
+
const kind = String(update.sessionUpdate ?? '');
|
|
4150
|
+
if (kind === 'agent_thought_chunk' || kind === 'agent_message_chunk') {
|
|
4151
|
+
const text = chunkTextOf(update.content);
|
|
4152
|
+
if (text === '')
|
|
4153
|
+
return;
|
|
4154
|
+
this.tps.onProse(text, Date.now());
|
|
4155
|
+
this.pushLiveTps();
|
|
4156
|
+
}
|
|
4157
|
+
else if (kind === 'tool_call' || kind === 'tool_call_update') {
|
|
4158
|
+
this.tps.onBreak();
|
|
4159
|
+
}
|
|
4160
|
+
}
|
|
4161
|
+
/** Throttled live aggregate so the dock's speed moves during a long turn. */
|
|
4162
|
+
pushLiveTps() {
|
|
4163
|
+
const dshSessionId = this.session.dshSessionId;
|
|
4164
|
+
if (dshSessionId === undefined)
|
|
4165
|
+
return;
|
|
4166
|
+
const now = Date.now();
|
|
4167
|
+
if (now - this.lastTpsPushAt < TPS_PUSH_INTERVAL_MS)
|
|
4168
|
+
return;
|
|
4169
|
+
const snap = this.tps.snapshot();
|
|
4170
|
+
if (snap.outputTokens <= 0 || snap.burstMs <= 0)
|
|
4171
|
+
return;
|
|
4172
|
+
this.lastTpsPushAt = now;
|
|
4173
|
+
const base = this.session.burst ?? { outputTokens: 0, burstMs: 0 };
|
|
4174
|
+
this.hub.broadcast({
|
|
4175
|
+
type: 'turn/metrics',
|
|
4176
|
+
sessionId: this.session.sessionId,
|
|
4177
|
+
dshSessionId,
|
|
4178
|
+
turnId: this.turnId,
|
|
4179
|
+
outputTokens: base.outputTokens + snap.outputTokens,
|
|
4180
|
+
burstMs: base.burstMs + snap.burstMs,
|
|
4181
|
+
final: false,
|
|
4182
|
+
});
|
|
4183
|
+
}
|
|
4184
|
+
/** Close the turn's metrics: persist the measurable part, then broadcast it. */
|
|
4185
|
+
finalizeTps() {
|
|
4186
|
+
const dshSessionId = this.session.dshSessionId;
|
|
4187
|
+
if (dshSessionId === undefined)
|
|
4188
|
+
return;
|
|
4189
|
+
const snap = this.tps.snapshot();
|
|
4190
|
+
if (snap.outputTokens > 0 && snap.burstMs > 0) {
|
|
4191
|
+
this.manager.noteSessionBurst(this.session, snap.outputTokens, snap.burstMs);
|
|
4192
|
+
}
|
|
4193
|
+
const aggregate = this.session.burst ?? { outputTokens: 0, burstMs: 0 };
|
|
4194
|
+
this.hub.broadcast({
|
|
4195
|
+
type: 'turn/metrics',
|
|
4196
|
+
sessionId: this.session.sessionId,
|
|
4197
|
+
dshSessionId,
|
|
4198
|
+
turnId: this.turnId,
|
|
4199
|
+
outputTokens: aggregate.outputTokens,
|
|
4200
|
+
burstMs: aggregate.burstMs,
|
|
4201
|
+
final: true,
|
|
4202
|
+
});
|
|
4203
|
+
}
|
|
2785
4204
|
/**
|
|
2786
4205
|
* Put one narrative line into the turn's prose stream.
|
|
2787
4206
|
*
|
|
@@ -2818,15 +4237,124 @@ class TurnStreamController {
|
|
|
2818
4237
|
return;
|
|
2819
4238
|
recordAcpToolOutput(callId, updateContentToText(update.content));
|
|
2820
4239
|
}
|
|
4240
|
+
/**
|
|
4241
|
+
* Whether this update belongs to a background drain — the one the session is
|
|
4242
|
+
* waiting on, or one whose wait already closed (retired) but whose run can
|
|
4243
|
+
* still speak.
|
|
4244
|
+
*/
|
|
4245
|
+
isAmbientOwned(update) {
|
|
4246
|
+
const requestId = codebuddyMetaOf(update)?.requestId;
|
|
4247
|
+
if (requestId === undefined)
|
|
4248
|
+
return false;
|
|
4249
|
+
return requestId === this.session.ambient?.requestId
|
|
4250
|
+
|| isRetiredAmbientId(this.session, requestId);
|
|
4251
|
+
}
|
|
4252
|
+
/** Learn this turn's own CodeBuddy request id from the first update naming one. */
|
|
4253
|
+
noteTurnRequestId(update) {
|
|
4254
|
+
if (this.turnRequestId !== undefined)
|
|
4255
|
+
return;
|
|
4256
|
+
const meta = codebuddyMetaOf(update);
|
|
4257
|
+
// A detached worker's update names A WORKER, never this turn: adopting it
|
|
4258
|
+
// would let the worker's own turn pass as the session's answer.
|
|
4259
|
+
if (meta?.parentToolCallId !== undefined)
|
|
4260
|
+
return;
|
|
4261
|
+
// While a hold runs, the only ids this turn owns are the drain ids it
|
|
4262
|
+
// accepted (see `BackgroundHold.acceptedIds`) — and those belong to the
|
|
4263
|
+
// background run, never to a second "own turn id" that would outlive the
|
|
4264
|
+
// hold. Nothing else may be adopted.
|
|
4265
|
+
if (this.activeBackgroundHold() !== undefined)
|
|
4266
|
+
return;
|
|
4267
|
+
const requestId = meta?.requestId;
|
|
4268
|
+
// A background drain's update names THE DRAIN, never this turn: adopting it
|
|
4269
|
+
// would make `isForeignAmbientText` pass the drain's own text through. A
|
|
4270
|
+
// RETIRED drain (its wait closed on silence, the run revived) is the same
|
|
4271
|
+
// trap — that id belongs to the background run whichever turn is on air.
|
|
4272
|
+
if (requestId === undefined || requestId === this.session.ambient?.requestId)
|
|
4273
|
+
return;
|
|
4274
|
+
if (isRetiredAmbientId(this.session, requestId))
|
|
4275
|
+
return;
|
|
4276
|
+
this.turnRequestId = requestId;
|
|
4277
|
+
}
|
|
4278
|
+
/** Adopt the request id the prompt RESPONSE declared for this turn. */
|
|
4279
|
+
adoptTurnRequestId(requestId) {
|
|
4280
|
+
// A retired drain id is never this turn's own: adopting it would make the
|
|
4281
|
+
// background run's prose pass `isForeignAmbientText` as the answer. The
|
|
4282
|
+
// response still ends a normal turn, so the withheld drain buffer is
|
|
4283
|
+
// dropped either way (the transcript keeps it).
|
|
4284
|
+
if (!isRetiredAmbientId(this.session, requestId))
|
|
4285
|
+
this.turnRequestId = requestId;
|
|
4286
|
+
// A normal turn: whatever the drain streamed while the id was unknown was
|
|
4287
|
+
// never this turn's answer. Drop it (the transcript keeps it) — it must not
|
|
4288
|
+
// surface when a later segment replays or when the turn flushes anything.
|
|
4289
|
+
this.clearPendingAmbient();
|
|
4290
|
+
}
|
|
4291
|
+
/**
|
|
4292
|
+
* Prose of the session's background drain while this turn runs on its OWN
|
|
4293
|
+
* request id: it belongs to the background run, not to the user's answer.
|
|
4294
|
+
* Fail-open by design — an unknown turn id splits nothing, and a turn that
|
|
4295
|
+
* was itself folded into the drain keeps its prose.
|
|
4296
|
+
*/
|
|
4297
|
+
isForeignAmbientText(update) {
|
|
4298
|
+
const meta = codebuddyMetaOf(update);
|
|
4299
|
+
const requestId = meta?.requestId;
|
|
4300
|
+
// A detached worker's own turn is never this session's answer, whichever
|
|
4301
|
+
// turn is on air (the fold-level guard lives in `foldInto` as well, for
|
|
4302
|
+
// the replay paths that never ask this question). This includes a worker
|
|
4303
|
+
// update that also names a drain id: its prose is the WORKER's.
|
|
4304
|
+
if (meta?.parentToolCallId !== undefined)
|
|
4305
|
+
return true;
|
|
4306
|
+
// A detached-worker hold owns the drain phase: any run it accepted (the
|
|
4307
|
+
// response's id, an announcement that named a different one) is this
|
|
4308
|
+
// turn's answer, and every OTHER id is background noise — whatever the
|
|
4309
|
+
// turn's own id says. Parked holds count: the response already owns them.
|
|
4310
|
+
const hold = this.activeBackgroundHold();
|
|
4311
|
+
if (hold !== undefined) {
|
|
4312
|
+
if (requestId === undefined)
|
|
4313
|
+
return false;
|
|
4314
|
+
return !hold.acceptedIds.has(requestId);
|
|
4315
|
+
}
|
|
4316
|
+
// The drain THIS turn is waiting on owns its text unconditionally: the
|
|
4317
|
+
// wait was opened for this id (a revived drain may still carry a retired
|
|
4318
|
+
// mark if the update raced the revival), so the whole steered answer must
|
|
4319
|
+
// reach the user instead of being filtered as background noise.
|
|
4320
|
+
if (requestId !== undefined && this.awaitingAmbient?.requestId === requestId)
|
|
4321
|
+
return false;
|
|
4322
|
+
// A RETIRED drain is foreign even though its record is gone: the quiet
|
|
4323
|
+
// close only means WE stopped waiting, not that the run ended. Its late
|
|
4324
|
+
// text must never join the turn now on air — even before that turn has
|
|
4325
|
+
// learned a request id of its own (the fail-open below does not apply to
|
|
4326
|
+
// an id known to be a background run).
|
|
4327
|
+
if (isRetiredAmbientId(this.session, requestId))
|
|
4328
|
+
return true;
|
|
4329
|
+
if (this.turnRequestId === undefined)
|
|
4330
|
+
return false;
|
|
4331
|
+
if (requestId === undefined || requestId === this.turnRequestId)
|
|
4332
|
+
return false;
|
|
4333
|
+
const ambient = this.session.ambient;
|
|
4334
|
+
return ambient !== undefined && requestId === ambient.requestId;
|
|
4335
|
+
}
|
|
2821
4336
|
/**
|
|
2822
4337
|
* The turn's whole fold, shared by live events and segment replay. Visible
|
|
2823
4338
|
* non-tool events END a card-bearing segment instead of joining it — that is
|
|
2824
4339
|
* the segmentation rule that puts each card between two assistant messages.
|
|
2825
4340
|
* Returns whether the event was consumed into this segment: the cutting
|
|
2826
4341
|
* event is left unconsumed (and thus behind the cursor) for the next one.
|
|
4342
|
+
*
|
|
4343
|
+
* `seq` is the update's transcript position when the caller has one: the
|
|
4344
|
+
* detached-worker hold uses it to count only the answer text that arrived
|
|
4345
|
+
* AFTER the hold began (a parked hold replays the parent's earlier ack).
|
|
2827
4346
|
*/
|
|
2828
|
-
foldInto(seg, update) {
|
|
4347
|
+
foldInto(seg, update, seq) {
|
|
2829
4348
|
const kind = String(update.sessionUpdate ?? '');
|
|
4349
|
+
// A DETACHED WORKER's own turn: its words and tools are the worker's, not
|
|
4350
|
+
// this session's answer. The transcript keeps them (the ACP view is where
|
|
4351
|
+
// the user can read them) and the shared tool-output registry still sees
|
|
4352
|
+
// the call, but nothing reaches this turn's message and no card opens for
|
|
4353
|
+
// the host to execute. This applies to EVERY turn, not just a hold.
|
|
4354
|
+
if (codebuddyMetaOf(update)?.parentToolCallId !== undefined) {
|
|
4355
|
+
this.noteToolRecord(update);
|
|
4356
|
+
return true;
|
|
4357
|
+
}
|
|
2830
4358
|
if (kind === 'agent_thought_chunk' || kind === 'agent_message_chunk') {
|
|
2831
4359
|
const text = chunkTextOf(update.content);
|
|
2832
4360
|
if (text === '')
|
|
@@ -2836,11 +4364,36 @@ class TurnStreamController {
|
|
|
2836
4364
|
return false;
|
|
2837
4365
|
}
|
|
2838
4366
|
seg.outputChars += text.length;
|
|
4367
|
+
// "The turn's own answer", as opposed to a background drain's prose that
|
|
4368
|
+
// happens to stream through the same session (see `isForeignAmbientText`).
|
|
4369
|
+
if (!this.isAmbientOwned(update))
|
|
4370
|
+
this.sawAgentProse = true;
|
|
4371
|
+
if (kind === 'agent_message_chunk') {
|
|
4372
|
+
this.sawVisibleText = true;
|
|
4373
|
+
// A hold's closing line asks whether ITS runs delivered answer text.
|
|
4374
|
+
// The parent's interim acknowledgement arrived before the hold and
|
|
4375
|
+
// already set `sawVisibleText`, so the hold keeps its own counter —
|
|
4376
|
+
// and only text that arrived after the hold began counts (a parked
|
|
4377
|
+
// hold replays that earlier ack from the transcript).
|
|
4378
|
+
const hold = this.activeBackgroundHold();
|
|
4379
|
+
if (hold !== undefined && (seq === undefined || seq > hold.floorSeq)) {
|
|
4380
|
+
hold.answerChars += text.length;
|
|
4381
|
+
}
|
|
4382
|
+
}
|
|
2839
4383
|
this.emitProse(seg, kind === 'agent_thought_chunk' ? 'reasoning' : 'text', text);
|
|
2840
4384
|
return true;
|
|
2841
4385
|
}
|
|
2842
4386
|
else if (kind === 'tool_call' || kind === 'tool_call_update') {
|
|
2843
4387
|
this.noteToolRecord(update);
|
|
4388
|
+
if (seg.ambient || this.isAmbientOwned(update)) {
|
|
4389
|
+
// A background drain's tools stay in the transcript: opening a card
|
|
4390
|
+
// would make the host execute a tool and propose a continuation step
|
|
4391
|
+
// for a turn whose answer has not started yet. The ownership check
|
|
4392
|
+
// covers a NORMAL turn too — its own segment is not ambient, but a
|
|
4393
|
+
// drain can stream a tool call on the same session while it runs, and
|
|
4394
|
+
// that call is not this turn's work.
|
|
4395
|
+
return true;
|
|
4396
|
+
}
|
|
2844
4397
|
const snapshot = acpToolCallFromUpdate(update);
|
|
2845
4398
|
if (snapshot === undefined)
|
|
2846
4399
|
return true;
|
|
@@ -2854,6 +4407,13 @@ class TurnStreamController {
|
|
|
2854
4407
|
}
|
|
2855
4408
|
if (this.seenCalls.has(snapshot.callId))
|
|
2856
4409
|
return true; // a restatement of a card already settled
|
|
4410
|
+
// A BARE status-only update for a call this turn never announced is not
|
|
4411
|
+
// a card. The real wire sends the detached dispatch's completion exactly
|
|
4412
|
+
// that way (no `_meta`, no title, no input); a turn that begins after the
|
|
4413
|
+
// dispatch belongs to another turn's card, and opening one here would
|
|
4414
|
+
// make the host execute a phantom tool.
|
|
4415
|
+
if (kind === 'tool_call_update' && snapshot.name === '' && snapshot.argsRaw === '')
|
|
4416
|
+
return true;
|
|
2857
4417
|
this.seenCalls.add(snapshot.callId);
|
|
2858
4418
|
this.openToolCallBlock(seg, snapshot);
|
|
2859
4419
|
return true;
|
|
@@ -3094,6 +4654,32 @@ export class BridgeManager {
|
|
|
3094
4654
|
turnStreamFor(dshSessionId) {
|
|
3095
4655
|
return this.turnStreams.get(dshSessionId);
|
|
3096
4656
|
}
|
|
4657
|
+
/**
|
|
4658
|
+
* The ambient windows a turn's controller reads once at construction (see
|
|
4659
|
+
* {@link BridgeTuning.ambientQuietMs}). Exposed as a plain snapshot so the
|
|
4660
|
+
* controller never pokes at the manager's tuning object, and tests can tune
|
|
4661
|
+
* the cadence through the constructor like every other window.
|
|
4662
|
+
*/
|
|
4663
|
+
get ambientTuning() {
|
|
4664
|
+
return {
|
|
4665
|
+
quietMs: this.tuning.ambientQuietMs,
|
|
4666
|
+
maxMs: this.tuning.ambientMaxMs,
|
|
4667
|
+
};
|
|
4668
|
+
}
|
|
4669
|
+
/**
|
|
4670
|
+
* The detached-worker hold's windows, read once at controller construction
|
|
4671
|
+
* for the same reason as {@link ambientTuning}: `taskMaxMs` is the worker
|
|
4672
|
+
* phase's only clock (see {@link BridgeTuning.backgroundTaskMaxMs}) and
|
|
4673
|
+
* `graceMs` the bounded wait a PARKED hold gives the host to open its
|
|
4674
|
+
* continuation step (see {@link BridgeTuning.backgroundParkedGraceMs}). The
|
|
4675
|
+
* drain phase reuses {@link ambientTuning}.
|
|
4676
|
+
*/
|
|
4677
|
+
get backgroundTuning() {
|
|
4678
|
+
return {
|
|
4679
|
+
taskMaxMs: this.tuning.backgroundTaskMaxMs,
|
|
4680
|
+
graceMs: this.tuning.backgroundParkedGraceMs,
|
|
4681
|
+
};
|
|
4682
|
+
}
|
|
3097
4683
|
/** Index a turn's controller, retiring whatever turn it supersedes. */
|
|
3098
4684
|
rememberTurnStream(dshSessionId, controller) {
|
|
3099
4685
|
const previous = this.turnStreams.get(dshSessionId);
|
|
@@ -3284,6 +4870,12 @@ export class BridgeManager {
|
|
|
3284
4870
|
setSessionWorkspaceResolver(resolver) {
|
|
3285
4871
|
this.sessionWorkspaceResolver = resolver;
|
|
3286
4872
|
}
|
|
4873
|
+
/**
|
|
4874
|
+
* Set the attachment service or context used to resolve image attachments in user prompts.
|
|
4875
|
+
*/
|
|
4876
|
+
setAttachmentResolver(resolver) {
|
|
4877
|
+
this.attachmentResolver = resolver;
|
|
4878
|
+
}
|
|
3287
4879
|
/**
|
|
3288
4880
|
* Wire the ACP-mode probe: given a DSH session id, report whether that
|
|
3289
4881
|
* session runs the ACP agent preset. The host half reads it back from the
|
|
@@ -3404,6 +4996,7 @@ export class BridgeManager {
|
|
|
3404
4996
|
...(session.modes === undefined ? {} : { modes: session.modes }),
|
|
3405
4997
|
...(session.availableCommands === undefined ? {} : { availableCommands: session.availableCommands }),
|
|
3406
4998
|
...(session.usage === undefined ? {} : { usage: session.usage }),
|
|
4999
|
+
...(session.burst === undefined ? {} : { burst: session.burst }),
|
|
3407
5000
|
...(session.released ? { released: true } : {}),
|
|
3408
5001
|
...(session.running ? { running: true } : {}),
|
|
3409
5002
|
...forkRowInfo(session, this.canForkNow(session.agentId)),
|
|
@@ -4316,6 +5909,21 @@ export class BridgeManager {
|
|
|
4316
5909
|
for (const dshSessionId of this.chatAgentIntent.keys())
|
|
4317
5910
|
this.syncConversationRoute(dshSessionId);
|
|
4318
5911
|
}
|
|
5912
|
+
/**
|
|
5913
|
+
* Fold one settled ACP turn's measurable burst into the session aggregate and
|
|
5914
|
+
* persist it. A turn with no measured inter-chunk gap contributes neither
|
|
5915
|
+
* side: adding its tokens with zero time would inflate the session rate.
|
|
5916
|
+
*/
|
|
5917
|
+
noteSessionBurst(session, outputTokens, burstMs) {
|
|
5918
|
+
if (outputTokens <= 0 || burstMs <= 0)
|
|
5919
|
+
return;
|
|
5920
|
+
const base = session.burst ?? { outputTokens: 0, burstMs: 0 };
|
|
5921
|
+
session.burst = {
|
|
5922
|
+
outputTokens: base.outputTokens + outputTokens,
|
|
5923
|
+
burstMs: base.burstMs + burstMs,
|
|
5924
|
+
};
|
|
5925
|
+
this.persistAgents();
|
|
5926
|
+
}
|
|
4319
5927
|
persistAgents() {
|
|
4320
5928
|
const filePath = this.getStoragePath();
|
|
4321
5929
|
if (!filePath)
|
|
@@ -4390,7 +5998,7 @@ export class BridgeManager {
|
|
|
4390
5998
|
continue;
|
|
4391
5999
|
if (!this.defsMap.has(agentId))
|
|
4392
6000
|
continue;
|
|
4393
|
-
const { title, modelId, reasoningEffort, modes, usage, hasHistory } = record;
|
|
6001
|
+
const { title, modelId, reasoningEffort, modes, usage, burst, hasHistory } = record;
|
|
4394
6002
|
const session = {
|
|
4395
6003
|
sessionId,
|
|
4396
6004
|
acpSessionId,
|
|
@@ -4404,6 +6012,7 @@ export class BridgeManager {
|
|
|
4404
6012
|
...(typeof reasoningEffort === 'string' && reasoningEffort !== '' ? { reasoningEffort } : {}),
|
|
4405
6013
|
...(isPersistedModes(modes) ? { modes } : {}),
|
|
4406
6014
|
...(isPersistedUsage(usage) ? { usage } : {}),
|
|
6015
|
+
...(isPersistedBurst(burst) ? { burst } : {}),
|
|
4407
6016
|
...(hasHistory === true ? { hasHistory: true } : {}),
|
|
4408
6017
|
running: false,
|
|
4409
6018
|
turnSeq: 0,
|
|
@@ -5320,7 +6929,7 @@ export class BridgeManager {
|
|
|
5320
6929
|
const session = await this.bridge(agentId).createSession(cwd);
|
|
5321
6930
|
return { sessionId: session.sessionId };
|
|
5322
6931
|
}
|
|
5323
|
-
async prompt(sessionId,
|
|
6932
|
+
async prompt(sessionId, prompt) {
|
|
5324
6933
|
let located = await this.locateOrResume(sessionId);
|
|
5325
6934
|
// The watchdog abandoned a previous turn here and the agent may still be
|
|
5326
6935
|
// holding its prompt slot on the old process: reclaim BEFORE sending, or
|
|
@@ -5339,8 +6948,36 @@ export class BridgeManager {
|
|
|
5339
6948
|
located = await this.locateOrResume(sessionId);
|
|
5340
6949
|
}
|
|
5341
6950
|
const { bridge, session } = located;
|
|
5342
|
-
|
|
6951
|
+
// The agent prompt slot is SHARED with chat turns: `running` alone misses
|
|
6952
|
+
// a chat turn whose cancellation already released it (`running` false,
|
|
6953
|
+
// `promptInFlight` still naming the agent-side turn), and a live chat
|
|
6954
|
+
// controller means a continuation step may resume the SAME agent turn.
|
|
6955
|
+
// A controller that already SETTLED (kept only so its card's continuation
|
|
6956
|
+
// can still stream) has given the agent slot back: `/acp` may use it, and
|
|
6957
|
+
// advancing `turnSeq` retires it — the behaviour the stale-controller
|
|
6958
|
+
// watchdog case pins.
|
|
6959
|
+
const chatController = session.turnController;
|
|
6960
|
+
if (session.running
|
|
6961
|
+
|| session.promptInFlight === true
|
|
6962
|
+
|| (chatController !== undefined && !chatController.stale && !chatController.hasSettled)) {
|
|
5343
6963
|
throw new AcpError('turn_in_flight', 'acp: this session already has a prompt in flight');
|
|
6964
|
+
}
|
|
6965
|
+
const blocks = typeof prompt === 'string'
|
|
6966
|
+
? [{ type: 'text', text: prompt }]
|
|
6967
|
+
: prompt;
|
|
6968
|
+
if (blocks.length === 0) {
|
|
6969
|
+
throw new AcpError('empty_prompt', 'acp: prompt cannot be empty');
|
|
6970
|
+
}
|
|
6971
|
+
const hasImages = blocks.some(b => b.type === 'image');
|
|
6972
|
+
if (hasImages && bridge.initResult?.agentCapabilities?.promptCapabilities?.image !== true) {
|
|
6973
|
+
throw new AcpError('image_unsupported', `acp: agent "${bridge.def.id}" does not support image input`);
|
|
6974
|
+
}
|
|
6975
|
+
// Claim the SAME slot the chat path claims (`claimTurn` + `beginTurn`): an
|
|
6976
|
+
// `/acp` turn in flight must make the next chat prompt wait, and vice
|
|
6977
|
+
// versa. The settler is idempotent and identity-guarded; the turn still
|
|
6978
|
+
// ends through `turn/end` below, so chat and RPC turns share one gate
|
|
6979
|
+
// without sharing a stream controller.
|
|
6980
|
+
const endTurn = bridge.beginTurn(session);
|
|
5344
6981
|
session.running = true;
|
|
5345
6982
|
session.turnSeq += 1;
|
|
5346
6983
|
const turnGen = session.turnSeq;
|
|
@@ -5351,9 +6988,12 @@ export class BridgeManager {
|
|
|
5351
6988
|
session.reclaimedTurnSeq = undefined;
|
|
5352
6989
|
this.armStallTimer();
|
|
5353
6990
|
const turnId = randomUUID();
|
|
5354
|
-
|
|
5355
|
-
|
|
5356
|
-
.
|
|
6991
|
+
const promptText = typeof prompt === 'string'
|
|
6992
|
+
? prompt
|
|
6993
|
+
: blocks.filter(b => b.type === 'text').map(b => b.text).join('\n');
|
|
6994
|
+
this.hub.broadcast({ type: 'turn/start', sessionId, turnId, text: promptText });
|
|
6995
|
+
void bridge.runPrompt(session, blocks, turnId, turnGen)
|
|
6996
|
+
.then(result => {
|
|
5357
6997
|
// The reclaim already ended this turn locally; the answer it caused is
|
|
5358
6998
|
// that same turn reporting back, not a second turn end for the caller.
|
|
5359
6999
|
if (session.reclaimedTurnSeq === turnGen) {
|
|
@@ -5364,8 +7004,11 @@ export class BridgeManager {
|
|
|
5364
7004
|
if (session.turnSeq !== turnGen)
|
|
5365
7005
|
return;
|
|
5366
7006
|
this.clearOrphanPrompt(session, turnGen);
|
|
7007
|
+
// Release the shared slot BEFORE the terminal frame: a listener that
|
|
7008
|
+
// sends the next prompt on `turn/end` must not read a stale claim.
|
|
7009
|
+
endTurn();
|
|
5367
7010
|
session.running = false;
|
|
5368
|
-
this.hub.broadcast({ type: 'turn/end', sessionId, turnId, stopReason });
|
|
7011
|
+
this.hub.broadcast({ type: 'turn/end', sessionId, turnId, stopReason: result.stopReason });
|
|
5369
7012
|
})
|
|
5370
7013
|
.catch((error) => {
|
|
5371
7014
|
if (session.reclaimedTurnSeq === turnGen) {
|
|
@@ -5383,6 +7026,7 @@ export class BridgeManager {
|
|
|
5383
7026
|
return;
|
|
5384
7027
|
}
|
|
5385
7028
|
this.clearOrphanPrompt(session, turnGen);
|
|
7029
|
+
endTurn();
|
|
5386
7030
|
session.running = false;
|
|
5387
7031
|
this.hub.broadcast({
|
|
5388
7032
|
type: 'turn/end',
|
|
@@ -5390,7 +7034,10 @@ export class BridgeManager {
|
|
|
5390
7034
|
turnId,
|
|
5391
7035
|
error: describeError(error),
|
|
5392
7036
|
});
|
|
5393
|
-
})
|
|
7037
|
+
})
|
|
7038
|
+
// Every outcome — normal answer, failure, reclaim early-return, or a
|
|
7039
|
+
// close's rejection — releases the shared prompt slot exactly once.
|
|
7040
|
+
.finally(() => { endTurn(); });
|
|
5394
7041
|
return { turnId };
|
|
5395
7042
|
}
|
|
5396
7043
|
/** The late answer landed (or failed): the turn is no longer an orphan. */
|
|
@@ -5698,6 +7345,7 @@ export class BridgeManager {
|
|
|
5698
7345
|
}
|
|
5699
7346
|
sessionWorkspaceResolver;
|
|
5700
7347
|
acpModeResolver;
|
|
7348
|
+
attachmentResolver;
|
|
5701
7349
|
/** Cached `chatSessionAcpMode` answers, keyed by DSH chat session id. */
|
|
5702
7350
|
acpModeCache = new Map();
|
|
5703
7351
|
/** In-flight cold reads behind `chatSessionAcpModeAnswer`, keyed by session id. */
|
|
@@ -6074,8 +7722,39 @@ export class BridgeManager {
|
|
|
6074
7722
|
// Forward the person's own words: the host folds its context sections
|
|
6075
7723
|
// (AGENTS.md digest, skill catalog, runtime snapshot) after the claimed user
|
|
6076
7724
|
// batch precisely so a consumer can tell them apart by `source` — see
|
|
6077
|
-
|
|
6078
|
-
const
|
|
7725
|
+
const workspaceRoot = chatWorkspaceRoot(this.sessionWorkspaceResolver, options.sessionId);
|
|
7726
|
+
const defaultReadFile = (p) => {
|
|
7727
|
+
try {
|
|
7728
|
+
if (existsSync(p))
|
|
7729
|
+
return readFileSync(p);
|
|
7730
|
+
if (workspaceRoot !== undefined && !isAbsolutePath(p)) {
|
|
7731
|
+
const joined = join(workspaceRoot, p);
|
|
7732
|
+
if (existsSync(joined))
|
|
7733
|
+
return readFileSync(joined);
|
|
7734
|
+
}
|
|
7735
|
+
}
|
|
7736
|
+
catch { }
|
|
7737
|
+
return undefined;
|
|
7738
|
+
};
|
|
7739
|
+
const resolver = this.attachmentResolver;
|
|
7740
|
+
const attachmentContext = resolver !== undefined && typeof resolver === 'object' && resolver !== null
|
|
7741
|
+
? Object.assign(Object.create(resolver), {
|
|
7742
|
+
readFile: (p) => {
|
|
7743
|
+
if (typeof resolver.readFile === 'function') {
|
|
7744
|
+
try {
|
|
7745
|
+
const res = resolver.readFile(p);
|
|
7746
|
+
if (res !== undefined)
|
|
7747
|
+
return res;
|
|
7748
|
+
}
|
|
7749
|
+
catch { }
|
|
7750
|
+
}
|
|
7751
|
+
return defaultReadFile(p);
|
|
7752
|
+
},
|
|
7753
|
+
})
|
|
7754
|
+
: {
|
|
7755
|
+
readFile: defaultReadFile,
|
|
7756
|
+
};
|
|
7757
|
+
const selection = selectForwardPrompt(options.messages ?? [], attachmentContext);
|
|
6079
7758
|
if (selection.kind === 'no-user-input') {
|
|
6080
7759
|
// An injected-context wake: nothing the user said, nothing to send.
|
|
6081
7760
|
yield { type: 'finish', reason: { kind: 'stop' } };
|
|
@@ -6140,15 +7819,31 @@ export class BridgeManager {
|
|
|
6140
7819
|
};
|
|
6141
7820
|
return;
|
|
6142
7821
|
}
|
|
7822
|
+
const hasImages = (selection.images?.length ?? 0) > 0 || (selection.content?.some(b => b.type === 'image') ?? false);
|
|
7823
|
+
if (hasImages && bridge.initResult?.agentCapabilities?.promptCapabilities?.image !== true) {
|
|
7824
|
+
if (session.provisionalLock === true)
|
|
7825
|
+
this.rollbackFirstTurn(session);
|
|
7826
|
+
yield {
|
|
7827
|
+
type: 'finish',
|
|
7828
|
+
reason: {
|
|
7829
|
+
kind: 'error',
|
|
7830
|
+
failure: {
|
|
7831
|
+
message: `acp: agent "${agentId}" does not support image input`,
|
|
7832
|
+
code: 'image_unsupported',
|
|
7833
|
+
},
|
|
7834
|
+
},
|
|
7835
|
+
};
|
|
7836
|
+
return;
|
|
7837
|
+
}
|
|
6143
7838
|
// One prompt slot per session, and a turn the user aborted still occupies
|
|
6144
7839
|
// the AGENT's slot until it acknowledges the cancel (see `claimTurn`).
|
|
6145
7840
|
const claim = await bridge.claimTurn(session);
|
|
6146
7841
|
if (!claim.ok) {
|
|
6147
|
-
//
|
|
6148
|
-
//
|
|
6149
|
-
//
|
|
6150
|
-
|
|
6151
|
-
|
|
7842
|
+
// Never roll the provisional lock back here: a failed claim means ANOTHER
|
|
7843
|
+
// prompt holds the slot (or the agent has not settled it), and that turn
|
|
7844
|
+
// owns the conversation's lock. Closing the session would kill a turn
|
|
7845
|
+
// that is actively streaming — the refusal already tells the user to
|
|
7846
|
+
// retry.
|
|
6152
7847
|
yield {
|
|
6153
7848
|
type: 'finish',
|
|
6154
7849
|
reason: {
|
|
@@ -6280,8 +7975,11 @@ export class BridgeManager {
|
|
|
6280
7975
|
// next turn has to respect. Settlement reaches the controller, which
|
|
6281
7976
|
// releases the slot (`endTurn`) whether the segment on air is the first
|
|
6282
7977
|
// or a continuation one.
|
|
6283
|
-
|
|
6284
|
-
.
|
|
7978
|
+
const promptBlocks = selection.content && selection.content.length > 0
|
|
7979
|
+
? selection.content
|
|
7980
|
+
: [{ type: 'text', text: promptText }];
|
|
7981
|
+
bridge.runPrompt(session, promptBlocks, turnId, turnGen)
|
|
7982
|
+
.then(result => {
|
|
6285
7983
|
// The reclaim already ended this turn locally, and the close it
|
|
6286
7984
|
// performed rejected this very request: the late answer is that same
|
|
6287
7985
|
// turn, not a second one to broadcast.
|
|
@@ -6289,9 +7987,76 @@ export class BridgeManager {
|
|
|
6289
7987
|
session.reclaimedTurnSeq = undefined;
|
|
6290
7988
|
return;
|
|
6291
7989
|
}
|
|
6292
|
-
|
|
7990
|
+
const responseMeta = codebuddyMetaOf({ _meta: result.meta });
|
|
7991
|
+
// A DETACHED-WORKER HOLD: this turn's `Agent` call handed real work
|
|
7992
|
+
// to a background worker that has not produced its answer yet, yet
|
|
7993
|
+
// the agent answered the prompt normally (CodeBuddy says "已派…" and
|
|
7994
|
+
// ends its own turn). Closing here loses the only answer the turn
|
|
7995
|
+
// will ever produce — the worker's report streams into this same
|
|
7996
|
+
// session later, as a background drain. Hold the turn open until
|
|
7997
|
+
// every dispatch reports and the drain goes quiet (see
|
|
7998
|
+
// `holdForBackgroundDispatch`); the prompt slot is released there.
|
|
7999
|
+
//
|
|
8000
|
+
// The decision comes BEFORE `adoptTurnRequestId`: adopting the id
|
|
8001
|
+
// would clear the updates the drain streamed while this turn's own id
|
|
8002
|
+
// was unknown, and those are exactly the ones the hold must fold.
|
|
8003
|
+
const holdDispatch = controller.dispatchedDetachedWorker
|
|
8004
|
+
&& !session.released
|
|
8005
|
+
&& !session.closed
|
|
8006
|
+
&& result.stopReason !== 'cancelled';
|
|
8007
|
+
if (!holdDispatch && responseMeta?.conversationRequestId !== undefined) {
|
|
8008
|
+
// The response names the conversation turn it queued: an instant
|
|
8009
|
+
// answer that still carries one is a real (if silent) turn, and its
|
|
8010
|
+
// prose can be told apart from a background drain's.
|
|
8011
|
+
controller.adoptTurnRequestId(responseMeta.conversationRequestId);
|
|
8012
|
+
}
|
|
8013
|
+
// An announced drain that has shown NO sign of life for the staleness
|
|
8014
|
+
// window is over, even though nothing said so (CodeBuddy never sends
|
|
8015
|
+
// `session_end`). Keeping the record would make a later instant answer
|
|
8016
|
+
// with no conversation request id look steered, and hold a normal turn
|
|
8017
|
+
// open against a drain that is never coming back. RETIRE the id
|
|
8018
|
+
// first, so a late update or a response naming it is never adopted as
|
|
8019
|
+
// this session's own turn; then forget the record (the notice de-dupe
|
|
8020
|
+
// set with it) so this response settles normally.
|
|
8021
|
+
if (session.ambient !== undefined
|
|
8022
|
+
&& Date.now() - session.ambient.lastActivityAt > this.tuning.ambientStaleMs) {
|
|
8023
|
+
retireAmbientId(session, session.ambient.requestId);
|
|
8024
|
+
session.ambient = undefined;
|
|
8025
|
+
session.ambientNotices?.clear();
|
|
8026
|
+
}
|
|
8027
|
+
if (holdDispatch) {
|
|
8028
|
+
// The response may name one run while an announcement named
|
|
8029
|
+
// another; the hold accepts both (see `BackgroundHold.acceptedIds`).
|
|
8030
|
+
controller.holdForBackgroundDispatch(result.stopReason, responseMeta?.conversationRequestId ?? responseMeta?.requestId);
|
|
8031
|
+
return;
|
|
8032
|
+
}
|
|
8033
|
+
// An instant answer with NO conversation request id, while the agent
|
|
8034
|
+
// announced a background drain this turn has not spoken into: the
|
|
8035
|
+
// prompt was folded into that drain (CodeBuddy's multitask). The
|
|
8036
|
+
// response is a formality — keep the turn open until the drain speaks.
|
|
8037
|
+
if (session.ambient !== undefined
|
|
8038
|
+
&& !session.released
|
|
8039
|
+
&& !session.closed
|
|
8040
|
+
&& result.stopReason !== 'cancelled'
|
|
8041
|
+
&& responseMeta?.conversationRequestId === undefined
|
|
8042
|
+
&& !controller.hasAgentProse) {
|
|
8043
|
+
controller.awaitAmbient(session.ambient.requestId, result.stopReason);
|
|
8044
|
+
return;
|
|
8045
|
+
}
|
|
8046
|
+
// No ambient record to wait on, but a drain this session stopped
|
|
8047
|
+
// waiting on was retired moments ago: the run may have taken this
|
|
8048
|
+
// prompt WITHOUT re-announcing itself. The turn cannot be held open
|
|
8049
|
+
// (nothing names the drain), but a silent close must say the output
|
|
8050
|
+
// may be in the transcript instead of blaming the agent.
|
|
8051
|
+
if (session.ambient === undefined
|
|
8052
|
+
&& result.stopReason !== 'cancelled'
|
|
8053
|
+
&& responseMeta?.conversationRequestId === undefined
|
|
8054
|
+
&& hasRecentRetiredAmbient(session, Math.max(this.tuning.ambientQuietMs * 2, RETIRED_AMBIENT_RECENT_MIN_MS))) {
|
|
8055
|
+
controller.noteRecentRetiredDrain();
|
|
8056
|
+
}
|
|
8057
|
+
controller.settle(result.stopReason === 'cancelled'
|
|
6293
8058
|
? { kind: 'cancelled' }
|
|
6294
|
-
: { kind: 'stop', stopReason });
|
|
8059
|
+
: { kind: 'stop', stopReason: result.stopReason });
|
|
6295
8060
|
})
|
|
6296
8061
|
.catch((err) => {
|
|
6297
8062
|
if (session.reclaimedTurnSeq === turnGen) {
|