dsh-acp-replay 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,27 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DeepSeek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ This repository vendors @deepseek-ai/dsh-acp (tag dsh-v0.1.5-rc.1) and adds the
26
+ session/load capability, the loadSession handler, and src/replay.ts. Those
27
+ additions are released under the same MIT terms.
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # dsh-acp-replay
2
+
3
+ A community ACP bridge for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) that answers `session/load`, so a client can rebuild a session transcript after its own restart.
4
+
5
+ **Status: prototype.** It works end to end on `dsh 0.1.5-rc.1`, but it is a vendored fork of `@deepseek-ai/dsh-acp` and needs a rebase whenever that package changes.
6
+
7
+ ## Why this exists
8
+
9
+ `dsh-acp` is an automation-only bridge: its `initialize` advertises `sessionCapabilities: { close, list, resume }` and no `loadSession`, and `session/resume` restores a session *without replaying its updates*. ACP defines `session/load` as the call where the agent "streams the entire conversation history back to the client via notifications", and clients that treat the agent as the durable transcript authority — Paseo, for one — call exactly that method to rebuild a timeline after restarting.
10
+
11
+ Without it, that model of client shows an empty conversation next to an agent that still remembers everything, because the harness keeps the transcript and the client has no way to read it. This bridge closes that gap; the discussion tracking the missing upstream feature is [deepseek-ai/deepseek-harness#6324](https://github.com/deepseek-ai/deepseek-harness/discussions/6324).
12
+
13
+ ## What it adds
14
+
15
+ Two changes over `@deepseek-ai/dsh-acp@0.1.5-rc.1`:
16
+
17
+ - `initialize` advertises `loadSession: true`.
18
+ - `session/load` is implemented: it opens the stored session with a read-only persistence handle (`ctx.sessionPersistence.open(id, 'read')`), resumes the session exactly as `session/resume` does, then replays the snapshot through the existing update mapping in `src/updates.ts` before returning.
19
+
20
+ `src/replay.ts` maps stored events to protocol updates:
21
+
22
+ | Stored event | ACP update |
23
+ | --- | --- |
24
+ | `user/message` with `source.kind === 'user'` | `user_message_chunk` |
25
+ | `assistant/message` | `agent_thought_chunk`, `agent_message_chunk`, `usage_update` |
26
+ | `tool/call` | `tool_call` |
27
+ | `tool/result` | `tool_call_update` |
28
+
29
+ Injected context (the runtime snapshot, skill lists, file-change notices) shares the `user/message` event type but is not something a user said, so it stays off the wire.
30
+
31
+ ## Fail-fast guard
32
+
33
+ The bridge needs the persistence handle API (`ctx.sessionPersistence.open(id, 'read')`, then `read(0)`) that `0.1.5-rc.1` introduced; `0.1.2-rc.1` read logs with `load`/`inspect` on the service instead. When the mounted harness does not offer what the bridge calls, `src/compat.ts` refuses to mount and prints the missing requirement, the alternative bridge, and this repository:
34
+
35
+ ```
36
+ dsh-acp-replay cannot serve session/load in this harness (harness 0.1.6):
37
+ - needs ctx.sessionPersistence.open(), but the mounted sessionPersistence service exposes inspect, list, load, stat
38
+ This plugin vendors @deepseek-ai/dsh-acp@0.1.5-rc.1 and needs the persistence handle API it introduced.
39
+ Use the shipped bridge instead ("dsh --profile acp"), or update this plugin for the installed harness.
40
+ https://github.com/zheng1/dsh-acp-replay
41
+ ```
42
+
43
+ Run `node test/selfcheck.mjs` after a harness upgrade to get that answer without a full client.
44
+
45
+ ## Use it
46
+
47
+ The bridge replaces the shipped `acp` row in a profile. Two ways:
48
+
49
+ **Install into an existing profile.** Add the package to the profile and append to its `cordis.patch.yml`:
50
+
51
+ Published on npm as [`dsh-acp-replay`](https://www.npmjs.com/package/dsh-acp-replay):
52
+
53
+ ```bash
54
+ dsh plugin --profile acp-replay add dsh-acp-replay # installs it into that profile
55
+ ```
56
+
57
+ The same package also works installed globally (`npm install -g dsh-acp-replay`) or copied from a checkout (put `lib/` and `package.json` in `$DSH_HOME/profiles/node_modules/dsh-acp-replay/`).
58
+
59
+ ```yaml
60
+ - id: acp
61
+ disabled: true
62
+
63
+ - insert:
64
+ - id: acp-replay
65
+ name: 'dsh-acp-replay'
66
+ inject: [acpAppStartup]
67
+ config:
68
+ provider: deepseek-official
69
+ model: deepseek-flash
70
+ ```
71
+
72
+ Then point the client at `dsh --profile acp-replay` instead of `dsh --profile acp`. In Paseo that is the provider's `command`:
73
+
74
+ ```json
75
+ {
76
+ "agents": {
77
+ "providers": {
78
+ "dsh": {
79
+ "extends": "acp",
80
+ "label": "DeepSeek Harness",
81
+ "command": ["dsh", "--profile", "acp-replay"]
82
+ }
83
+ }
84
+ }
85
+ }
86
+ ```
87
+
88
+ **Or use it as a bundle.** The package declares `dsh.bundle.patch`, so adding `dsh-acp-replay` to a profile's `dsh.profile.bundles` composes the same rows.
89
+
90
+ ## Build
91
+
92
+ ```bash
93
+ npm install --legacy-peer-deps # the 0.1.5 peer graph has an rc.1/rc.2 skew
94
+ npm run build # tsc -> lib/
95
+ npm test # guard behaviour (node:test)
96
+ ```
97
+
98
+ ## Verified
99
+
100
+ Against `dsh 0.1.5-rc.1`, a scratch `DSH_HOME`, and a real API key:
101
+
102
+ - `initialize` reports `loadSession: true`.
103
+ - A session with two prompts runs normally through the bridge (`session/new`, `session/prompt`, `session/close`).
104
+ - A **new process** calling `session/load` for that session id receives `user_message_chunk ×2` and `agent_message_chunk ×2` carrying the original prompts and answers, plus `usage_update ×2`.
105
+
106
+ `test/replay-check.mjs` runs both phases (`record` then `load <session-id>`); `test/selfcheck.mjs` only performs `initialize` and reports whether `loadSession` is advertised.
107
+
108
+ **End to end through a client** (Paseo 0.7.2 daemon, `dsh 0.1.5-rc.1`, two DSH providers on one daemon so only the bridge differs). Each agent ran one prompt, then the daemon was restarted — the event that used to erase the visible conversation:
109
+
110
+ | Provider | Before restart | After restart |
111
+ | --- | --- | --- |
112
+ | `dsh-replay` (this bridge, advertises `loadSession`) | user prompt, assistant answer, reasoning row | **all three rows restored** |
113
+ | `dsh` (shipped bridge, no `loadSession`) | user prompt, assistant answer | `No activity to display.` |
114
+
115
+ Paseo's ACP adapter takes the `loadSession` branch only when the agent advertises the capability, primes its timeline from `streamHistory()`, and needed no change for this to work.
116
+
117
+ ## Not verified / known limits
118
+
119
+ - Tool-call replay is mapped but not exercised by the test above.
120
+ - An older host (`dsh 0.1.2-rc.1`) exposes a different persistence API (`load`/`inspect` instead of `open`/`read`), so this fork targets `0.1.5-rc.1` and newer only.
121
+ - Replayed history is delivered before `session/load` returns, one notification at a time; a very long session makes that call proportionally long.
122
+ - The profile's own help text still reads `--profile acp` (it comes from the bundled `dsh-acp-app` command provider, not from this package).
123
+
124
+ ## License
125
+
126
+ MIT, matching the upstream package it vendors. `src/*.ts` except `src/replay.ts` are copied from [`@deepseek-ai/dsh-acp`](https://github.com/deepseek-ai/deepseek-harness/tree/master/packages/acp/acp) at tag `dsh-v0.1.5-rc.1`; the additions are the `loadSession` capability, the `loadSession` handler in `src/index.ts`, and `src/replay.ts`.
@@ -0,0 +1,10 @@
1
+ # Replace the shipped automation bridge with this one, which also answers
2
+ # `session/load` so a client can rebuild a transcript after its own restart.
3
+
4
+ - id: acp
5
+ disabled: true
6
+
7
+ - insert:
8
+ - id: acp-replay
9
+ name: 'dsh-acp-replay'
10
+ inject: [acpAppStartup]
package/lib/codec.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Pure translation between the harness lifecycle and the automation-only ACP wire.
3
+ * @module @deepseek-ai/dsh-acp/codec
4
+ */
5
+ /**
6
+ * Map a harness turn ending to ACP's terminal reason vocabulary.
7
+ * @param reason - harness turn outcome.
8
+ * @returns the closest legal ACP stop reason.
9
+ */
10
+ export function turnEndToStopReason(reason) {
11
+ switch (reason.kind) {
12
+ case 'completed':
13
+ return 'end_turn';
14
+ case 'max-tokens':
15
+ return 'max_tokens';
16
+ // `cancelled` is reserved for explicit client cancellation (`session/cancel`)
17
+ // and disposal, both settled out of band; a turn aborted by a hook or
18
+ // another owner is ordinary quiescence and reports `end_turn`.
19
+ case 'aborted':
20
+ return 'end_turn';
21
+ case 'interrupted':
22
+ return 'cancelled';
23
+ case 'blocked':
24
+ case 'error':
25
+ return 'end_turn';
26
+ /* v8 ignore next 2 -- TurnEndReason is closed and every member is handled above */
27
+ default:
28
+ return 'end_turn';
29
+ }
30
+ }
package/lib/compat.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Fail loudly when the harness this profile booted into cannot serve
3
+ * `session/load`.
4
+ *
5
+ * This bridge vendors `@deepseek-ai/dsh-acp@0.1.5-rc.1` and reads a persisted
6
+ * log through the persistence handle API that release introduced. A harness
7
+ * upgrade can move that API again (`0.1.2-rc.1` exposed `load`/`inspect`
8
+ * directly on the service), and a silent mismatch would surface much later as
9
+ * "the provider will not start" or as an empty transcript. Checking once at
10
+ * mount turns that into one sentence naming the fix.
11
+ *
12
+ * @module dsh-acp-replay/compat
13
+ */
14
+ /** Persistence methods this bridge calls on the injected service. */
15
+ const REQUIRED_PERSISTENCE_METHODS = ['open', 'stat'];
16
+ const HOMEPAGE = 'https://github.com/zheng1/dsh-acp-replay';
17
+ function hasFunction(value, key) {
18
+ return typeof value?.[key] === 'function';
19
+ }
20
+ function describeService(value) {
21
+ if (value === null || value === undefined)
22
+ return 'not mounted';
23
+ const keys = Object.keys(value).filter((key) => hasFunction(value, key));
24
+ return keys.length > 0 ? keys.sort().join(', ') : 'no callable methods';
25
+ }
26
+ /**
27
+ * Report every requirement this bridge does not find in its host.
28
+ * @param support - the injected persistence service and ACP method constant.
29
+ * @returns one entry per unmet requirement; empty when the host is usable.
30
+ */
31
+ export function replayHostProblems(support) {
32
+ const problems = [];
33
+ for (const method of REQUIRED_PERSISTENCE_METHODS) {
34
+ if (!hasFunction(support.persistence, method)) {
35
+ problems.push({
36
+ requirement: `ctx.sessionPersistence.${method}()`,
37
+ detail: `the mounted sessionPersistence service exposes ${describeService(support.persistence)}`,
38
+ });
39
+ }
40
+ }
41
+ if (support.sessionLoadMethod === undefined || support.sessionLoadMethod === null) {
42
+ problems.push({
43
+ requirement: 'the ACP SDK session/load method constant',
44
+ detail: 'this @agentclientprotocol/sdk version does not expose methods.agent.session.load',
45
+ });
46
+ }
47
+ return problems;
48
+ }
49
+ /**
50
+ * Refuse to mount when the host cannot serve a replayed transcript.
51
+ * @param support - the injected persistence service and ACP method constant.
52
+ * @throws when any requirement is unmet; the message names the requirement, the
53
+ * alternative bridge, and where to update this plugin.
54
+ */
55
+ export function assertReplayHostSupport(support) {
56
+ const problems = replayHostProblems(support);
57
+ if (problems.length === 0)
58
+ return;
59
+ const harness = support.harnessVersion === undefined ? '' : ` (harness ${support.harnessVersion})`;
60
+ const lines = [
61
+ `dsh-acp-replay cannot serve session/load in this harness${harness}:`,
62
+ ...problems.map((problem) => ` - needs ${problem.requirement}, but ${problem.detail}`),
63
+ `This plugin vendors @deepseek-ai/dsh-acp@0.1.5-rc.1 and needs the persistence handle API it introduced.`,
64
+ `Use the shipped bridge instead ("dsh --profile acp"), or update this plugin for the installed harness.`,
65
+ HOMEPAGE,
66
+ ];
67
+ const error = new Error(lines.join('\n'));
68
+ error.name = 'ReplayHostUnsupportedError';
69
+ throw error;
70
+ }
package/lib/content.js ADDED
@@ -0,0 +1,212 @@
1
+ /** ACP wire-content admission and projection owned by the ACP adapter. @module */
2
+ import { isImageAdmissionError } from '@deepseek-ai/dsh-attachment';
3
+ /** Raster formats shared by ACP image blocks and the core attachment vocabulary. */
4
+ const IMAGE_MEDIA_TYPES = [
5
+ 'image/png',
6
+ 'image/jpeg',
7
+ 'image/webp',
8
+ 'image/gif',
9
+ ];
10
+ /** Canonical RFC 4648 base64, excluding whitespace and URL-safe aliases. */
11
+ const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
12
+ /** Error with a stable ACP request-failure category and no raw binary payload. */
13
+ export class AcpContentError extends Error {
14
+ /** Whether the bridge should report invalid params or an internal failure. */
15
+ kind;
16
+ /**
17
+ * @param message - safe protocol-facing detail without inline binary data.
18
+ * @param kind - request-failure category.
19
+ * @param options - optional causal chain for diagnostics.
20
+ */
21
+ constructor(message, kind, options) {
22
+ super(message, options);
23
+ this.name = 'AcpContentError';
24
+ this.kind = kind;
25
+ }
26
+ }
27
+ /** Narrow a wire MIME string to the durable raster vocabulary. */
28
+ function imageMediaType(value) {
29
+ return IMAGE_MEDIA_TYPES.includes(value) ? value : undefined;
30
+ }
31
+ /** Strictly decode one ACP inline image without accepting base64 aliases. */
32
+ function decodeImage(block) {
33
+ const mediaType = imageMediaType(block.mimeType);
34
+ if (mediaType === undefined) {
35
+ throw new AcpContentError('image mimeType must be image/png, image/jpeg, image/webp, or image/gif', 'invalid');
36
+ }
37
+ if (!CANONICAL_BASE64.test(block.data)) {
38
+ throw new AcpContentError('image data must be canonical base64', 'invalid');
39
+ }
40
+ const data = Buffer.from(block.data, 'base64');
41
+ if (data.toString('base64') !== block.data) {
42
+ throw new AcpContentError('image data must be canonical base64', 'invalid');
43
+ }
44
+ return { data, mediaType };
45
+ }
46
+ /** Resolve the exact current route and require explicit image input support. */
47
+ async function assertImageRoute(ctx, route, signal) {
48
+ const provider = route?.provider;
49
+ const model = route?.model;
50
+ const llm = ctx.get('llm');
51
+ if (provider === undefined || model === undefined || llm === undefined) {
52
+ throw new AcpContentError('the current model route could not be resolved for image input', 'invalid');
53
+ }
54
+ let info;
55
+ try {
56
+ info = await llm.resolveModelInfo(provider, model, signal);
57
+ }
58
+ catch (error) {
59
+ throw new AcpContentError('the current model route could not be verified for image input', 'internal', { cause: error });
60
+ }
61
+ if (info.inputModalities === undefined || !info.inputModalities.includes('image')) {
62
+ throw new AcpContentError(`model "${model}" does not declare image input`, 'invalid');
63
+ }
64
+ }
65
+ /**
66
+ * Determine whether initialization may truthfully advertise inline image prompts.
67
+ * Unknown service, route, capability, or deployment media support is negative.
68
+ * @param ctx - bridge context carrying optional attachment and model services.
69
+ * @param provider - configured provider route used for newly created sessions.
70
+ * @param model - configured exact model id used for newly created sessions.
71
+ * @returns whether this bridge can admit images at initialization time.
72
+ */
73
+ export async function supportsAcpImagePrompts(ctx, provider, model) {
74
+ const attachments = ctx.get('attachments');
75
+ const llm = ctx.get('llm');
76
+ if (attachments === undefined || llm === undefined || provider === undefined || model === undefined)
77
+ return false;
78
+ if (!attachments.imageLimits.mediaTypes.some(mediaType => IMAGE_MEDIA_TYPES.includes(mediaType)))
79
+ return false;
80
+ try {
81
+ const info = await llm.resolveModelInfo(provider, model);
82
+ return info.inputModalities?.includes('image') === true;
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
88
+ /** Render one baseline resource link into the core's current text vocabulary. */
89
+ function resourceLinkText(block) {
90
+ return `\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`;
91
+ }
92
+ /**
93
+ * Admit one ACP prompt into ordered durable core content.
94
+ * Every wire block and image is validated before the ordered image batch starts
95
+ * writing; cancellation after a successful content-addressed write may leave an
96
+ * unreachable object but never queues a late user message.
97
+ * @param ctx - bridge context carrying attachment and model services.
98
+ * @param route - selection pinned to the accepted prompt.
99
+ * @param prompt - untrusted ACP prompt blocks in wire order.
100
+ * @param imageEnabled - capability result advertised during initialization.
101
+ * @param signal - admission cancellation signal.
102
+ * @returns core content with durable image references in wire order.
103
+ */
104
+ export async function admitAcpPrompt(ctx, route, prompt, imageEnabled, signal) {
105
+ const images = [];
106
+ for (const block of prompt) {
107
+ switch (block.type) {
108
+ case 'text':
109
+ case 'resource_link':
110
+ break;
111
+ case 'image':
112
+ if (!imageEnabled)
113
+ throw new AcpContentError('inline image prompts were not advertised by this connection', 'invalid');
114
+ images.push(decodeImage(block));
115
+ break;
116
+ case 'audio':
117
+ throw new AcpContentError('audio prompt content is not supported', 'invalid');
118
+ case 'resource':
119
+ throw new AcpContentError('embedded resource prompt content is not supported', 'invalid');
120
+ /* v8 ignore next 2 -- ACP ContentBlock is a closed generated union. */
121
+ default:
122
+ throw new AcpContentError('unsupported ACP prompt content', 'invalid');
123
+ }
124
+ }
125
+ let refs = [];
126
+ if (images.length > 0) {
127
+ const attachments = ctx.get('attachments');
128
+ if (attachments === undefined)
129
+ throw new AcpContentError('no attachment store is mounted', 'invalid');
130
+ await assertImageRoute(ctx, route, signal);
131
+ signal.throwIfAborted();
132
+ try {
133
+ refs = await attachments.saveImages(images);
134
+ }
135
+ catch (error) {
136
+ if (isImageAdmissionError(error)) {
137
+ throw new AcpContentError(error.message, 'invalid', { cause: error });
138
+ }
139
+ throw new AcpContentError('unable to persist the prompt image batch', 'internal', { cause: error });
140
+ }
141
+ signal.throwIfAborted();
142
+ }
143
+ const content = [];
144
+ let pendingText = '';
145
+ let imageIndex = 0;
146
+ const flushText = () => {
147
+ if (pendingText.length === 0)
148
+ return;
149
+ content.push({ type: 'text', text: pendingText });
150
+ pendingText = '';
151
+ };
152
+ for (const block of prompt) {
153
+ switch (block.type) {
154
+ case 'text':
155
+ pendingText += block.text;
156
+ break;
157
+ case 'resource_link':
158
+ pendingText += resourceLinkText(block);
159
+ break;
160
+ case 'image': {
161
+ flushText();
162
+ const ref = refs[imageIndex++];
163
+ content.push({ type: 'image', attachment: ref });
164
+ break;
165
+ }
166
+ /* v8 ignore start -- the validation pass above rejects both tags before reconstruction. */
167
+ case 'audio':
168
+ case 'resource':
169
+ break;
170
+ /* v8 ignore stop */
171
+ /* v8 ignore next 2 -- validated by the first closed-union switch. */
172
+ default:
173
+ break;
174
+ }
175
+ }
176
+ flushText();
177
+ if (!content.some(block => block.type === 'image' || (block.type === 'text' && block.text.trim().length > 0))) {
178
+ throw new AcpContentError('empty prompt', 'invalid');
179
+ }
180
+ return content;
181
+ }
182
+ /**
183
+ * Translate one committed assistant block to ACP wire content.
184
+ * Images are re-read and integrity-verified before inline base64 delivery;
185
+ * unsupported core output blocks stay off the automation wire.
186
+ * @param ctx - bridge context carrying the authoritative attachment store.
187
+ * @param block - committed core assistant block.
188
+ * @returns ACP text/image content, or undefined for non-output blocks.
189
+ */
190
+ export async function assistantBlockToAcp(ctx, block) {
191
+ if (block.type === 'text') {
192
+ return block.text.length === 0 ? undefined : { type: 'text', text: block.text };
193
+ }
194
+ if (block.type !== 'image')
195
+ return undefined;
196
+ const attachments = ctx.get('attachments');
197
+ if (attachments === undefined) {
198
+ throw new AcpContentError('cannot deliver assistant image: no attachment store is mounted', 'internal');
199
+ }
200
+ let stored;
201
+ try {
202
+ stored = await attachments.readImage(block.attachment);
203
+ }
204
+ catch (error) {
205
+ throw new AcpContentError('cannot deliver assistant image: the attachment is unavailable or corrupt', 'internal', { cause: error });
206
+ }
207
+ return {
208
+ type: 'image',
209
+ data: Buffer.from(stored.data).toString('base64'),
210
+ mimeType: stored.ref.mediaType,
211
+ };
212
+ }