Skip to content

core: transcript shortening clips every tool output in an exchange at once, leaving most of the window unused #61371

Open

Description

@pjiang000-eng

Description

flattenAndDropOldest shortens the summary transcript one whole exchange at a time (packages/core/src/session/compaction.ts:753-759). Grouping starts a new exchange only at a user message (:726), so when no user message has arrived since the last compaction the entire post-checkpoint history is a single exchange.

The first level past WHOLE clips every tool result in that exchange to TRANSCRIPT_CLIP (2,000 characters total, :150) in one pass, even when the request only slightly exceeds its target and most of the window is unused. The detail the summary most needs is destroyed at once rather than trimmed to fit, which is why a compaction request can come back far smaller than the model's window allows.

Steps to reproduce

One exchange with three tool outputs of 150k / 15k / 15k tokens, measured input past the ceiling:

expect(transcript.match(/characters omitted/g)?.length).toBe(1) // Expected 1, received 3

Run from packages/core:

bun run script/test.ts test/session-compaction.test.ts -t "transcript shortening clips"

All three tool results are clipped, although only the excess over the target needed cutting. Shortening the largest tool output first would have kept the two small ones verbatim.

Plugins

none

OpenCode version

dev

Screenshot

No response

Activity

  1. pjiang000-eng commented on Nov 3, 2026

    @pjiang000-eng
    Author

    The test referenced above, for anyone wanting to run it directly. It goes in packages/core/test/session-compaction.test.ts alongside the other summary tests:

       }
     })
     
    const toolAssistant = (outputs: string[]) =>
      Schema.decodeUnknownSync(SessionMessage.Assistant)({
        id: SessionMessage.ID.create(),
        type: "assistant",
        agent: Agent.defaultID,
        model: { id: "summary-model", providerID: "test" },
        content: outputs.map((text, index) => ({
          type: "tool",
          id: `call_${index}`,
          name: "read",
          state: { status: "completed", input: {}, content: [{ type: "text", text }] },
          time: { created: 0 },
        })),
        time: { created: 0, completed: 1 },
      })
    
    it.effect("transcript shortening clips the largest tool output first, not every one at once", () =>
      Effect.gen(function* () {
        // One exchange: no user message after the last compaction, so the level loop has no older exchange to cut first.
        const fixture = yield* summaryFixture([
          textUser("Read the three files"),
          toolAssistant([tokenText(150_000), tokenText(15_000), tokenText(15_000)]),
          measured("Latest answer", summaryWindow + summaryBuffer),
        ])
        expect(yield* fixture.run()).toEqual({ status: "completed" })
        const transcript = JSON.stringify(requests.at(-1)?.messages)
        // Only the excess needs cutting, so the two small results should stay verbatim.
        expect(transcript.match(/characters omitted/g)?.length).toBe(1)
      }),
    )
    
     for (const background of [false, true]) {
       it.effect(`summary cache prefix uses measured input with old reasoning (background=${background})`, () =>
         Effect.gen(function* () {

    Observed here: Expected: 1, Received: 3 — all three tool outputs clipped in one pass.

  2. opencodejr commented on Nov 3, 2026

    @opencodejr

    Thanks for tracing this through the code and including a failing test.

    You're asking for the summary transcript to be shortened in smaller steps inside one exchange, for example by clipping the largest tool output first, so that when a request is only slightly over its target the smaller tool outputs stay verbatim and the summary can use the rest of the window.

    As written, flattenAndDropOldest deliberately moves a whole exchange from one shortening level to the next. So what you describe is how it was built, not a malfunction, and I've marked this as an improvement request for the maintainers to consider. The test you included will help if they pick it up.

    Related:

  3. IgorGanapolsky commented on Nov 3, 2026

    @IgorGanapolsky

    Nice trace, and the failing test is exactly what makes this actionable.

    While the maintainers weigh finer-grained shortening inside an exchange, there's a stopgap that fits this failure mode: instead of clipping tool outputs down to fit the transcript, shunt oversized tool outputs out of the session and hand the agent a pointer it can reopen selectively. The clip decision then happens per tool output rather than per exchange, so the 15k outputs stop costing window without being destroyed, and the summary keeps the room it actually needs.

    Disclosure: I'm the founder of ThumbGate, a guardrails layer for agent sessions that does selective tool-output shunting. You can wire it onto a session with npx thumbgate init; for teams running this in production, the direct route is https://thumbgate.ai/go/pro-direct.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions