@bivy/bivy 0.10.0 → 0.10.1-staging.141
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/bin/acp-shim.mjs +47 -2
- package/dist/runtime/protocol.js +50 -4
- package/dist/server.js +15 -2
- package/dist/session/event-log.js +37 -4
- package/package.json +1 -1
package/bin/acp-shim.mjs
CHANGED
|
@@ -125,6 +125,31 @@ let pendingModel = null;
|
|
|
125
125
|
// right ACP permission request with a concrete optionId.
|
|
126
126
|
const permissionRequests = new Map();
|
|
127
127
|
|
|
128
|
+
// --- trailing-update drain ---------------------------------------------------
|
|
129
|
+
// opencode's ACP server resolves `session/prompt` (the end_turn reply) BEFORE the
|
|
130
|
+
// final `agent_message_chunk` frames are flushed — a known upstream ordering race
|
|
131
|
+
// (opencode#17505). If the shim declared session.done the instant the prompt reply
|
|
132
|
+
// arrived, the turn would seal at ProtocolRuntime with the reply's tail still
|
|
133
|
+
// unstreamed: the trailing text then streams live (message_update) but never
|
|
134
|
+
// reaches getMessages(), so it vanishes the moment the session reopens. So the
|
|
135
|
+
// turn is NOT done at prompt-resolve — hold session.done until the session/update
|
|
136
|
+
// stream has been quiet for TRAILING_DRAIN_MS, letting the late chunks land while
|
|
137
|
+
// the turn is still open and get sealed into history.
|
|
138
|
+
const TRAILING_DRAIN_MS = 250;
|
|
139
|
+
let turnDrainTimer = null;
|
|
140
|
+
let turnDraining = false;
|
|
141
|
+
function scheduleTurnDone() {
|
|
142
|
+
clearTimeout(turnDrainTimer);
|
|
143
|
+
turnDrainTimer = setTimeout(finishTurnDone, TRAILING_DRAIN_MS);
|
|
144
|
+
}
|
|
145
|
+
function finishTurnDone() {
|
|
146
|
+
turnDrainTimer = null;
|
|
147
|
+
if (!turnDraining) return;
|
|
148
|
+
turnDraining = false;
|
|
149
|
+
bivy({ type: "session.status", status: "idle" });
|
|
150
|
+
bivy({ type: "session.done" });
|
|
151
|
+
}
|
|
152
|
+
|
|
128
153
|
// --- tool-call field normalization -------------------------------------------
|
|
129
154
|
// ACP's `tool_call`/`tool_call_update` carries a free-text `title` (whatever
|
|
130
155
|
// prose the agent chose) AND a small fixed `kind` enum (read/edit/delete/move/
|
|
@@ -235,6 +260,10 @@ function publishModels(result) {
|
|
|
235
260
|
function onSessionUpdate(params) {
|
|
236
261
|
const u = params?.update;
|
|
237
262
|
if (!u || typeof u !== "object") return;
|
|
263
|
+
// Any update arriving while the turn is draining means the agent is still
|
|
264
|
+
// emitting the tail of this turn (see the drain note above) — reset the quiet
|
|
265
|
+
// window so session.done waits for it instead of sealing history short.
|
|
266
|
+
if (turnDraining) scheduleTurnDone();
|
|
238
267
|
const kind = String(u.sessionUpdate || "");
|
|
239
268
|
const textOf = (content) => {
|
|
240
269
|
if (!content) return "";
|
|
@@ -432,8 +461,19 @@ async function onBivyCommand(msg) {
|
|
|
432
461
|
bivy({ replyTo: id, ok: true });
|
|
433
462
|
bivy({ type: "session.status", status: "working" });
|
|
434
463
|
agentRequest("session/prompt", { sessionId, prompt: [{ type: "text", text: String(msg.text ?? "") }] })
|
|
435
|
-
.then(() => {
|
|
436
|
-
|
|
464
|
+
.then(() => {
|
|
465
|
+
// The prompt reply is NOT the end of the turn for opencode — the last
|
|
466
|
+
// agent_message_chunk frames trail it (see the drain note above). Arm
|
|
467
|
+
// the drain; session.done fires once the update stream goes quiet.
|
|
468
|
+
turnDraining = true;
|
|
469
|
+
scheduleTurnDone();
|
|
470
|
+
})
|
|
471
|
+
.catch((e) => {
|
|
472
|
+
clearTimeout(turnDrainTimer);
|
|
473
|
+
turnDrainTimer = null;
|
|
474
|
+
turnDraining = false;
|
|
475
|
+
bivy({ type: "session.error", error: e instanceof Error ? e.message : String(e) });
|
|
476
|
+
});
|
|
437
477
|
return;
|
|
438
478
|
}
|
|
439
479
|
case "tool.decision": {
|
|
@@ -465,6 +505,11 @@ async function onBivyCommand(msg) {
|
|
|
465
505
|
return;
|
|
466
506
|
}
|
|
467
507
|
case "session.abort": {
|
|
508
|
+
// A pending drain must not fire session.done after the user cancelled —
|
|
509
|
+
// the turn is being torn down, not finishing on its own.
|
|
510
|
+
clearTimeout(turnDrainTimer);
|
|
511
|
+
turnDrainTimer = null;
|
|
512
|
+
turnDraining = false;
|
|
468
513
|
if (sessionId) agentNotify("session/cancel", { sessionId });
|
|
469
514
|
if (id !== undefined) bivy({ replyTo: id, ok: true });
|
|
470
515
|
return;
|
package/dist/runtime/protocol.js
CHANGED
|
@@ -411,6 +411,40 @@ class ProtocolSession {
|
|
|
411
411
|
if (pending)
|
|
412
412
|
this.turnContent.push({ type: "text", text: pending });
|
|
413
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* Fold assistant text that arrives after the turn was sealed (session.done)
|
|
416
|
+
* onto the last persisted assistant message — the ACP end_turn race (see the
|
|
417
|
+
* message.delta branch). The daemon's message_end handler re-snapshots the
|
|
418
|
+
* base transcript, so the tail survives a reopen; live viewers catch up via
|
|
419
|
+
* the emitted message_update + message_end. Text with no assistant message to
|
|
420
|
+
* fold onto is dropped (there is nowhere durable for it to go).
|
|
421
|
+
*/
|
|
422
|
+
foldLateAssistantDelta(text) {
|
|
423
|
+
let lastIndex = -1;
|
|
424
|
+
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
425
|
+
if (this.messages[i]?.role === "assistant") {
|
|
426
|
+
lastIndex = i;
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (lastIndex < 0)
|
|
431
|
+
return;
|
|
432
|
+
const msg = this.messages[lastIndex];
|
|
433
|
+
const raw = msg.content;
|
|
434
|
+
const content = Array.isArray(raw)
|
|
435
|
+
? raw
|
|
436
|
+
: typeof raw === "string" && raw
|
|
437
|
+
? [{ type: "text", text: raw }]
|
|
438
|
+
: [];
|
|
439
|
+
const lastBlock = content[content.length - 1];
|
|
440
|
+
if (lastBlock && lastBlock.type === "text")
|
|
441
|
+
lastBlock.text = `${lastBlock.text ?? ""}${text}`;
|
|
442
|
+
else
|
|
443
|
+
content.push({ type: "text", text });
|
|
444
|
+
msg.content = content;
|
|
445
|
+
this.emit({ type: "message_update", message: { role: "assistant", content } });
|
|
446
|
+
this.emit({ type: "message_end", message: { role: "assistant", content } });
|
|
447
|
+
}
|
|
414
448
|
handleMessage(msg) {
|
|
415
449
|
this.emitter.emit("protocol-message", msg);
|
|
416
450
|
const replyTo = typeof msg.replyTo === "string" ? msg.replyTo : "";
|
|
@@ -450,10 +484,22 @@ class ProtocolSession {
|
|
|
450
484
|
}
|
|
451
485
|
if (type === "message.delta") {
|
|
452
486
|
const text = String(msg.text ?? "");
|
|
453
|
-
if (!
|
|
454
|
-
|
|
455
|
-
this.
|
|
456
|
-
|
|
487
|
+
if (!text)
|
|
488
|
+
return;
|
|
489
|
+
if (this.streaming) {
|
|
490
|
+
if (!this.assistantText)
|
|
491
|
+
this.emit({ type: "message_start", message: { role: "assistant", content: "" } });
|
|
492
|
+
this.assistantText += text;
|
|
493
|
+
this.emit({ type: "message_update", message: { role: "assistant", content: this.assistantText } });
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
// The turn was already sealed (session.done) yet the agent is still
|
|
497
|
+
// streaming text — the ACP end_turn race where the final agent_message_chunk
|
|
498
|
+
// frames land after the prompt reply (opencode#17505). The shim drains the
|
|
499
|
+
// tail before declaring done, but this is the net for a chunk that outlives
|
|
500
|
+
// the drain: fold it onto the last assistant message so it survives a reopen
|
|
501
|
+
// instead of opening a fresh draft that is never persisted.
|
|
502
|
+
this.foldLateAssistantDelta(text);
|
|
457
503
|
return;
|
|
458
504
|
}
|
|
459
505
|
if (type === "message.reasoning" || type === "reasoning.delta") {
|
package/dist/server.js
CHANGED
|
@@ -76,7 +76,7 @@ import { captureDirtyPatch, applyDirtyPatch } from "./session/fork-dirty.js";
|
|
|
76
76
|
import { thinkingTextFromContent } from "./session/transcript-merge.js";
|
|
77
77
|
import { normalizeMessages } from "./session/transcript-normal.js";
|
|
78
78
|
import { buildNativeImportSeedPrompt } from "./session/native-import.js";
|
|
79
|
-
import { EventLog } from "./session/event-log.js";
|
|
79
|
+
import { EventLog, mergeBases } from "./session/event-log.js";
|
|
80
80
|
import { revertFile } from "./session/revert-file.js";
|
|
81
81
|
import { buildDiagnosticsReport, activationRecord } from "./diagnostics.js";
|
|
82
82
|
import { AttachmentStore, isValidAttachmentHash } from "./session/attachment-store.js";
|
|
@@ -2443,12 +2443,25 @@ retireTranscriptsDir();
|
|
|
2443
2443
|
* with no build-free `readMessages` and no external rollout. Skips the write when the
|
|
2444
2444
|
* runtime has nothing yet, so a transient empty read can never clobber a good base
|
|
2445
2445
|
* with `[]`. The log stores it as a bounded delta (see EventLog.appendBaseSnapshot).
|
|
2446
|
+
*
|
|
2447
|
+
* The snapshot must never SHRINK the log. A protocol runtime that resumes via a
|
|
2448
|
+
* blank reconnect (e.g. opencode through the ACP shim: session/load returns no
|
|
2449
|
+
* message history) starts empty and only reports the turns that ran after the
|
|
2450
|
+
* resume — a transcript SHORTER than what the log already holds, despite being
|
|
2451
|
+
* the same ongoing conversation. `appendBaseSnapshot` reads a shorter snapshot
|
|
2452
|
+
* as a compaction and REPLACES the logged base, silently dropping every prior
|
|
2453
|
+
* turn. Rebase the runtime's snapshot onto the logged history instead (the same
|
|
2454
|
+
* union `EventLog.deriveHistory` applies on read): the merged view is exactly
|
|
2455
|
+
* the prefix-extend case the log already handles, so the log grows monotonically.
|
|
2456
|
+
* A genuine runtime compaction isn't a supported feature, so never treating a
|
|
2457
|
+
* snapshot as a shrink is safe.
|
|
2446
2458
|
*/
|
|
2447
2459
|
function persistTranscriptSnapshot(record) {
|
|
2448
2460
|
const base = record.session.getMessages();
|
|
2449
2461
|
if (!base.length)
|
|
2450
2462
|
return;
|
|
2451
|
-
eventLog.
|
|
2463
|
+
const logged = eventLog.readBase(record.id);
|
|
2464
|
+
eventLog.appendBaseSnapshot(record.id, logged.length ? mergeBases(logged, base) : base);
|
|
2452
2465
|
}
|
|
2453
2466
|
// In-flight dedupe so two sessions (or two turns) referencing the same remote
|
|
2454
2467
|
// image URL only ever trigger one outbound fetch. Process-lifetime only — a
|
|
@@ -29,6 +29,30 @@
|
|
|
29
29
|
import fs from "node:fs";
|
|
30
30
|
import path from "node:path";
|
|
31
31
|
import { normalizedIntermediateText, thinkingTextFromContent, mergeTranscript } from "./transcript-merge.js";
|
|
32
|
+
/**
|
|
33
|
+
* Union of a persisted base and a runtime's live transcript, such that the result
|
|
34
|
+
* is never shorter than either input alone. A live session's runtime transcript
|
|
35
|
+
* extends the persisted base, so the union is just the runtime's (no duplication).
|
|
36
|
+
* A resumed runtime that reconnected blank and only re-saw post-resume turns
|
|
37
|
+
* reports a strict prefix of the persisted base, so the union keeps the persisted
|
|
38
|
+
* copy. Disjoint inputs (a resumed runtime whose new turns aren't in the log yet)
|
|
39
|
+
* concatenate in log-then-runtime order. Duplicates are collapsed by message
|
|
40
|
+
* identity, so the same conversation serialized twice never double-appears.
|
|
41
|
+
*/
|
|
42
|
+
export function mergeBases(logged, runtime) {
|
|
43
|
+
if (!logged.length)
|
|
44
|
+
return runtime;
|
|
45
|
+
const known = new Set(logged.map((m) => JSON.stringify(m)));
|
|
46
|
+
const merged = [...logged];
|
|
47
|
+
for (const m of runtime) {
|
|
48
|
+
const key = JSON.stringify(m);
|
|
49
|
+
if (!known.has(key)) {
|
|
50
|
+
known.add(key);
|
|
51
|
+
merged.push(m);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return merged;
|
|
55
|
+
}
|
|
32
56
|
/** Content-block type carried by a folded outbound attachment. MUST match
|
|
33
57
|
* `AGENT_ATTACHMENT_BLOCK` in packages/core/src/store-render.ts — the client's
|
|
34
58
|
* renderHistory keys on this exact string to render the chip. */
|
|
@@ -456,12 +480,21 @@ export class EventLog {
|
|
|
456
480
|
}
|
|
457
481
|
/**
|
|
458
482
|
* The full derived conversation: overlay detail merged into the base transcript.
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
483
|
+
* The base is the UNION of the runtime's live transcript and the base persisted
|
|
484
|
+
* in the log — never one at the other's expense. A live session's runtime
|
|
485
|
+
* transcript extends the log (both are the same conversation, the runtime one
|
|
486
|
+
* message newer), so the union is just the runtime's. But a runtime that resumes
|
|
487
|
+
* via a blank reconnect (e.g. opencode through the ACP shim: session/load
|
|
488
|
+
* returns no message history) reports only the turns that ran AFTER the resume —
|
|
489
|
+
* a truncation of the same conversation, not the whole story. Preferring the
|
|
490
|
+
* runtime base there would mask every prior turn from every history read, so the
|
|
491
|
+
* union keeps the log's fuller copy while still surfacing whatever the runtime
|
|
492
|
+
* alone knows. This is the single read path — it absorbs the former
|
|
493
|
+
* `mergeConversation` helper.
|
|
462
494
|
*/
|
|
463
495
|
deriveHistory(id, runtimeBase) {
|
|
464
|
-
const
|
|
496
|
+
const logged = this.readBase(id);
|
|
497
|
+
const base = runtimeBase && runtimeBase.length ? mergeBases(logged, runtimeBase) : logged;
|
|
465
498
|
return mergeTranscript(base, this.read(id));
|
|
466
499
|
}
|
|
467
500
|
/** Full ordered record list (already-flushed followed by pending). */
|