@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 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(() => { bivy({ type: "session.status", status: "idle" }); bivy({ type: "session.done" }); })
436
- .catch((e) => bivy({ type: "session.error", error: e instanceof Error ? e.message : String(e) }));
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;
@@ -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 (!this.assistantText)
454
- this.emit({ type: "message_start", message: { role: "assistant", content: "" } });
455
- this.assistantText += text;
456
- this.emit({ type: "message_update", message: { role: "assistant", content: this.assistantText } });
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.appendBaseSnapshot(record.id, base);
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
- * Prefers the runtime's own live transcript when it has one; otherwise replays the
460
- * base persisted in the log (a reopened session on a runtime that can't rebuild it).
461
- * This is the single read path it absorbs the former `mergeConversation` helper.
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 base = runtimeBase && runtimeBase.length ? runtimeBase : this.readBase(id);
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). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.10.0",
3
+ "version": "0.10.1-staging.141",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",