Skip to content

feat(desktop): show the context ring what a compaction just freed - #700

Open
zhangqingkun976 wants to merge 1 commit into
vastsa:mainfrom
zhangqingkun976:feat/post-compaction-occupancy
Open

zhangqingkun976 wants to merge 1 commit into
vastsa:mainfrom
zhangqingkun976:feat/post-compaction-occupancy

Conversation

@zhangqingkun976

@zhangqingkun976 zhangqingkun976 commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

When a compaction lands, the context ring keeps showing the pre-compaction
request: the checkpoint changes what the next request will carry, but the ring
only updates when a provider response reports usage, so until then the UI
quietly understates the available room by the whole compacted range.

Nothing on disk records what the window shrank to. The visible transcript is
untouched by a compaction — that is its point — so the only place the new
occupancy can live is the checkpoint itself.

This stamps it there, and lets the ring lead with it:

  • CompactionRecord.tokensAfter (Rust + shared type): optional, so a
    transcript written before the field existed loads with None and an older
    reader ignores the extra key on a newer line. A test writes a legacy
    checkpoint line by hand and reads it back.
  • persistCheckpoint computes it: the estimate of the compacted projection,
    plus the gap between tokensBefore (a measured request size) and the
    pre-compaction estimate — that gap is the system prompt and tool schemas the
    next request pays again, and without adding it back the number would not be
    comparable with the request-based occupancy the ring shows elsewhere.
  • contextCompactionMark carries it onto the mark, so the live
    compaction_end event and the store both have it without extra payload.
  • latestTurnContextInspector surfaces estimatedOccupancyTokens only
    while the newest checkpoint is newer than the latest usage-bearing message —
    the next real request replaces it, and the popover's provider rows keep the
    last real usage either way.
  • ContextUsageInspector leads the ring and heading with the estimate and
    prefixes it with ≈, because it is an estimate, not a measurement.

Verification (Windows 11; re-run on the rebased head ce620f07, with
@pi-desktop/shared rebuilt first):

cargo fmt --all -- --check                                 exit 0
cargo test -p host-core --locked                            581 passed / 4 failed
  (the four are the pre-existing Windows path-separator asserts in
   mcp_servers, user_skills, scheduled_rpc and scheduled_tools — identical
   on pristine main)
pnpm --filter @pi-desktop/shared test                      80 files / 934 tests passed
pnpm --filter @pi-desktop/agent-runtime typecheck          exit 0
vitest run (full agent-runtime suite)                      763 passed / 1 failed
  (the pre-existing native-pi-session case again)
node --test apps/desktop/test/latest-turn-context.test.mjs 9 passed / 0 failed
pnpm test:e2e (scripts/e2e-smoke.mjs)                      23 passed / 2 skipped

cargo fmt --all -- --check                                 exit 0
cargo test -p host-core --locked                            549 passed / 4 failed
  (the four are the pre-existing Windows path-separator asserts in
   mcp_servers, user_skills, scheduled_rpc and scheduled_tools — identical
   on pristine main. One further Rust test,
   rpc::tests::tools_abort_during_execution_kills_bash_and_cleans_registry,
   timed out once under full-suite load, then passed standalone here 3/3
   and on pristine main)
pnpm --filter @pi-desktop/shared typecheck                 exit 0
pnpm --filter @pi-desktop/shared test                      78 files / 901 tests passed
pnpm --filter @pi-desktop/agent-runtime typecheck          exit 0
vitest run (full agent-runtime suite)                      749 passed / 1 failed
  (the pre-existing native-pi-session case again)
node --test apps/desktop/test/latest-turn-context.test.mjs 9 passed / 0 failed
pnpm test:e2e (scripts/e2e-smoke.mjs)                      23 passed / 2 skipped

Three new tests cover the inspector's rule — leads with the estimate after
compaction, drops it once newer usage arrives, ignores marks without one — and
one shared test covers the record → mark hop. Mutation-checked: removing the
"newer usage wins" condition turns the second test red and restoring it turns
the suite green again.

One existing runtime test changed: the strict mark assertion in "compacts at
the turn boundary" now also requires tokensAfter: expect.any(Number), which
is the new contract, not a relaxation.

Note on CI: this branch used to inherit the Typecheck Desktop failure from
user-login-path.ts; that one-line fix landed in main as #692, so the
rebased head does not carry it.

Base and head

base 206085c07 · head ce620f07 · 10 files, +221 / −7. Rebased onto current main (82 commits
further on); it applied cleanly except for one same-point insertion in
ModelSelectionPanes.tsx on the branches that touch it, where both sides were
kept. The published tree is verified to be the same object the gates ran on:

local tree : 5df7af8599af0a79735b93c06e135924c7b25231
remote tree: 5df7af8599af0a79735b93c06e135924c7b25231

@zhangqingkun976

Copy link
Copy Markdown
Contributor Author

#688 / #700 / #705 are all included in #721

These three are the same work split small. I have assembled them (plus the
recall tools, the session ledger, the configurable gate/silent pass/sleep
digest and the single-tier wording) into one change: #721
feat/context-and-compaction-set.

Nothing in them changed; #721 is strictly a superset. Review whichever shape you
prefer �� if you would rather land the small ones one at a time, they are still
accurate as written and I will keep them updated against main. If you would
rather review the feature as a whole, #721 is the one to take and I will close
these three on request.

zhangqingkun976 added a commit to zhangqingkun976/PI-Desktop that referenced this pull request Sep 20, 2026
…r-model context settings

# Context and compaction, as one set

This assembles the context/compaction work into a single reviewable change: the
model can read its own history back, compaction stops being a one-way door, and
the two thresholds that decide *when* it happens are configurable per model.
It subsumes my three open PRs (vastsa#688, vastsa#700, vastsa#705) — those are the same code, split
differently; review whichever shape you prefer.

## 1. The model can read its own history (`recall`, `recall_project`)

**Host.** `recall_transcript` searches a session's complete transcript and ranks
candidates by match strength (how many query words, how often), and
`read_message_text` pages one message back in bounded character windows — which
is how a tool result (not in the word index) is read. `search_project_messages`
+ `read_project_messages` do the same across one project's sessions, and both
take a project id the host resolves from a path; a session bound to another
project reads as *not found*, so session existence never leaks across projects.
`SleepRecord` + `append_sleep` add a `sleep` line kind to the transcript: the
layout scan ignores it and it never counts as a message. Five RPC methods carry
this: `session.recall`, `session.readMessage`, `search.query`,
`session.readProject`, `session.appendSleep`.

**Model side.** `packages/agent-runtime/src/recall-tools.ts` holds both tools and
takes a two-method host adapter (so both are unit-testable without a session,
a provider or a process). Both are registered in the **core** tool set: a
capability the model has to go looking for is one it will not reach for while it
is trying to recover what a summary left out.

The query rule is the host's, not a preference, so the description states it
(split into words, every word must appear, unspaced Chinese matches literally)
and tells the model to write the query in the words of whoever wrote the
message. An empty answer says what to try instead; a bare "no matches" teaches
nothing.

## 2. Compaction stops being a one-way door

- **The projected summary carries a recall pointer**: pre-boundary messages are
  not deleted, and `recall` reads them back verbatim. The pointer is a promise,
  so it is only appended because the tool exists.
- **A session ledger** is built mechanically when a checkpoint is installed
  (files read/modified, commands, message and tool-call counts, plus the goal
  and the unresolved items) and stored on the checkpoint's opaque `details`.
  The projection renders it as a bounded block after the summary, so the next
  window keeps an account of what the boundary covered — text that is one
  `recall` call away. A checkpoint written before the ledger existed projects
  exactly the pointer and nothing else.
- **The wording now matches the mechanism.** The second budget reminder
  (Codex's `AutoCompactFallbackPrompt`: "unsummarized detail will not be
  available afterwards") is deleted rather than rephrased — that sentence is
  false here. The rollover text says where the messages went and how to read
  them again instead of telling the model the transcript is available "to the
  user".

## 3. Two thresholds, per model

| Setting | Default | What it does |
| --- | --- | --- |
| `dynamicContext.thresholdPercent` | window-aware (`100 − max(100k, 15%×window)/window`, clamped 40–95) | At this share of the hard limit, old tool results are shortened to a head plus a recovery pointer. Off means nothing is narrowed. |
| `earlyCompaction` `{thresholdPercent, delaySeconds, silent}` | 75 / 120 s / silent | After a run settles and the session stays idle that long, the pass compacts **in the background**. Any new prompt stands it down before it spends its summary request. |
| `sleepTime` `{enabled, maxRunsPerHour}` | off / 2 | On the idle arm, write a deterministic digest of where the work stands to the transcript (no model call), so a summary request that never returns still leaves the next window with the goal and the open items. |

The **coupling** is the point: the narrowing gate is what the outgoing request,
the hard-boundary check and the early pass all read, so what narrowing saves is
visible to compaction instead of compaction firing while real room remained. The
early pass is the one place that spends a summary request off the critical path,
and it is silent **only when it succeeded** — a degraded pass warns exactly like
the inline path, and the hard boundary always warns.

`compaction_end` reports `idle` and `silent`; the transcript row, the context
inspector and `recall` record the checkpoint either way, and the renderer skips
only the routine toast.

## Verification (Windows 11)

    cargo test -p host-core --bin pi-desktop-host-core     555 passed, 4 failed
    cargo fmt / clippy                                     clean (1 pre-existing warning)
    pnpm --filter @pi-desktop/shared typecheck|build        exit 0
    pnpm --filter @pi-desktop/agent-runtime typecheck       exit 0
    pnpm --filter @pi-desktop/agent-runtime test            45/46 files
    pnpm --filter @pi-desktop/desktop typecheck             exit 0

- The 4 Rust failures are pre-existing Windows path-separator assertions in
  `mcp_servers`, `user_skills`, `scheduled_rpc` and `scheduled_tools`; two of
  those files are untouched by this change (hash-verified).
- The 1 agent-runtime failure is the pre-existing
  `src/native-pi-session.test.ts` case ("never deletes a foreign publication…"),
  which fails identically without this change.
- New tests: 6 Rust (recall matching/ranking, non-ASCII folding, character
  paging, sleep append + layout, project search scoping, project read
  isolation), 10 for the recall tools (both tools, paging, the empty-answer
  advice, the output cap, project scoping, a host failure surfacing as text),
  2 for the summary projection (pointer + ledger; a pre-ledger checkpoint
  projects the pointer alone).
- Four existing tests were updated to the new contract, each strengthened rather
  than relaxed: the core tool list now includes both recall tools; the budget
  reminder is asserted to have **one** tier (and the sharper second one to be
  absent); the projected summary is asserted to carry the recall pointer; the
  dormant second-tier flag is gone.

## What is deliberately not here

- **The settings pane UI.** The three settings are typed, clamped, persisted with
  the model binding and carried on the launch payload, but the pane's controls
  and their translations are not in this change — so today they are set through
  the binding rather than the UI. That is the next piece.
- **Estimate calibration (vastsa#683).** The reviewer asked for a one-way (never
  lower) correction and a ratio-based unanchored term before it lands; that
  rework is in progress and will come as its own PR.
- **The semantic/embeddings recall channel.** Measured and frozen in my build:
  it needs a model asset and a host channel for a gain the lexical channel
  already gets on the queries that matter here.
@zhangqingkun976
zhangqingkun976 force-pushed the feat/post-compaction-occupancy branch from 2dbefa0 to 857a22d Compare September 21, 2026 00:15
@zhangqingkun976

Copy link
Copy Markdown
Contributor Author

Rebased onto main (79cd7ae8); it applied cleanly, no conflicts. New head 857a22d9.

Local before pushing: shared 78/78 test files, agent-runtime 47/48 files (the one failure is the pre-existing native-pi-session case, identical on pristine main), the two desktop contract files touched here 22/22, cargo fmt --all -- --check clean, cargo test -p host-core --locked 550 passed / 4 failed — the four are the pre-existing Windows path-separator assertions in mcp_servers, user_skills, scheduled_rpc, scheduled_tools, none of which this change touches. The new Rust case (a_checkpoint_without_a_post_compaction_estimate_still_loads) passes.

This PR is also part of #721, if you would rather review the feature as a whole.

@zhangqingkun976
zhangqingkun976 force-pushed the feat/post-compaction-occupancy branch 2 times, most recently from 5df43d5 to f78b337 Compare September 21, 2026 05:25
@zhangqingkun976

Copy link
Copy Markdown
Contributor Author

Rebased onto main (ab39a9b4) so the new Head contains latest base gate passes. Head is now f78b3374.

The only conflicts were append-vs-append — the E2E scenario catalog and the decisions log, where main and this branch each added an entry — and both sides are kept. The identifier bands are still free: upstream's newest ADR is 0299 and its newest decisions-log entry is D607 (the plugin crash report, which is this repository's own fix for issue #747 built on my #756).

@zhangqingkun976
zhangqingkun976 force-pushed the feat/post-compaction-occupancy branch from f78b337 to c7ce845 Compare September 21, 2026 09:28
@zhangqingkun976

Copy link
Copy Markdown
Contributor Author

Rebased onto main (b71fcf05, 39 commits further on); clean. New head c7ce845b.

Local gates on the rebased head:

  • cargo fmt --all -- --check exit 0; cargo test -p host-core --locked 549 passed / 4 failed — the four are the pre-existing Windows path-separator asserts (mcp_servers, user_skills, scheduled_rpc, scheduled_tools), identical on pristine main. One further Rust test (rpc::tests::tools_abort_during_execution_kills_bash_and_cleans_registry) timed out once under full-suite load and then passed standalone on this branch 3/3 and on pristine main — load flake, not this change.
  • shared typecheck 0 / 78 files, 901 tests passed; agent-runtime typecheck 0 / suite 749 passed, 1 failed (the same pre-existing native-pi-session case).
  • node --test apps/desktop/test/latest-turn-context.test.mjs 9/9.
  • pnpm test:e2e (scripts/e2e-smoke.mjs, the headless protocol suite) 23/23 passed, 2 skipped — and this one does touch the changed surface: the suite persists a session (E2E-020-session-persist) and round-trips session revisions (E2E-SESSION-revision-round-trip) against the host binary built from this branch, which is where tokensAfter is written and read back.
  • node scripts/check-pr-base-main.mjs passed.

The description now carries the base/head line and the E2E status in full.

@zhangqingkun976
zhangqingkun976 force-pushed the feat/post-compaction-occupancy branch from c7ce845 to e98d0db Compare September 21, 2026 12:34
@zhangqingkun976

Copy link
Copy Markdown
Contributor Author

Rebased onto main (43a37373, 24 commits further on). New head e98d0db3, one commit.

It applied cleanly, including the convertToLlm seam in runtime.ts that main has since rewritten: your tool-call-dedupe.ts refactor of my #780 now sits there, and git's three-way merge composed it with this change instead of dropping either side. That composition is verified rather than assumed — on the two branches that add a pass at that seam (#705 and #721, a superset of it) the seam now reads this.narrowToolResultsUnderPressure(this.dropDuplicateToolCalls(messages)), so the dedupe runs first and the tiering second. Both passes are identity functions when they have nothing to do, which keeps this PR's original promise that an ordinary request is byte-identical.

Local gates on the rebased head: node scripts/check-pr-base-main.mjs passed; cargo fmt --all -- --check 0 where the branch touches Rust; pnpm docs:check 79 EN/zh pairs, 499 pages on the branches that touch docs. mergeable=true, behind=0.

One thing to know when reading the JS job: main has landed image generation and its new suite is red on main itself. Pristine 43a37373 fails 8 tests across 4 files (native-pi-session, parent-host-proxy, image-generation, openai-images-contract); this branch fails exactly the same 8 and adds its own on top. I have not touched those files.

Rebased onto main (79cd7ae); the rebase applied cleanly, no conflicts.

A compaction leaves the message transcript untouched by design, so the ring kept
showing the pre-compaction request until the next provider response landed.
`CompactionRecord.tokensAfter` (optional; a checkpoint written before the field
existed reads as absent) carries the post-compaction estimate, the mark and the
store carry it, and the inspector leads with it until a newer real request
reports usage.

C:\Users\10470\.pi-desktop\scratch\cb9e2d41-8a55-4a18-899d-92ca2791ab51\commit-700-r2.txt
@zhangqingkun976
zhangqingkun976 force-pushed the feat/post-compaction-occupancy branch from e98d0db to ce620f0 Compare September 22, 2026 01:34
@zhangqingkun976

Copy link
Copy Markdown
Contributor Author

Correction to my previous comment on this PR. I wrote that pristine main failed 8 tests across 4 files (native-pi-session, parent-host-proxy, image-generation, openai-images-contract) and that the image-generation failures were main's own. That was wrong, and the fault was mine, not main's.

What actually happened: my local packages/shared/dist was stale. main had landed image generation, which added exports to @pi-desktop/shared, and my built dist predated them — so agent-runtime imported names that did not exist in the artifact it was type-checking and running against. Six of the eight failures, and the three typecheck errors I saw, were produced by my environment.

After rebuilding @pi-desktop/shared and re-measuring on pristine main (206085c07):

  • pnpm --filter @pi-desktop/agent-runtime typecheck → clean, 0 errors
  • pnpm --filter @pi-desktop/agent-runtime test → 763 passed, 2 failed

The two real baseline failures are:

  1. src/native-pi-session.test.ts > native fork children > never deletes a foreign publication and classifies the failure path-free — a long-standing Windows path assertion, unrelated to this change.
  2. src/hosted-search-contract.test.ts > forwards hosted_search_update as message_update — a 5 s timeout that is intermittent: it passes standalone and on reruns.

So main is not red, and image generation is not broken. I should have rebuilt the workspace dependency before drawing a conclusion from a failure I did not recognise; the fact that the failures appeared only after main gained a new feature should have pointed at my stale artifact first.

This head ce620f07 was measured the same way, with the dependency rebuilt: the suite fails exactly the single native-pi-session case above, and nothing else. No file in the image-generation or hosted-search area is touched by this PR.

Apologies for the noise — and for stating a baseline I had not verified. The CI panel on this head is the authoritative record, and it is green.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant