Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
- Added the `commandcode-goat` model profile for the Command Code GOAT provider, assigning GLM-5.3 to the default role, DeepSeek V4 Flash to execution, Kimi K3 to planning, GLM-5.2 to criticism, and DeepSeek V4 Pro to architecture.

- Session endpoints hosted on the notification-adapter transport now deliver every ring-retained session event live to attached SDK subscribers as the same positioned `event` envelope (`generation`/`seq`) that `event_replay` returns, sent per connection over the validated directed leg with the same capability gating replay applies. Previously the live leg only pushed raw side-channel frames — the native broadcast enum reduced non-native kinds (including terminal `agent_end` lifecycle) to empty `unknown` frames, and correlated lifecycle reached only the submitting connection — so an already-attached direct SDK subscriber could observe a later positioned event, including a turn's terminal lifecycle, only by issuing another replay. Each connection's directed writer now bounds queued host frames to the replay-ring capacity; a lagged subscriber rejects additional best-effort live sends and recovers through replay (including the existing sequence-gap contract) instead of growing an unbounded backlog. Ring persistence, replay ordering, event positions, correlated requester delivery, and native notification frames are unchanged.
- Persisted edit results no longer inline complete pre/post-edit file snapshots. `EditToolDetails.oldText`/`newText` (and each `perFileResults[]` copy) larger than 16 KiB are replaced at the session-persistence boundary by a fixed-size digest receipt (`oldTextDigest`/`newTextDigest`: UTF-8 byte length + SHA-256) plus an externalization marker, so a tiny `apply_patch` to a large file costs bytes proportional to its diff instead of ~2x the file size in the managed transcript (#4566). Live in-process results still carry full bodies for ACP `diff` ToolCallContent and editors; sub-16 KiB snapshots persist verbatim; diffs, paths, ops, first-changed-line, diagnostics, and meta are unchanged. A regression suite pins bounding, inline sub-cap behavior, multi-file copies, and the near-limit committed-edit durability contract.
- Near-limit managed appends are now a typed, deterministic outcome instead of a silent recovery or an unclassified abort. When a live append crosses the 128 MiB managed per-file cap, the existing full-rewrite recovery runs and is then verified: if the recovered transcript still cannot hold the entry (or the rewrite itself hits `content_too_large`), the append throws `SessionNearLimitAppendError` carrying structured fields (`code: "near_limit_append"`, entry/live/cap bytes, whether the entry is retained in memory) and a message stating whether the committed edit's receipt is preserved and how to continue (`/compact` or `gjc export`). `AgentSession` maps it to a structured tool-result outcome instead of a generic fatal `SessionAppendPersistenceError`, so a committed source mutation can no longer lose its receipt silently (#4566).
- Telegram notification delivery now carries an explicit per-update inbound acknowledgement contract: user messages are acked `accepted` at session preflight acceptance (before the turn starts, so a fast turn can no longer out-race the pending-update registration), late admission failures ack `rejected`, and genuinely discarded frames ack `dropped`. Policy-suspended control commands are deferred to activation instead of being acked as dropped, per-update reaction transitions are serialized with terminal states monotonic (a slow queued 👀 can no longer overwrite a later ✅), and retraction sends the empty reaction list the Bot API requires. Daemon generation bumped 167→168. (#4528)

- Managed fallback local snapshot failures now surface their one producer-boundary diagnostic immediately instead of re-issuing the identical request up to three times. The failure still never charges the provider fallback chain, advances models, or mutates credentials.
Expand Down
40 changes: 40 additions & 0 deletions packages/coding-agent/src/edit/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Edit tool renderer and LSP batching helpers.
*/

import { createHash } from "node:crypto";
import type { Component } from "@gajae-code/tui";
import { Text, visibleWidth, wrapTextWithAnsi } from "@gajae-code/tui";
import { sanitizeText } from "@gajae-code/utils";
Expand Down Expand Up @@ -85,6 +86,45 @@ export interface EditToolDetails {
newText?: string;
}

/**
* Bounded durable identity of one edit snapshot (#4566).
*
* Live edit results carry full `oldText`/`newText` file bodies so in-process
* consumers (ACP `diff` ToolCallContent, editors) keep working. Every persisted
* transcript entry replaces those bodies with this fixed-size receipt: byte
* length plus SHA-256 content digest, enough to detect source drift and to
* account for the edit durably without re-writing the whole file per edit.
*/
export interface EditSnapshotReceipt {
/** UTF-8 byte length of the snapshot (`0` for create/delete-absent sides). */
bytes: number;
/** SHA-256 hex digest of the exact snapshot text (empty string for length 0). */
sha256: string;
}

/** Per-edit-mode cap on any single persisted edit-result string field (#4566). */
export const EDIT_PERSIST_FIELD_MAX_CHARS = 16 * 1024;

/** Fixed marker used when a snapshot receipt replaces a full body. */
export const EDIT_SNAPSHOT_EXTERNALIZED_NOTICE =
"[edit snapshot externalized: see oldTextDigest/newTextDigest; full body omitted from transcript]";

function sha256Hex(text: string): string {
if (text.length === 0) return "";
return createHash("sha256").update(Buffer.from(text, "utf-8")).digest("hex");
}

/** Build the bounded durable receipt for one snapshot body. */
export function editSnapshotReceipt(text: string | undefined): EditSnapshotReceipt | undefined {
if (text === undefined) return undefined;
return { bytes: Buffer.byteLength(text, "utf-8"), sha256: sha256Hex(text) };
}

/** True when a snapshot body is small enough to persist inline without amplification. */
export function editSnapshotPersistableInline(text: string | undefined): boolean {
return text !== undefined && text.length <= EDIT_PERSIST_FIELD_MAX_CHARS;
}

// ═══════════════════════════════════════════════════════════════════════════
// TUI Renderer
// ═══════════════════════════════════════════════════════════════════════════
Expand Down
42 changes: 42 additions & 0 deletions packages/coding-agent/src/session/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ import {
SessionAppendPersistenceError,
SessionContextTooLargeError,
SessionManager,
SessionNearLimitAppendError,
transferSessionMessageIdentity,
} from "./session-manager";
import { getEntriesForInternalRead, getSessionContextForInternalRead } from "./session-manager-internal";
Expand Down Expand Up @@ -5015,6 +5016,47 @@ export class AgentSession {
try {
this.sessionManager.appendMessage(event.message);
} catch (error) {
// Typed near-limit append (#4566): the transcript hit the managed
// per-file cap and even the live-entry rewrite could not hold this
// entry. The edit/effect itself may already be committed; surface a
// structured tool-result outcome stating that and the exact
// continuation path instead of a generic fatal abort. The typed
// error already performed deterministic recovery, so the session is
// not poisoned and the turn ends with actionable state.
if (error instanceof SessionNearLimitAppendError) {
this.agent.abort();
if (event.message.role === "toolResult") {
event.message.isError = true;
const committed = error.entryRetained
? "The edit committed and its receipt is retained in the live session; it will persist on the next successful write."
: "The edit committed on disk but its receipt could not be retained in the live session.";
event.message.content = [
{
type: "text",
text: [
"Session transcript reached the managed per-file limit; this result could not be recorded durably.",
committed,
"Continue by compacting the session (`/compact`) or exporting to a fresh session (`gjc export <session-file>`); re-verify the edited file before relying on it.",
].join("\n"),
},
];
event.message.details = {
...(event.message.details && typeof event.message.details === "object"
? event.message.details
: {}),
failureKind: "persistence",
nearLimitAppend: {
code: error.code,
entryBytes: error.entryBytes,
liveBytes: error.liveBytes,
capBytes: error.capBytes,
entryRetained: error.entryRetained,
},
};
this.agent.touchContext();
}
return;
}
if (
event.message.role !== "toolResult" ||
event.message.toolName !== "todo_write" ||
Expand Down
Loading
Loading