# Answer again · dsh-session-retry [中文](README.md) | [English](README.en.md) > Adds an **Answer again** button to the assistant message action strip: it re-submits the > user prompt that this turn's answer belongs to, to the **same session**, and **first takes > that turn's old answer out of the model's context** — when answering again the model cannot > see what it itself answered in that turn last time (the log and the history messages in the > UI are untouched). The button sits between copy and branch (`conversation.chat.assistant-actions`, `order: 20`), and looks the same as the native action-strip buttons: a square icon button, a dark tooltip bubble on hover, shown and hidden together with that action strip on hover/focus (on the `data-clock=end` turn its size follows the native icons). Both the icon and the tooltip bubble ship inside the plugin: plugin practice requires that a third-party plugin's browser half does not `require` Harness client packages, so this one only does `require('react')`. - Row id: `session-retry` - Position: the assistant message action strip (`conversation.chat.assistant-actions`) - Dependencies: none (the host half injects `connection` and `agents`, the browser half depends on `react` only) ## What it does - The **host half** ([index.js](index.js)) registers two routes on the shared `/api` channel: ``` POST /api/session-retry/context { sessionId } (read-only) → { ok, sessionId, latestTurn, cached, anchors: [ { messageId, turn, text, promptCount, omittedParts, promptSeq, anchorSeq } ] } POST /api/session-retry/regenerate { sessionId, messageId } (registers the rewrite) → { ok, turn, promptSeq, runs, shadowed, kept, eventSeqs, deferred: true } ``` The first sweeps the whole session log once, pairing "which assistant message the action strip hangs on" with "that turn's user prompt", and caches the result per session for 1.5 seconds. The second accepts **live sessions** only: it takes the session from `sessions.get(id)`, recomputes the anchors on the spot with `snapshotEvents()` (the same `retryTargets` rules as `/context`), then replaces that turn's visible-surface nodes with empty nodes — but it does **not** write at the moment the button is clicked; it registers a shadow plan and writes it in later, when the next request really opens a step (why, see the next section). - The **browser half** ([client.js](client.js)) registers one action-strip entry per session: it fetches the table above once, and each action strip looks itself up by its own `messageId` — a hit makes it clickable, a miss greys it out and states the reason. On click it **first** POSTs `/regenerate`, and **only on success** submits the prompt with `session.prompt([{ type: 'text', text }], 'queue')` (the same path as `ui-conversation`'s `conversation.send`) — only when both steps succeed does it say "answered again (the old answer has left the context…)"; if the rewrite fails it only relays the reason and submits nothing, because submitting anyway would ask the prompt a second time while the old answer is still in the context. ## How the old answer disappears What gets rewritten is the **visible surface (surface)**, not the log: the session record, the history messages in the UI, the title and the statistics are all kept as they are, and only the message list handed to the model drops this stretch — what the model can see is decided by `dsh-session`'s surface folding and `deriveMessages()`. The vehicle is the surface's own replacement channel: append a `system/message` event carrying `surfaceOp: { op: 'replace', startSeq, endSeq }` and `sourceEventSeqs` (every shadowed node must be listed; the app validates this strictly). The carrier must be an **empty system node**: the app's own rule is "an empty system/developer node keeps its visible-surface position but produces no wire message" (`dsh-session`'s `deriveEventMessage`), whereas an empty `user/message` really is sent out as an empty user message. **The timing of that write is a hard constraint**: `system/message` is a V4 "step event", and validation requires its `turn`/`step` to equal the **currently open** pair (`dsh-session-format-v3-to-v4`'s `STEP_EVENT_TYPES` + `requireStep`). At the moment the button is clicked the session is **idle** — the previous turn has already `turn/end`, the next one has not `turn/start` yet, no step is open — and writing it there with the previous turn's `turn`/`step` corrupts the log on the spot: ``` SessionFormatError: system/message does not match an open turn and step → stored log is corrupt ... ``` So `/regenerate` only **registers** the shadow plan (`deferred: true`, the visible surface is untouched at that moment), and when the prompt the client submits really opens a step, the write is made up in the `agent/request` hook with the **turn/step open at that time**. That spot is both legal (the step really is open) and early enough — `dsh-agent-loop`'s `step()` does `step/start` first, then `prepareRequest`→`agent/request` (hook), and only then `buildRequest`→`deriveMessages()`, so this very request from the user already cannot see the old answer. It is also fine if the session is closed before the deferred write: the plan carries a TTL, is dropped on expiry, and nothing was ever written to the log. **How the shadow range is computed**: every visible-surface node gets a "content interval end" — an append-type node is new content itself, so its end is its own seq; a replace-type node inherits the position of the stretch it replaced (that is how a compaction summary occupies an early position), and its end is the far end of the replaced stretch. A node whose end precedes this turn's prompt is "memory from before the prompt" (a compaction summary produced before the prompt, say), and is **kept**; a node whose end is not before the prompt is a product of this turn and is **shadowed out**. The nodes to shadow are not necessarily contiguous on the visible surface, so they are cut into contiguous runs by visible-surface index and appended one run at a time (each run leaves one empty node in place). This decision is the pure function `shadowPlan()`, so both real logs and synthetic events can be fed straight into tests. ## Targeting rules (aligned exactly with the UI) The action strip is rendered by `ui-chat` at the turn tail: one per turn, hung on the **last** `assistant/message` of the **last step of that turn that produced text** (`closing = finalized.findLast(hasText)`). This plugin's anchor is that message, so: - a turn with no step that produced text (interrupted midway, say) → the UI renders no action strip in the first place, so there is naturally no button either; - the turn's prompt = the human messages after `turn/start` and before the anchor (`source.kind === 'user'`). Injected context (`time-context`, `skill-catalog`, plugin notifications) and compaction checkpoints are not prompts; replace operations (`surfaceOp` as an object, which is how compaction rewrites old messages) are not appends either and take no part in the decision. **More than one prompt in a turn**: when the user speaks while the model is answering (steering / queued), that message is appended into the same turn. In that case the turn's prompts are **merged into one stretch** in order and resubmitted — picking one of them would drop the other, while merging loses nothing and submits once. The notice states plainly "merged N prompts". ## Deliberate trade-offs - **Shadow out rather than delete**: the host has no public API to truncate history or roll back a turn, and events already written to the log cannot be deleted either, so this is "make the model unable to see it". To genuinely start a fresh stretch of history, use the **branch** button on the action strip (it opens another session). - **Answering an earlier turn again = falling back to that turn and starting over**: the shadow range runs from that turn's prompt to the end of the visible surface, so the turns after it leave the context as well (otherwise the model would answer the earlier turn while carrying "what has already happened since"). - **Live sessions only**: `/regenerate` needs the in-memory session object in order to rewrite; when the session is not open in the app it answers `NOT_LIVE`. When the session is answering it answers `RUNNING` — rewriting the visible surface mid-answer is the kind of controlled scenario compaction is, and it is not done here (the notice says outright "stop first, then answer again"). - **Non-text content is not resubmitted**: only text blocks are resubmitted; images and attachments in the original message are only counted, and the notice states "N non-text items were not resubmitted". - **One request answers for the whole session**: there is one action strip per turn, so a long session has dozens of them mounted on the page at once; asking one at a time would mean dozens of log replays. So the client fetches the table once per session, and the action strips mounted in the same commit share that one request (an action strip mounted later fetches once more, so what it sees is the decision as of its own mount). ## Engineering constraints - **The browser half depends on `react` only**: `dsh.client.inject` declares only `@deepseek-ai/dsh-client-ui-chat` (the slot's owner), and `immediately: true` makes the front end prefetch that package at startup. The icon (`RefreshIcon`, geometry copied from the native refresh icon) and the tooltip bubble (pure CSS show/hide, no portal, no writing to `document.body`) are both carried inside [client.js](client.js). - **The host half injects `connection` and `agents`** (`agents` is required by the `agent/request` hook that does the deferred write): neither `sessionQuery` nor `sessions` is injected, and a missing one degrades to an unavailable answer — in a bundle without `sessionQuery`, `/context` answers `UNAVAILABLE`, and in a bundle without `sessions`, `/regenerate` answers `UNAVAILABLE` too; neither takes the plugin down. - **The empty node is written inside an open step**: the deferred write happens only in `agent/request` (the step is already open, the messages are not assembled yet), so the empty node's `turn`/`step` always equal the pair open at that moment and the log cannot be corrupted; when no step is open (if another bundle calls the hook early, say) nothing is written at all and the plan waits for the next step. ## Install This plugin is a standard DSH bundle: `package.json` declares `dsh.bundle.patch` and `dsh.client`, and installing it into any profile takes the same path. Install the DSH desktop app first ([deepseek.com/download](https://www.deepseek.com/download/)), then pick any one of the following. ### Option 1 · Install inside the DSH desktop app (recommended) 1. In the sidebar click **插件** (Plugins) → **添加插件** (Add plugin); 2. In the input box enter any one of these specs: - this repository's address: `https://github.com/linanbuan/dsh-session-retry` - this repository's zip link (`Source code (zip)` on the GitHub page) - the **absolute path of a directory** already cloned on this machine - a package name (if this is ever published to npm) 3. Click **安装** (Install): Harness first reads out what the spec points at (package name, description, version), and starts installing once you confirm; 4. When it is done, click **立即启用** (Enable now). The whole install is done by Harness's own plugin manager: it writes the package into the current profile's `dsh.profile.bundles` and mounts the host half. **Installing a plugin in the desktop app needs no command line and no hand-edited profile file.** ### Option 2 · Let an Agent install it Hand the repository address or the local directory to the Agent in any session, and it calls the `plugin_manager` tool (`action: install_bundle`), which takes the same path. This is the official recommended way to install a third-party plugin. ### Option 3 · Command line On a machine with the dsh CLI installed: ```bash dsh plugin --profile add ``` `` is the same as in option 1 (a package name, a Git address, a zip link, or a local path); for the exact flags, trust the actual output of `dsh plugin --help`. ### Option 4 · Install into a profile by hand (for troubleshooting, not recommended) The profile directory is `~/.dsh/profiles/` by default. Put the package in there and register it in two places in the profile's own `package.json`: ```json "dependencies": { "@local/dsh-session-retry": "link:" }, "dsh": { "profile": { "bundles": [ "…", "@local/dsh-session-retry" ] } } ``` Editing a profile file by hand goes through a permission approval, and breaking it stops Harness from starting, so install normally through one of the first three options. **One pitfall**: in WSL, a link created with `ln -s` is a Linux symlink, and the Electron running on the Windows side cannot resolve it; the plugins page then reports `dsh: cannot resolve profile bundle "@local/dsh-session-retry"`. If you really want a link, let the Windows-side pnpm create it (option 1/2); otherwise use a directory copy. ## After installing Activating, and **how to make a code change take effect**, are two different things: 1. Sidebar **插件** (Plugins) → find `@local/dsh-session-retry`; 2. Click the toggle on that row to **disable and re-enable** (one hot mount); 3. The component status goes from 已关闭 (off) to 运行中 (running) = the host half is mounted in the boot graph; 4. The browser half re-fetches `client.js` along with the graph frame on `/plugins/events`, **no page refresh needed**. **But a change to the host half's code requires restarting the app.** cordis's plugin loader loads host modules with a bare `import()`, Node's ESM cache hits by file URL, and disable-and-re-enable only re-runs `apply()` of the **old cached module** — newly registered routes never appear. The symptom: the button is the new version, but clicking it says "the host half is still the old version (404), restart the app and try again" (the client translates that 404 into one sentence you can act on, on purpose). So: - changed only [client.js](client.js): a hot mount is enough, no page refresh; - changed [index.js](index.js) or [package.json](package.json): **restart the app**. ### Disable / uninstall ```yaml - id: session-retry disabled: true ``` Or just click the toggle on that row in the plugins page; to remove it for good, use plugins page → this package → **卸载** (Uninstall). ## Notes 1. **This plugin is not official**: it is not affiliated with DeepSeek AI in any way and comes with no service commitment. It depends on DSH's internal surface folding and step-event contract, and **those are not stable APIs**: after a Harness upgrade the button may grey out or the rewrite may fail, and when that happens the plugin only relays the reason and submits nothing — it does not damage the session log. 2. **It rewrites the visible surface of the current session**: that is the plugin's entire purpose, but it does append events to the session (`system/message` + `surfaceOp`). The original log and the UI history are untouched, and you can always use **branch** to open another session and keep them as they are. **Do not use it in a one-off session you need to keep verbatim.** 3. **Versions before 0.3.0 wrote corrupt logs**: early versions appended the step event on the spot while the session was idle, which the V4 validator rejects (`system/message does not match an open turn and step`), and the affected session logs need separate repair; from 1.0.0 the write became "register a shadow plan + make it up inside an open step", and the problem is gone. If you are on an old version, upgrade. 4. **A running session cannot be answered again**: it answers `RUNNING`, and the notice says "stop first, then answer again". 5. **Non-text content is not resubmitted**: images and attachments are only counted, not resubmitted, and the notice states the count. 6. **No automatic updates after install**: the official plugin manager does not support automatic updates yet, so upgrading = uninstall first, then install the new version; after replacing an installed package the host half needs an app restart. 7. **Compatibility**: developed and verified on DSH 0.1.7-rc.2 (Windows desktop). The host half injects `connection` and `agents` (the latter for the `agent/request` hook), and degrades to `UNAVAILABLE` instead of crashing when `sessionQuery` or `sessions` is missing. 8. **Privacy**: the plugin makes no network calls and has no telemetry. On the host side it reads the session log and the in-memory session object; the browser half only receives the anchor table for the turn at hand (message id, turn, prompt text). 9. **Permissions**: the plugin itself requests no extra permissions; at install time Harness's plugin manager runs its own approval flow. 10. **This repository's privacy**: it holds no keys, credentials, account identifiers, session logs or machine paths (every path in the docs is either a `~/.dsh/...` form or a ``). ## Tests ```bash node test/retry-targets.mjs # unit tests for targeting and the shadow range (synthetic events, zero dependencies) node test/client-contract.mjs # contract test for the browser half in a stand-in environment (zero dependencies) ``` `retry-targets` covers anchor targeting and the shadow range: one turn per session, multiple turns taking the highest turn, one turn with several prompts merged and counted, no anchor when the prompt holds only non-text content, no anchor when the turn has no step that produced text, injected context and plugin notifications not counting as prompts, a prompt after the anchor not counting as this turn, empty logs / malformed events / a missing `turn/end` degrading safely, and the shadow range's boundaries and non-contiguous cutting. `client-contract` builds a minimal stand-in for the host client's loading contract (`window.__ModuleLoader__` + react + the slots/locale/sessions services) and drives the real module through the whole path "register → mount → fetch context → click to submit → notice". The stand-in **deliberately provides no Harness client package** — the moment `client.js` tries to `require` one again, it fails right there. What it verifies is the module's own logic and its registration shape; "where the button sits and how it looks in the real UI" is outside what it can prove. Real-machine evidence (an end-to-end run using the app's own `Session` implementation and real session logs, including "the log produced by the deferred write reads back through the V4 validator") is not in this repository; it is local verification material. ## Layout ``` package.json bundle manifest (dsh.bundle.patch + dsh.client) cordis.patch.yml one insert row: mounts the host half index.js host half: the two routes + retryTargets/shadowPlan client.js browser half: the action-strip button (hand-written lazy CJS, react only) test/retry-targets.mjs targeting and shadow range unit tests test/client-contract.mjs browser half contract test icon.svg plugin icon ``` ## Credits and third-party licenses - **The tooltip bubble and the refresh icon** copy the look of DSH's official `@deepseek-ai/dsh-client-ui-primitives` (13px text, dark background, rounded corners, the 6px placement below, and the geometry of `IconRefreshOutlineRegular`), **taking only the look and the geometry, without loading that package and without copying its code**: the official package is MIT-licensed, and this note credits the source as MIT requires. - The rules aligned with the UI's targeting (the action strip hangs on "the last assistant message of the turn's last step that produced text") come from the implementation stance of DSH's official `ui-chat`; this is alignment for interface compatibility and contains no copy of official code. - The surface replacement channel (the `replace` semantics of `surfaceOp`, an empty system node producing no wire message) is a contract DSH's official `dsh-session` ships with; this plugin only writes to that contract and contains none of its code. - This repository **does not** reference or adapt code from any third-party plugin. ## License [MIT](LICENSE) © 2026 linanbuan