fix(spaces): keep Current Widget transient fresh and trim-protected#29
Open
nsyring wants to merge 1 commit intoagent0ai:mainfrom
Open
fix(spaces): keep Current Widget transient fresh and trim-protected#29nsyring wants to merge 1 commit intoagent0ai:mainfrom
nsyring wants to merge 1 commit intoagent0ai:mainfrom
Conversation
The Current Widget transient envelope is the authoritative source the agent uses to build exact-snippet patches. Three bugs let trimming and stale state corrupt that source: 1. readWidget did not refresh the transient. Its result landed in chat history only, where the long-message middle-replacement could later inject the placeholder string into the visible widget source. The agent then either copied that placeholder into a `find` snippet (patch fails because the text does not exist in storage) or pasted it into a renderWidget(...) body (the placeholder reads as a JavaScript syntax error - "Unexpected identifier 'characters'" - and the renderer crashes on first execution). 2. A failed patchWidget / renderWidget / upsertWidget left the transient stale. The runtime validator throws (overlapping edits, "patchWidget is for partial only", missing find snippet) before buildWidgetToolResult runs, so the transient still reflects the prior turn or is missing entirely. The agent retries against an out-of-date or absent source, often falling back to a full renderWidget rewrite. 3. The transient itself was trim-eligible by default. Even with read and failed-patch refreshes wired up, the prompt-budget trimmer would still mid-replace the source content under pressure, reintroducing the placeholder-in-renderer corruption. Three changes close the loop: - New refreshCurrentWidgetTransientFromStorage(...) helper reads the widget from storage and republishes the Current Widget section. Called from readWidget on success, and from patchWidget, renderWidget, and upsertWidget try/catch blocks before re-throwing. Refresh failures are silent so the original error is never masked. - Current Widget transient section is now marked trimAllowed: false. Comment at the set-site documents why (placeholder-as-renderer syntax error). The onscreen-agent transient runtime and prompt-item normalizer thread the flag through to the agent_prompt applyPromptPartBudget step, which already honored trimAllowed: false but had no caller passing it through. The change is scoped to onscreen agent surface only. Admin chat does not currently engage the long-message trimmer (it concatenates transient sections directly in api.js:formatTransientMessageBlock). If admin chat later adopts a trimmer, that PR would need to wire trimAllowed through its own normalizeTransientSection in the same pattern; this is intentionally left to that future change rather than adding dead code here. Verified end-to-end with both gpt-5.4 (Codex provider) and a local qwen3-coder model: repeated targeted patchWidget edits across multiple turns without falling back to renderWidget rewrites; transient source remains byte-stable across budget pressure.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Refresh the
Current Widgettransient section afterreadWidget()and after a failedpatchWidget()/renderWidget()/upsertWidget(), and mark the section astrimAllowed: falseso the prompt-budget trimmer cannot mid-replace its content. Together these changes give the agent a stable authoritative widget source on every turn, eliminating the most reliable trigger of "agent rewrites the entire widget" loops.Why
The space-widgets skill contract promises:
Currently the contract is broken at three points:
readWidget()does not refresh the transient. Its result lands in chat history only, where the long-message middle-replacement (trimPromptLongMessageinapp/L0/_all/mod/_core/agent_prompt/prompt-items.js) can later inject the placeholder string<<N characters removed to optimize context, ...>>directly into the visible widget source. The agent then either copies that placeholder into afindsnippet (patch fails because the text does not exist in storage) or pastes it into arenderWidget(...)body (the placeholder reads as a JavaScript syntax error and the renderer crashes on first execution withUnexpected identifier 'characters').A failed write leaves the transient stale. When the runtime validator in
spaces/storage.jsthrows (overlapping edits,"patchWidget is for partial renderer edits only", missingfindsnippet), the call short-circuits beforebuildWidgetToolResult(...)runs, so the transient still reflects the prior turn — or is missing entirely if no successful write has happened yet. The agent retries against an out-of-date or absent source, often falling back to a fullrenderWidget(...)rewrite which can drift further from the current file state.The transient itself was trim-eligible by default. Even with read and failed-write refreshes wired up, prompt-budget pressure would still let the trimmer mid-replace the transient's source content, reintroducing failure mode 1.
Together the three problems form the most reliable trigger of "agent rewrites the entire widget for a small request" that I reproduced repeatedly during local development. They are infrastructural — the skill rules tell the agent the right thing, but the runtime cannot deliver on the promise.
What changed
app/L0/_all/mod/_core/spaces/store.js:refreshCurrentWidgetTransientFromStorage({spaceId, widgetId})helper reads the widget from storage and republishes theCurrent Widgettransient section with the same shape used after a successful write. Silent no-op if the widget cannot be resolved.readWidget(widgetName)calls the new helper after the storage read succeeds.patchWidget,renderWidget, andupsertWidgetwrap their storage call intry/catch, refresh the transient from the (still unchanged) on-disk source before re-throwing, so the agent's next turn sees the real current source.trimAllowed: false. A comment at the set-site documents why: the placeholder-as-renderer corruption mode is correctness-breaking, not just performance-degrading.app/L0/_all/mod/_core/onscreen_agent/store.jsandapp/L0/_all/mod/_core/onscreen_agent/llm.js:normalizeTransientSection(...)andcreateTransientPromptItem(...)now thread thetrimAllowedflag through to the prompt-item definition. Without this propagation, thetrimAllowed: falseset on the section would be lost betweentransient.set(...)and the budget trimmer (which already honoredtrimAllowed: falseinagent_prompt/prompt-items.jsbut had no caller passing it through).The change is scoped to the onscreen agent surface. Admin chat does not currently engage the long-message trimmer —
admin/views/agent/api.js:formatTransientMessageBlockconcatenates transient sections directly without going throughapplyPromptPartBudget— so thetrimAllowedflag would be unread metadata there today. If admin chat later adopts a trimmer, that PR would need to wire the flag through its ownnormalizeTransientSectionin the same pattern; doing it here would just add dead code with no enforcement of the invariant.No new dependencies, no behavior change for the success paths — they continue to refresh the transient via
buildWidgetToolResult(...)exactly as before. The new failure-path refresh runs before the original error is re-thrown, so callers see the same exception they did pre-PR.Failure-path refresh ordering
The refresh runs before
throw error, so:falsesilently — never masks the original write errorNo regression for the success paths
patchWidget/renderWidget/upsertWidgetstill callbuildWidgetToolResult(...)on success, which already refreshes the transient. The new try/catch only adds a refresh on the failure path; it does not change what happens when the call succeeds.readWidget(...)previously emitted the read result as a chat-history message only. With this PR it additionally publishes the same result via the transient envelope; it does not change whatreadWidgetreturns to the caller.Test plan
node --checkon every modified file passestests/spaces_prompt_context_test.mjs,tests/spaces_widget_import_test.mjs)npm run desktop:packbuilds:patchWidgetedits across multi-turn widget development withoutUnexpected identifier 'characters'syntax errors and without falling back torenderWidgetrewrites for small changesmainreliably triggers full-renderer rewritesOut of scope (possible follow-ups)
prompt-items.jsso the placeholder remains syntactically inert (e.g. emitted as a JavaScript block comment) even when it does land inside trimmed history. Orthogonal to this PR; useful even if the transient stays clean, becausereadWidgetresults in chat history can still be trimmed.trimAllowed: falseand lets the budget overflow if the widget is genuinely huge. In practice the default 30% transient budget at 120k+ max-tokens accommodates any widget I have shipped; if this becomes a real problem, a "widget too large; call readWidget(id) directly" hint section would be the right shape.🤖 Generated with Claude Code