dsh-ntr 0.3.1 → 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 +3 -1
- package/README.md +3 -1
- package/cordis.patch.yml +17 -1
- package/lib/bridge.js +2436 -82
- package/lib/client/acp-ui.js +103 -3
- package/lib/client/acp-view.js +24 -4
- package/lib/client/activity-pill.js +156 -0
- package/lib/client/branch-action.js +149 -0
- package/lib/client/composer-mode.js +48 -13
- package/lib/client/context-meter.js +86 -0
- package/lib/client/index.js +279 -16
- package/lib/client/model-seat.js +8 -2
- package/lib/client/panel.js +96 -29
- package/lib/client/released-banner.js +66 -0
- package/lib/client/session-controls.js +38 -13
- package/lib/client/slash-isolation.js +2 -2
- package/lib/client/store.js +380 -13
- package/lib/client.js +1364 -70
- package/lib/client.js.map +1 -1
- package/lib/impl.js +1229 -8
- package/lib/index.js +34 -0
- package/lib/protocol.js +19 -0
- package/lib/tps.js +32 -0
- package/lib/types/bridge.d.ts +850 -18
- package/lib/types/client/acp-ui.d.ts +34 -2
- package/lib/types/client/acp-view.d.ts +13 -2
- package/lib/types/client/activity-pill.d.ts +81 -0
- package/lib/types/client/branch-action.d.ts +47 -0
- package/lib/types/client/composer-mode.d.ts +28 -0
- package/lib/types/client/context-meter.d.ts +51 -0
- package/lib/types/client/model-seat.d.ts +21 -0
- package/lib/types/client/released-banner.d.ts +34 -0
- package/lib/types/client/slash-isolation.d.ts +1 -1
- package/lib/types/client/store.d.ts +108 -1
- package/lib/types/impl.d.ts +94 -7
- package/lib/types/protocol.d.ts +145 -0
- package/lib/types/tps.d.ts +17 -0
- package/package.json +2 -1
- package/src/bridge.ts +2754 -86
- package/src/client/acp-ui.tsx +123 -2
- package/src/client/acp-view.tsx +30 -5
- package/src/client/activity-pill.tsx +201 -0
- package/src/client/branch-action.tsx +178 -0
- package/src/client/composer-mode.tsx +59 -13
- package/src/client/context-meter.tsx +128 -0
- package/src/client/index.tsx +298 -15
- package/src/client/model-seat.tsx +28 -3
- package/src/client/panel.tsx +157 -40
- package/src/client/primitives.d.ts +27 -0
- package/src/client/released-banner.tsx +97 -0
- package/src/client/session-controls.tsx +34 -11
- package/src/client/slash-isolation.ts +2 -1
- package/src/client/store.ts +412 -14
- package/src/impl.ts +1251 -13
- package/src/index.ts +33 -0
- package/src/protocol.ts +145 -0
- package/src/tps.ts +40 -0
package/lib/client/store.js
CHANGED
|
@@ -17,6 +17,35 @@ export const PERMISSION_DEADLINE_EXPIRED = 'expired';
|
|
|
17
17
|
export function permissionsForSession(permissions, dshSessionId) {
|
|
18
18
|
return permissions.filter(permission => permission.dshSessionId === dshSessionId);
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* The branch button's state for one conversation, in the precedence it renders:
|
|
22
|
+
* the host service missing beats everything (nothing can work), then the
|
|
23
|
+
* agent's fork capability, then the session's own state (a parked session
|
|
24
|
+
* before a running one), then the DOM's tail verdict, and only then ready.
|
|
25
|
+
*
|
|
26
|
+
* Every input is a fact someone else owns — the row's `fork` verdict is the
|
|
27
|
+
* host's, `hostFork` is the structural probe of the runtime's `sessions.fork`,
|
|
28
|
+
* `isTail` is read from the host's own `[data-turn-tail]` marker, and
|
|
29
|
+
* `liveRunning` is the SSE-driven running flag (the row's own `running` only
|
|
30
|
+
* refreshes on a chat-list read, so it would answer last turn). `undefined`
|
|
31
|
+
* facts degrade CLOSED: no row and no `fork` verdict both read as not-ready,
|
|
32
|
+
* never as a click-through.
|
|
33
|
+
*/
|
|
34
|
+
export function forkStateOf(row, hostFork, isTail, liveRunning) {
|
|
35
|
+
if (!hostFork)
|
|
36
|
+
return 'hostUnavailable';
|
|
37
|
+
if (row === undefined)
|
|
38
|
+
return 'released';
|
|
39
|
+
if (row.fork === undefined)
|
|
40
|
+
return 'unsupported';
|
|
41
|
+
if (row.fork.supported !== true)
|
|
42
|
+
return row.fork.reason ?? 'unsupported';
|
|
43
|
+
if (liveRunning === true)
|
|
44
|
+
return 'busy';
|
|
45
|
+
if (!isTail)
|
|
46
|
+
return 'notTail';
|
|
47
|
+
return 'ready';
|
|
48
|
+
}
|
|
20
49
|
/**
|
|
21
50
|
* Compact countdown for one ask, or `undefined` when there is nothing to count
|
|
22
51
|
* down (`wait` mode, or a frame without a deadline). Pure so the formatting is
|
|
@@ -350,8 +379,10 @@ export class AcpStore {
|
|
|
350
379
|
probingModels: {},
|
|
351
380
|
probeErrors: {},
|
|
352
381
|
chatSessions: [],
|
|
382
|
+
burstStats: {},
|
|
353
383
|
tagHalo: { ...DEFAULT_TAG_HALO },
|
|
354
384
|
approvalDeadlines: undefined,
|
|
385
|
+
idleRelease: undefined,
|
|
355
386
|
chatTranscripts: {},
|
|
356
387
|
acpMode: {},
|
|
357
388
|
chatBoundAgent: {},
|
|
@@ -371,6 +402,13 @@ export class AcpStore {
|
|
|
371
402
|
openTurns = new Set();
|
|
372
403
|
/** In-flight `session/chat-list` reads, keyed by chat session id. */
|
|
373
404
|
chatSessionReads = new Map();
|
|
405
|
+
/**
|
|
406
|
+
* ACP session ids whose chat-list row was requested mid-turn (one read each).
|
|
407
|
+
* A conversation's first ACP session does not exist when the composer's
|
|
408
|
+
* mount/running read fires, so its first `usage_update` would find no row to
|
|
409
|
+
* patch; `turn/start` asks again once the host has materialized the session.
|
|
410
|
+
*/
|
|
411
|
+
chatSessionRowReads = new Set();
|
|
374
412
|
/**
|
|
375
413
|
* Chat sessions whose list has answered at least once in this page. The
|
|
376
414
|
* FIRST answer only seeds the lock; a lock appearing on a later answer is a
|
|
@@ -494,9 +532,17 @@ export class AcpStore {
|
|
|
494
532
|
list.push(item);
|
|
495
533
|
}
|
|
496
534
|
let chatTranscripts = this.state.chatTranscripts;
|
|
535
|
+
let chatSessions = this.state.chatSessions;
|
|
497
536
|
let blocksChanged = false;
|
|
498
537
|
for (const [sessionId, items] of bySession) {
|
|
499
538
|
const last = items[items.length - 1];
|
|
539
|
+
// `usage_update` is state, not stream content: it never becomes a block,
|
|
540
|
+
// but both observed snapshots that carry usage must move with it —
|
|
541
|
+
// otherwise the ACP view header and the composer's occupancy ring only
|
|
542
|
+
// refresh when some other RPC re-reads the session.
|
|
543
|
+
const usage = usageOfBatch(items);
|
|
544
|
+
if (usage !== undefined)
|
|
545
|
+
chatSessions = withSessionUsage(chatSessions, sessionId, usage);
|
|
500
546
|
// The observed transcript folds every frame (a watched session's cards
|
|
501
547
|
// and prose); blocks are copied only when something actually changed, so
|
|
502
548
|
// a usage-only tick costs one record object, not a re-render payload.
|
|
@@ -507,9 +553,12 @@ export class AcpStore {
|
|
|
507
553
|
if (foldUpdate(record.blocks, item.update))
|
|
508
554
|
changed = true;
|
|
509
555
|
}
|
|
556
|
+
const session = usage !== undefined && !sameUsage(record.session.usage, usage)
|
|
557
|
+
? { ...record.session, usage }
|
|
558
|
+
: record.session;
|
|
510
559
|
chatTranscripts = {
|
|
511
560
|
...chatTranscripts,
|
|
512
|
-
[sessionId]: { ...record, seq: last.seq, ...(changed ? { blocks: [...record.blocks] } : {}) },
|
|
561
|
+
[sessionId]: { ...record, session, seq: last.seq, ...(changed ? { blocks: [...record.blocks] } : {}) },
|
|
513
562
|
};
|
|
514
563
|
}
|
|
515
564
|
if (sessionId !== this.state.activeSessionId) {
|
|
@@ -532,6 +581,8 @@ export class AcpStore {
|
|
|
532
581
|
const patch = {};
|
|
533
582
|
if (chatTranscripts !== this.state.chatTranscripts)
|
|
534
583
|
patch.chatTranscripts = chatTranscripts;
|
|
584
|
+
if (chatSessions !== this.state.chatSessions)
|
|
585
|
+
patch.chatSessions = chatSessions;
|
|
535
586
|
if (blocksChanged)
|
|
536
587
|
patch.blocks = [...this.state.blocks];
|
|
537
588
|
if (Object.keys(patch).length > 0)
|
|
@@ -586,7 +637,7 @@ export class AcpStore {
|
|
|
586
637
|
/** Snapshot refresh used on panel open and after a stream outage. */
|
|
587
638
|
async resync() {
|
|
588
639
|
try {
|
|
589
|
-
const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult, versionResult] = await Promise.all([
|
|
640
|
+
const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult, idleReleaseResult, versionResult] = await Promise.all([
|
|
590
641
|
this.call('agents/list'),
|
|
591
642
|
this.call('sessions/list'),
|
|
592
643
|
// The plugin's OWN default ACP agent — a plugin-private id, not a DSH
|
|
@@ -600,6 +651,9 @@ export class AcpStore {
|
|
|
600
651
|
// `undefined` (older half) hides the deadline card instead of showing a
|
|
601
652
|
// number this browser made up.
|
|
602
653
|
this.call('ui/approval-deadlines').catch(() => ({ deadlines: undefined })),
|
|
654
|
+
// `undefined` (older half) hides the tab instead of showing a
|
|
655
|
+
// number this browser made up.
|
|
656
|
+
this.call('ui/idle-release').catch(() => ({ idleRelease: undefined })),
|
|
603
657
|
// The settings badge's data source; older halves answer `unknown
|
|
604
658
|
// endpoint`, which hides the badge.
|
|
605
659
|
this.call('plugin/version').catch(() => null),
|
|
@@ -617,8 +671,14 @@ export class AcpStore {
|
|
|
617
671
|
preset: presetResult?.preset ?? undefined,
|
|
618
672
|
tagHalo: tagHaloResult?.halo ?? { ...DEFAULT_TAG_HALO },
|
|
619
673
|
approvalDeadlines: deadlinesResult.deadlines,
|
|
674
|
+
idleRelease: idleReleaseResult.idleRelease,
|
|
620
675
|
pluginVersion: versionResult?.version !== undefined && versionResult.version !== '' ? versionResult.version : undefined,
|
|
621
676
|
});
|
|
677
|
+
// `sessions/list` is the `/acp` RPC face; the composer's chat rows
|
|
678
|
+
// (agent modes, occupancy) are a separate list. A reconnect must refresh
|
|
679
|
+
// that list too or the ring keeps a pre-outage sample until a turn ends.
|
|
680
|
+
if (this.state.chatSessionsFor !== undefined)
|
|
681
|
+
void this.loadChatSessions(this.state.chatSessionsFor);
|
|
622
682
|
const active = this.state.activeSessionId ?? sessions[0]?.sessionId;
|
|
623
683
|
if (active !== undefined)
|
|
624
684
|
await this.loadTranscript(active);
|
|
@@ -1080,9 +1140,9 @@ export class AcpStore {
|
|
|
1080
1140
|
* the frame's authority.
|
|
1081
1141
|
*/
|
|
1082
1142
|
loadChatSessions(dshSessionId, options) {
|
|
1083
|
-
//
|
|
1084
|
-
//
|
|
1085
|
-
//
|
|
1143
|
+
// The composer entries (agent-mode control, ACP cluster, occupancy ring)
|
|
1144
|
+
// read this and re-read when a turn settles; one round-trip answers for
|
|
1145
|
+
// all of them instead of racing reads. A caller arriving mid-read waits
|
|
1086
1146
|
// for THAT read rather than returning immediately: the slash source's
|
|
1087
1147
|
// one-shot "do I have a verdict yet?" ask is only useful if it can
|
|
1088
1148
|
// receive the answer of a read already in flight.
|
|
@@ -1097,7 +1157,7 @@ export class AcpStore {
|
|
|
1097
1157
|
async readChatSessionList(dshSessionId, options, read) {
|
|
1098
1158
|
const settle = options?.settleVerdict === true || !this.acpModeLive.has(dshSessionId);
|
|
1099
1159
|
try {
|
|
1100
|
-
const { sessions, acpMode, boundAgentId, lockedAgentId, choice } = await this.call('session/chat-list', { dshSessionId, refresh: true });
|
|
1160
|
+
const { sessions = [], acpMode, boundAgentId, lockedAgentId, choice } = await this.call('session/chat-list', { dshSessionId, refresh: true });
|
|
1101
1161
|
// A committed `agent-preset/selected` frame may have settled this
|
|
1102
1162
|
// session's verdict while the read was in flight; the host answer then
|
|
1103
1163
|
// describes the log as of BEFORE that switch and must not overwrite it.
|
|
@@ -1252,6 +1312,13 @@ export class AcpStore {
|
|
|
1252
1312
|
* being the one authorized exception. Same no-op-when-unchanged discipline as
|
|
1253
1313
|
* the other accessors, so a frame restating the held verdict costs no
|
|
1254
1314
|
* listener churn.
|
|
1315
|
+
*
|
|
1316
|
+
* The seat gate (`syncAcpMode`) is the SECOND caller: it folds the runtime
|
|
1317
|
+
* row's own `agentPreset` — the same session-log source the frames announce —
|
|
1318
|
+
* through here, in both directions. That hands the row the frame's authority
|
|
1319
|
+
* on purpose (one provenance, one rank): a stale projection is corrected by
|
|
1320
|
+
* the gate's next evaluation with fresher row data, and only HOST READS are
|
|
1321
|
+
* barred from regressing what the runtime itself already said.
|
|
1255
1322
|
*/
|
|
1256
1323
|
noteAcpMode(dshSessionId, acpMode) {
|
|
1257
1324
|
this.acpModeLive.add(dshSessionId);
|
|
@@ -1336,12 +1403,93 @@ export class AcpStore {
|
|
|
1336
1403
|
async setChatSessionMode(dshSessionId, modeId, agentId) {
|
|
1337
1404
|
try {
|
|
1338
1405
|
await this.call('session/chat-mode-set', { dshSessionId, modeId, ...(agentId === undefined ? {} : { agentId }) });
|
|
1406
|
+
const safeAgent = (agentId && agentId.trim()) || undefined;
|
|
1407
|
+
this.writeChoice(dshSessionId, choice => ({ ...choice, mode: modeId }), safeAgent);
|
|
1408
|
+
if (this.state.chatSessionsFor === dshSessionId) {
|
|
1409
|
+
const owner = safeAgent ?? this.state.chatChoice[dshSessionId]?.agentId ?? this.state.chatBoundAgent[dshSessionId] ?? this.state.defaultAgent;
|
|
1410
|
+
let changed = false;
|
|
1411
|
+
const chatSessions = this.state.chatSessions.map(s => {
|
|
1412
|
+
if (s.agentId !== owner || !s.modes || s.modes.currentModeId === modeId)
|
|
1413
|
+
return s;
|
|
1414
|
+
changed = true;
|
|
1415
|
+
return { ...s, modes: { ...s.modes, currentModeId: modeId } };
|
|
1416
|
+
});
|
|
1417
|
+
if (changed)
|
|
1418
|
+
this.mutate({ chatSessions });
|
|
1419
|
+
}
|
|
1339
1420
|
}
|
|
1340
1421
|
catch (error) {
|
|
1341
1422
|
this.flashError(error);
|
|
1342
1423
|
}
|
|
1343
1424
|
await this.loadChatSessions(dshSessionId);
|
|
1344
1425
|
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Bring the conversation's released ACP session back on a fresh process —
|
|
1428
|
+
* the released banner's explicit form of the "next message resumes it"
|
|
1429
|
+
* promise (`session/chat-resume`). A failure is reported through the notice
|
|
1430
|
+
* AND rethrown (the `respondPermission` contract), so the banner can pin
|
|
1431
|
+
* the message beside the button the user just pressed; the binding is
|
|
1432
|
+
* re-read either way so the row follows the host's real answer.
|
|
1433
|
+
*/
|
|
1434
|
+
async resumeChatSession(dshSessionId) {
|
|
1435
|
+
let failure;
|
|
1436
|
+
try {
|
|
1437
|
+
await this.call('session/chat-resume', { dshSessionId });
|
|
1438
|
+
}
|
|
1439
|
+
catch (error) {
|
|
1440
|
+
this.flashError(error);
|
|
1441
|
+
failure = error;
|
|
1442
|
+
}
|
|
1443
|
+
await this.loadChatSessions(dshSessionId);
|
|
1444
|
+
if (failure !== undefined)
|
|
1445
|
+
throw failure;
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* Branch one conversation (the NTR branch button's click): the fixed
|
|
1449
|
+
* two-stage order — the HOST forks the DSH session tree first (copied
|
|
1450
|
+
* history, `(1)` title, workspace), then the plugin adopts the ACP side via
|
|
1451
|
+
* `session/chat-fork-adopt`, which runs the agent's own `session/fork` so
|
|
1452
|
+
* the child actually remembers the parent context. Host first is structural:
|
|
1453
|
+
* ACP fork has no cut-point parameter and nothing to return to if the host
|
|
1454
|
+
* half failed second (see the plan's §D7).
|
|
1455
|
+
*
|
|
1456
|
+
* Resolves `{ childId }` on success, or `{ failure }` with one actionable
|
|
1457
|
+
* line — the reason is ALSO flashed into the notice banner (a user-action
|
|
1458
|
+
* failure, so the banner is its surface), and the local line is what the
|
|
1459
|
+
* conversation renders beside the button (the user is not on the settings
|
|
1460
|
+
* page). A failed adopt leaves the child an ordinary copied-history
|
|
1461
|
+
* conversation (its first message starts a fresh `session/new` — today's
|
|
1462
|
+
* behavior), never a half-bound one, and NOTHING is opened.
|
|
1463
|
+
*
|
|
1464
|
+
* `forkHost` is the runtime's `sessions.fork` face, resolved structurally by
|
|
1465
|
+
* the entry and injected here: the store stays transport-free.
|
|
1466
|
+
*/
|
|
1467
|
+
async forkChatSession(dshSessionId, forkHost) {
|
|
1468
|
+
let childId;
|
|
1469
|
+
try {
|
|
1470
|
+
childId = await forkHost(dshSessionId);
|
|
1471
|
+
}
|
|
1472
|
+
catch (error) {
|
|
1473
|
+
const failure = error instanceof Error ? error.message : String(error);
|
|
1474
|
+
this.flashError(failure);
|
|
1475
|
+
return { failure };
|
|
1476
|
+
}
|
|
1477
|
+
if (typeof childId !== 'string' || childId === '') {
|
|
1478
|
+
const failure = this.locale('宿主没有返回分支会话,分支未完成。', 'The host returned no branched session; the branch did not complete.');
|
|
1479
|
+
this.flashError(new Error(failure));
|
|
1480
|
+
return { failure };
|
|
1481
|
+
}
|
|
1482
|
+
try {
|
|
1483
|
+
await this.call('session/chat-fork-adopt', { dshSessionId: childId, parentDshSessionId: dshSessionId });
|
|
1484
|
+
}
|
|
1485
|
+
catch (error) {
|
|
1486
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1487
|
+
const failure = this.locale(`分支会话已创建,但没有接上 agent 的上下文(${reason});在子会话里发送的第一条消息会开启全新会话。`, `The branched session was created but the agent context was not attached (${reason}); its first message will start a fresh session.`);
|
|
1488
|
+
this.flashError(new Error(failure));
|
|
1489
|
+
return { failure };
|
|
1490
|
+
}
|
|
1491
|
+
return { childId };
|
|
1492
|
+
}
|
|
1345
1493
|
// ---- ACP transcript observation (the conversation's ACP view) ----
|
|
1346
1494
|
/**
|
|
1347
1495
|
* Start (or refresh) observation of every ACP transcript behind one DSH chat
|
|
@@ -1512,6 +1660,34 @@ export class AcpStore {
|
|
|
1512
1660
|
async resetApprovalDeadlines() {
|
|
1513
1661
|
await this.writeApprovalDeadlines({ reset: true });
|
|
1514
1662
|
}
|
|
1663
|
+
/**
|
|
1664
|
+
* Write the idle-release window (in ms).
|
|
1665
|
+
*
|
|
1666
|
+
* The host answer — the same route returns the window now in force — is
|
|
1667
|
+
* the authority, so an optimistic write that the host trims self-corrects
|
|
1668
|
+
* instead of drifting from what runs.
|
|
1669
|
+
*/
|
|
1670
|
+
async setIdleRelease(idleTimeoutMs) {
|
|
1671
|
+
await this.writeIdleRelease({ idleTimeoutMs });
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Drop the persisted override: the config file (or the built-in default)
|
|
1675
|
+
* decides again.
|
|
1676
|
+
*/
|
|
1677
|
+
async resetIdleRelease() {
|
|
1678
|
+
await this.writeIdleRelease({ reset: true });
|
|
1679
|
+
}
|
|
1680
|
+
async writeIdleRelease(payload) {
|
|
1681
|
+
const previous = this.state.idleRelease;
|
|
1682
|
+
try {
|
|
1683
|
+
const result = await this.call('ui/idle-release-set', payload);
|
|
1684
|
+
this.mutate({ idleRelease: result.idleRelease });
|
|
1685
|
+
}
|
|
1686
|
+
catch (error) {
|
|
1687
|
+
this.mutate({ idleRelease: previous });
|
|
1688
|
+
this.flashError(error);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1515
1691
|
async writeApprovalDeadlines(payload) {
|
|
1516
1692
|
const previous = this.state.approvalDeadlines;
|
|
1517
1693
|
// Optimistic only where it is safe: the effective numbers and the mode move
|
|
@@ -1565,7 +1741,21 @@ export class AcpStore {
|
|
|
1565
1741
|
// Optimistic for the same reason the binding is: the host recorded the
|
|
1566
1742
|
// pick, and a conversation with no ACP session yet would otherwise show
|
|
1567
1743
|
// the agent's default until its first turn created one.
|
|
1568
|
-
|
|
1744
|
+
const safeAgent = (agentId && agentId.trim()) || undefined;
|
|
1745
|
+
const targetAgent = safeAgent ?? (modelId ? splitModelAgentId(modelId) : undefined);
|
|
1746
|
+
this.writeChoice(dshSessionId, choice => ({ ...choice, modelId }), targetAgent);
|
|
1747
|
+
if (this.state.chatSessionsFor === dshSessionId) {
|
|
1748
|
+
const owner = targetAgent ?? this.state.chatChoice[dshSessionId]?.agentId ?? this.state.chatBoundAgent[dshSessionId] ?? this.state.defaultAgent;
|
|
1749
|
+
let changed = false;
|
|
1750
|
+
const chatSessions = this.state.chatSessions.map(s => {
|
|
1751
|
+
if (s.agentId !== owner || s.modelId === modelId)
|
|
1752
|
+
return s;
|
|
1753
|
+
changed = true;
|
|
1754
|
+
return { ...s, modelId };
|
|
1755
|
+
});
|
|
1756
|
+
if (changed)
|
|
1757
|
+
this.mutate({ chatSessions });
|
|
1758
|
+
}
|
|
1569
1759
|
}
|
|
1570
1760
|
catch (error) {
|
|
1571
1761
|
this.flashError(error);
|
|
@@ -1580,10 +1770,23 @@ export class AcpStore {
|
|
|
1580
1770
|
...(effort === undefined ? {} : { effort }),
|
|
1581
1771
|
...(agentId === undefined ? {} : { agentId }),
|
|
1582
1772
|
});
|
|
1773
|
+
const targetAgent = (agentId && agentId.trim()) || undefined;
|
|
1583
1774
|
this.writeChoice(dshSessionId, choice => {
|
|
1584
1775
|
const { effort: _dropped, ...rest } = choice;
|
|
1585
1776
|
return effort === undefined ? rest : { ...rest, effort };
|
|
1586
|
-
},
|
|
1777
|
+
}, targetAgent);
|
|
1778
|
+
if (this.state.chatSessionsFor === dshSessionId) {
|
|
1779
|
+
const owner = targetAgent ?? this.state.chatChoice[dshSessionId]?.agentId ?? this.state.chatBoundAgent[dshSessionId] ?? this.state.defaultAgent;
|
|
1780
|
+
let changed = false;
|
|
1781
|
+
const chatSessions = this.state.chatSessions.map(s => {
|
|
1782
|
+
if (s.agentId !== owner || s.reasoningEffort === effort)
|
|
1783
|
+
return s;
|
|
1784
|
+
changed = true;
|
|
1785
|
+
return { ...s, reasoningEffort: effort };
|
|
1786
|
+
});
|
|
1787
|
+
if (changed)
|
|
1788
|
+
this.mutate({ chatSessions });
|
|
1789
|
+
}
|
|
1587
1790
|
}
|
|
1588
1791
|
catch (error) {
|
|
1589
1792
|
this.flashError(error);
|
|
@@ -1691,11 +1894,20 @@ export class AcpStore {
|
|
|
1691
1894
|
return Promise.resolve();
|
|
1692
1895
|
}
|
|
1693
1896
|
case 'session/meta': {
|
|
1694
|
-
// Host re-published the session view after mode/config changes
|
|
1897
|
+
// Host re-published the session view after mode/config changes or a
|
|
1898
|
+
// resume. The composer's occupancy ring reads the chat-list row, so
|
|
1899
|
+
// that row must follow the snapshot's usage too — including an absent
|
|
1900
|
+
// one, which must clear an older reading rather than outlive it.
|
|
1695
1901
|
const sessions = this.state.sessions.some(s => s.sessionId === event.sessionId)
|
|
1696
1902
|
? this.state.sessions.map(s => (s.sessionId === event.sessionId ? event.session : s))
|
|
1697
1903
|
: this.state.sessions;
|
|
1698
|
-
this.
|
|
1904
|
+
let chatSessions = withSessionUsage(this.state.chatSessions, event.sessionId, event.session.usage);
|
|
1905
|
+
chatSessions = withSessionModes(chatSessions, event.sessionId, event.session.modes);
|
|
1906
|
+
this.mutate({
|
|
1907
|
+
sessions,
|
|
1908
|
+
...(chatSessions === this.state.chatSessions ? {} : { chatSessions }),
|
|
1909
|
+
...this.watchedSessionPatch(event.sessionId, event.session),
|
|
1910
|
+
});
|
|
1699
1911
|
return Promise.resolve();
|
|
1700
1912
|
}
|
|
1701
1913
|
case 'session/closed': {
|
|
@@ -1709,11 +1921,20 @@ export class AcpStore {
|
|
|
1709
1921
|
// entry would keep reading as a live session (the agent tag's halo
|
|
1710
1922
|
// breathes on `!released`) until some future turn re-reads the list.
|
|
1711
1923
|
const chatSessions = this.state.chatSessions.filter(s => s.sessionId !== event.sessionId);
|
|
1924
|
+
// The burst aggregate belongs to this ACP session only; another
|
|
1925
|
+
// session's entry must survive. Only patch when there was an entry.
|
|
1926
|
+
const hadBurst = this.state.burstStats[event.sessionId] !== undefined;
|
|
1927
|
+
let burstStats = this.state.burstStats;
|
|
1928
|
+
if (hadBurst) {
|
|
1929
|
+
burstStats = { ...burstStats };
|
|
1930
|
+
delete burstStats[event.sessionId];
|
|
1931
|
+
}
|
|
1712
1932
|
this.mutate({
|
|
1713
1933
|
sessions,
|
|
1714
1934
|
activeSessionId: active,
|
|
1715
1935
|
chatTranscripts: closedTranscripts,
|
|
1716
1936
|
...(chatSessions.length !== this.state.chatSessions.length ? { chatSessions } : {}),
|
|
1937
|
+
...(hadBurst ? { burstStats } : {}),
|
|
1717
1938
|
permissions: this.state.permissions.filter(p => p.sessionId !== event.sessionId),
|
|
1718
1939
|
...(event.reason === 'agent-died'
|
|
1719
1940
|
? this.logPatch('error', this.locale('会话因 agent 进程退出而终止', 'session ended: agent process died'), closingSession?.agentId)
|
|
@@ -1731,9 +1952,7 @@ export class AcpStore {
|
|
|
1731
1952
|
// The composer's own list (`session/chat-list` views) must flip too, or
|
|
1732
1953
|
// the agent tag's live halo keeps breathing for a process that is gone:
|
|
1733
1954
|
// nothing else re-reads this list until the next turn settles.
|
|
1734
|
-
const chatSessions = this.state.chatSessions
|
|
1735
|
-
? this.state.chatSessions.map(s => (s.sessionId === event.sessionId ? { ...s, released: true } : s))
|
|
1736
|
-
: this.state.chatSessions;
|
|
1955
|
+
const chatSessions = withSessionReleased(this.state.chatSessions, event.sessionId, true);
|
|
1737
1956
|
// One runtime-log line per release cause; `idle` stays silent on
|
|
1738
1957
|
// purpose (an expected lifecycle transition, not a fault).
|
|
1739
1958
|
const cause = event.cause ?? 'died';
|
|
@@ -1775,8 +1994,15 @@ export class AcpStore {
|
|
|
1775
1994
|
// an observed transcript must drop both its blocks and its watermark
|
|
1776
1995
|
// or the replay would be read as a gap and refetched forever.
|
|
1777
1996
|
const watched = this.state.chatTranscripts[event.sessionId];
|
|
1997
|
+
// ...and the composer's own list must flip back to live on the same
|
|
1998
|
+
// event (mirror of the `session/released` flip): the released banner
|
|
1999
|
+
// and the tag's halo read that row, and nothing else re-reads it until
|
|
2000
|
+
// a turn settles — a resume would leave the banner claiming a parked
|
|
2001
|
+
// process that is already back.
|
|
2002
|
+
const chatSessions = withSessionReleased(this.state.chatSessions, event.sessionId, false);
|
|
1778
2003
|
this.mutate({
|
|
1779
2004
|
sessions,
|
|
2005
|
+
...(chatSessions === this.state.chatSessions ? {} : { chatSessions }),
|
|
1780
2006
|
...(isActive ? { blocks: [] } : {}),
|
|
1781
2007
|
...(watched === undefined ? {} : {
|
|
1782
2008
|
chatTranscripts: {
|
|
@@ -1885,9 +2111,42 @@ export class AcpStore {
|
|
|
1885
2111
|
});
|
|
1886
2112
|
return Promise.resolve();
|
|
1887
2113
|
}
|
|
2114
|
+
case 'turn/metrics': {
|
|
2115
|
+
// Plugin-side burst aggregate for one ACP session. A late LIVE frame
|
|
2116
|
+
// must not regress a turn this page already saw finalized (frames can
|
|
2117
|
+
// land out of order across a stream reconnect); any other frame writes
|
|
2118
|
+
// the entry. Blocks / sessions stay untouched — this is a metric.
|
|
2119
|
+
const prior = this.state.burstStats[event.sessionId];
|
|
2120
|
+
if (event.final === false && prior !== undefined && prior.final === true && prior.turnId === event.turnId) {
|
|
2121
|
+
return Promise.resolve();
|
|
2122
|
+
}
|
|
2123
|
+
this.mutate({
|
|
2124
|
+
burstStats: {
|
|
2125
|
+
...this.state.burstStats,
|
|
2126
|
+
[event.sessionId]: {
|
|
2127
|
+
outputTokens: event.outputTokens,
|
|
2128
|
+
burstMs: event.burstMs,
|
|
2129
|
+
turnId: event.turnId,
|
|
2130
|
+
final: event.final,
|
|
2131
|
+
},
|
|
2132
|
+
},
|
|
2133
|
+
});
|
|
2134
|
+
return Promise.resolve();
|
|
2135
|
+
}
|
|
1888
2136
|
case 'turn/start': {
|
|
1889
2137
|
this.openTurns.add(event.sessionId);
|
|
1890
2138
|
this.markWatchedRunning(event.sessionId, true);
|
|
2139
|
+
// The session materializes inside this turn; if the composer's list
|
|
2140
|
+
// has not seen its row yet (the mount/running read beat session/new),
|
|
2141
|
+
// read once now so the occupancy ring has a row for the live usage
|
|
2142
|
+
// ticks. The per-session set bounds this to one extra read.
|
|
2143
|
+
const chatSessionId = this.state.chatSessionsFor;
|
|
2144
|
+
if (chatSessionId !== undefined
|
|
2145
|
+
&& !this.state.chatSessions.some(row => row.sessionId === event.sessionId)
|
|
2146
|
+
&& !this.chatSessionRowReads.has(event.sessionId)) {
|
|
2147
|
+
this.chatSessionRowReads.add(event.sessionId);
|
|
2148
|
+
void this.loadChatSessions(chatSessionId);
|
|
2149
|
+
}
|
|
1891
2150
|
if (event.sessionId === this.state.activeSessionId) {
|
|
1892
2151
|
this.state.blocks.push({ kind: 'user', text: event.text });
|
|
1893
2152
|
trimBlocks(this.state.blocks);
|
|
@@ -2070,6 +2329,114 @@ export const STATE_ONLY_UPDATES = new Set([
|
|
|
2070
2329
|
'usage_update',
|
|
2071
2330
|
'session_info_update',
|
|
2072
2331
|
]);
|
|
2332
|
+
/**
|
|
2333
|
+
* Context occupancy carried by one raw `usage_update` frame, in the shape the
|
|
2334
|
+
* session snapshots already use. A malformed frame is ignored (the last good
|
|
2335
|
+
* sample stays on screen) instead of zeroing the meter.
|
|
2336
|
+
*/
|
|
2337
|
+
export function usageFromUpdate(update) {
|
|
2338
|
+
const used = update.used;
|
|
2339
|
+
const size = update.size;
|
|
2340
|
+
if (typeof used !== 'number' || !Number.isFinite(used))
|
|
2341
|
+
return undefined;
|
|
2342
|
+
if (typeof size !== 'number' || !Number.isFinite(size))
|
|
2343
|
+
return undefined;
|
|
2344
|
+
const cost = update.cost;
|
|
2345
|
+
if (cost !== null && typeof cost === 'object' && !Array.isArray(cost)) {
|
|
2346
|
+
const row = cost;
|
|
2347
|
+
if (typeof row.amount === 'number' && Number.isFinite(row.amount)
|
|
2348
|
+
&& typeof row.currency === 'string' && row.currency !== '') {
|
|
2349
|
+
return { used, size, cost: { amount: row.amount, currency: row.currency } };
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
return { used, size };
|
|
2353
|
+
}
|
|
2354
|
+
function sameUsage(left, right) {
|
|
2355
|
+
if (left === right)
|
|
2356
|
+
return true;
|
|
2357
|
+
if (left === undefined || right === undefined)
|
|
2358
|
+
return false;
|
|
2359
|
+
if (left.used !== right.used || left.size !== right.size)
|
|
2360
|
+
return false;
|
|
2361
|
+
const leftCost = left.cost;
|
|
2362
|
+
const rightCost = right.cost;
|
|
2363
|
+
if (leftCost === undefined || rightCost === undefined)
|
|
2364
|
+
return leftCost === rightCost;
|
|
2365
|
+
return leftCost.amount === rightCost.amount && leftCost.currency === rightCost.currency;
|
|
2366
|
+
}
|
|
2367
|
+
/** Patch one session row's usage; other rows and an unchanged value keep their identities. */
|
|
2368
|
+
function withSessionUsage(rows, sessionId, usage) {
|
|
2369
|
+
let changed = false;
|
|
2370
|
+
const next = rows.map(row => {
|
|
2371
|
+
if (row.sessionId !== sessionId || sameUsage(row.usage, usage))
|
|
2372
|
+
return row;
|
|
2373
|
+
changed = true;
|
|
2374
|
+
return { ...row, usage };
|
|
2375
|
+
});
|
|
2376
|
+
return changed ? next : rows;
|
|
2377
|
+
}
|
|
2378
|
+
/**
|
|
2379
|
+
* Flip one chat row's `released` flag (release/resume bookkeeping). The
|
|
2380
|
+
* composer's own list (`session/chat-list` rows) has no other live writer —
|
|
2381
|
+
* `session/released` must mark it, `session/reset` must un-mark it, or the
|
|
2382
|
+
* agent tag's halo and the released banner read yesterday's process state.
|
|
2383
|
+
*/
|
|
2384
|
+
function withSessionReleased(rows, sessionId, released) {
|
|
2385
|
+
let changed = false;
|
|
2386
|
+
const next = rows.map(row => {
|
|
2387
|
+
if (row.sessionId !== sessionId || (row.released === true) === released)
|
|
2388
|
+
return row;
|
|
2389
|
+
changed = true;
|
|
2390
|
+
return { ...row, released };
|
|
2391
|
+
});
|
|
2392
|
+
return changed ? next : rows;
|
|
2393
|
+
}
|
|
2394
|
+
function sameModes(a, b) {
|
|
2395
|
+
if (a === b)
|
|
2396
|
+
return true;
|
|
2397
|
+
if (!a || !b)
|
|
2398
|
+
return false;
|
|
2399
|
+
if (a.currentModeId !== b.currentModeId)
|
|
2400
|
+
return false;
|
|
2401
|
+
const aModes = a.availableModes ?? [];
|
|
2402
|
+
const bModes = b.availableModes ?? [];
|
|
2403
|
+
if (aModes.length !== bModes.length)
|
|
2404
|
+
return false;
|
|
2405
|
+
return aModes.every((m, i) => {
|
|
2406
|
+
const o = bModes[i];
|
|
2407
|
+
return m != null && o != null && m.id === o.id && m.name === o.name && m.description === o.description;
|
|
2408
|
+
});
|
|
2409
|
+
}
|
|
2410
|
+
function withSessionModes(rows, sessionId, modes) {
|
|
2411
|
+
if (!modes)
|
|
2412
|
+
return rows;
|
|
2413
|
+
let changed = false;
|
|
2414
|
+
const next = rows.map(row => {
|
|
2415
|
+
if (row.sessionId !== sessionId || sameModes(row.modes, modes))
|
|
2416
|
+
return row;
|
|
2417
|
+
changed = true;
|
|
2418
|
+
return { ...row, modes };
|
|
2419
|
+
});
|
|
2420
|
+
return changed ? next : rows;
|
|
2421
|
+
}
|
|
2422
|
+
function splitModelAgentId(modelId) {
|
|
2423
|
+
const at = modelId.indexOf('@');
|
|
2424
|
+
const agent = at === -1 ? modelId.trim() : (at === 0 ? '' : modelId.slice(0, at).trim());
|
|
2425
|
+
return agent || undefined;
|
|
2426
|
+
}
|
|
2427
|
+
/** Last well-formed `usage_update` in one coalesced batch (later ticks win). */
|
|
2428
|
+
function usageOfBatch(items) {
|
|
2429
|
+
let usage;
|
|
2430
|
+
for (const item of items) {
|
|
2431
|
+
const update = item.update;
|
|
2432
|
+
if (update.sessionUpdate !== 'usage_update')
|
|
2433
|
+
continue;
|
|
2434
|
+
const parsed = usageFromUpdate(update);
|
|
2435
|
+
if (parsed !== undefined)
|
|
2436
|
+
usage = parsed;
|
|
2437
|
+
}
|
|
2438
|
+
return usage;
|
|
2439
|
+
}
|
|
2073
2440
|
function trimBlocks(blocks) {
|
|
2074
2441
|
if (blocks.length > MAX_BLOCKS)
|
|
2075
2442
|
blocks.splice(0, blocks.length - MAX_BLOCKS);
|