@1agh/maude 0.39.0 → 0.39.1
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/apps/studio/acp/bridge.ts +144 -5
- package/apps/studio/acp/index.ts +11 -1
- package/apps/studio/test/acp-bridge.test.ts +132 -0
- package/apps/studio/test/acp-commands.test.ts +8 -3
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +24 -1
- package/apps/studio/whats-new.json +9 -0
- package/package.json +8 -8
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// (env.ts): the child inherits the environment MINUS `ANTHROPIC_API_KEY`, so
|
|
9
9
|
// auth precedence falls through to the user's Pro/Max subscription.
|
|
10
10
|
|
|
11
|
-
import { appendFile, mkdir } from 'node:fs/promises';
|
|
11
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
12
12
|
import { dirname } from 'node:path';
|
|
13
13
|
|
|
14
14
|
import {
|
|
@@ -71,6 +71,29 @@ const EFFORT_THINKING_TOKENS: Record<string, number | null> = {
|
|
|
71
71
|
|
|
72
72
|
export type AcpEffort = keyof typeof EFFORT_THINKING_TOKENS;
|
|
73
73
|
|
|
74
|
+
// Real sessionIds are adapter-generated `randomUUID()`s. A persisted value that
|
|
75
|
+
// doesn't look like one (corrupt sidecar, or a tracked file a cloned repo
|
|
76
|
+
// shipped despite `_chat/` being gitignored — DDR-115) is rejected rather than
|
|
77
|
+
// forwarded into the privileged `loadSession` ACP call.
|
|
78
|
+
const VALID_SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
79
|
+
|
|
80
|
+
// `loadSession`'s replay can, in principle, never settle if the underlying
|
|
81
|
+
// transport dies mid-call (adapter crash, a concurrent `stop()` from another
|
|
82
|
+
// chat sharing this bridge). Bound it so `replaying` always resets and a
|
|
83
|
+
// resume attempt always falls back to `newSession` instead of wedging the
|
|
84
|
+
// bridge silent forever. Mirrors the `withTimeout`/`TIMED_OUT` pattern already
|
|
85
|
+
// used for network calls in `apps/studio/git/service.ts`.
|
|
86
|
+
const LOAD_SESSION_TIMEOUT_MS = 15_000;
|
|
87
|
+
const TIMED_OUT = Symbol('maude-acp-load-session-timeout');
|
|
88
|
+
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT> {
|
|
89
|
+
p.catch(() => {});
|
|
90
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
91
|
+
const t = new Promise<typeof TIMED_OUT>((res) => {
|
|
92
|
+
timer = setTimeout(() => res(TIMED_OUT), ms);
|
|
93
|
+
});
|
|
94
|
+
return Promise.race([p, t]).finally(() => clearTimeout(timer));
|
|
95
|
+
}
|
|
96
|
+
|
|
74
97
|
/**
|
|
75
98
|
* Build the `session/new` params, carrying TWO adapter-internal `_meta` payloads
|
|
76
99
|
* (both spread by the installed `claude-agent-acp@0.49.x` `newSession`):
|
|
@@ -90,6 +113,10 @@ export type AcpEffort = keyof typeof EFFORT_THINKING_TOKENS;
|
|
|
90
113
|
* must fail the presence tests LOUDLY (acp-bootstrap-brief.test.ts +
|
|
91
114
|
* acp-session-plugins.test.ts), not silently un-brief / un-plugin every session.
|
|
92
115
|
* Exported for those tests.
|
|
116
|
+
*
|
|
117
|
+
* `cwd`/`mcpServers`/`_meta` are also exactly the shared fields of a
|
|
118
|
+
* `LoadSessionRequest` (schema/types.gen.d.ts) — `sessionFor`'s resume path
|
|
119
|
+
* spreads this same object and adds `sessionId` rather than duplicating it.
|
|
93
120
|
*/
|
|
94
121
|
export function newSessionParams(
|
|
95
122
|
repoRoot: string,
|
|
@@ -136,12 +163,21 @@ export class AcpBridge {
|
|
|
136
163
|
// context. The adapter (one subprocess) holds them all; switching chats reuses
|
|
137
164
|
// the session, so claude remembers that chat while the app is open.
|
|
138
165
|
private sessions = new Map<string, string>(); // chatId → sessionId
|
|
166
|
+
// In-flight sessionFor() calls, keyed by chatId — lets a `warm` and a `prompt`
|
|
167
|
+
// racing for the same chat share one resume/create attempt instead of each
|
|
168
|
+
// running establishSession() and stomping the single shared `replaying` flag.
|
|
169
|
+
private sessionPromises = new Map<string, Promise<string>>();
|
|
139
170
|
private currentSession: string | null = null; // the in-flight prompt's session
|
|
140
171
|
/** Sessions whose bootstrap brief already hit the transcript (audit record). */
|
|
141
172
|
private briefLogged = new Set<string>();
|
|
142
173
|
private starting: Promise<void> | null = null;
|
|
143
174
|
/** Per-chat transcript file (`_chat/<id>.jsonl`); set per prompt. */
|
|
144
175
|
private transcriptPath: string | null = null;
|
|
176
|
+
/** Sidecar persisting this chat's ACP sessionId across restarts (`_chat/<id>.session.json`). */
|
|
177
|
+
private sessionStorePath: string | null = null;
|
|
178
|
+
/** True while `conn.loadSession()` is replaying a resumed session's history
|
|
179
|
+
* back through the `sessionUpdate` client callback — see the guard in `start()`. */
|
|
180
|
+
private replaying = false;
|
|
145
181
|
// Model + effort are env-at-spawn (ANTHROPIC_MODEL / MAX_THINKING_TOKENS), so a
|
|
146
182
|
// change re-spawns the adapter. `desired*` is what the UI asked for; `active*`
|
|
147
183
|
// is what the running session was spawned with.
|
|
@@ -165,6 +201,10 @@ export class AcpBridge {
|
|
|
165
201
|
this.transcriptPath = path;
|
|
166
202
|
}
|
|
167
203
|
|
|
204
|
+
setSessionStorePath(path: string | null): void {
|
|
205
|
+
this.sessionStorePath = path;
|
|
206
|
+
}
|
|
207
|
+
|
|
168
208
|
/** Desired model (alias/id, or null for the user's default) + effort. Applied
|
|
169
209
|
* on the next prompt — re-spawning the adapter only if it actually changed. */
|
|
170
210
|
setConfig(model: string | null, effort: AcpEffort): void {
|
|
@@ -187,18 +227,104 @@ export class AcpBridge {
|
|
|
187
227
|
return this.starting;
|
|
188
228
|
}
|
|
189
229
|
|
|
190
|
-
/**
|
|
230
|
+
/**
|
|
231
|
+
* Get-or-create the ACP session for a chat id (one claude context per chat).
|
|
232
|
+
* `warm` and `prompt` can both reach this for the same chat close together
|
|
233
|
+
* (composer autocomplete warm-up racing the user hitting send) — a second
|
|
234
|
+
* concurrent call for the same chatId shares the FIRST call's in-flight
|
|
235
|
+
* promise (mirrors `ensureStarted`'s `this.starting` pattern) rather than
|
|
236
|
+
* re-entering resume/create and stomping the single shared `replaying` flag.
|
|
237
|
+
*/
|
|
191
238
|
private async sessionFor(chatId: string): Promise<string> {
|
|
192
239
|
const existing = this.sessions.get(chatId);
|
|
193
240
|
if (existing) return existing;
|
|
241
|
+
const inFlight = this.sessionPromises.get(chatId);
|
|
242
|
+
if (inFlight) return inFlight;
|
|
243
|
+
|
|
244
|
+
const promise = this.establishSession(chatId).finally(() => {
|
|
245
|
+
this.sessionPromises.delete(chatId);
|
|
246
|
+
});
|
|
247
|
+
this.sessionPromises.set(chatId, promise);
|
|
248
|
+
return promise;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Resumes a session persisted from a PRIOR app/dev-server lifetime (the
|
|
253
|
+
* cross-restart memory gap tracked in DDR-125) via the adapter's `loadSession`
|
|
254
|
+
* before falling back to a brand-new `newSession` — either because this chat
|
|
255
|
+
* has never had a session, or because the resume attempt failed (e.g. the
|
|
256
|
+
* underlying claude session was pruned, `claude` was reinstalled, or the
|
|
257
|
+
* adapter's response never arrives — `loadSession` is time-boxed so a dead
|
|
258
|
+
* transport can't wedge `replaying` true forever and silently black-hole
|
|
259
|
+
* every future turn on this bridge).
|
|
260
|
+
*/
|
|
261
|
+
private async establishSession(chatId: string): Promise<string> {
|
|
194
262
|
if (!this.conn) throw new Error('ACP adapter not started');
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
);
|
|
263
|
+
|
|
264
|
+
const params = newSessionParams(this.opts.repoRoot, this.opts.studioBrief, this.opts.plugins);
|
|
265
|
+
const persistedId = await this.readPersistedSessionId();
|
|
266
|
+
if (persistedId) {
|
|
267
|
+
try {
|
|
268
|
+
this.replaying = true;
|
|
269
|
+
const result = await withTimeout(
|
|
270
|
+
this.conn.loadSession({ ...params, sessionId: persistedId }),
|
|
271
|
+
LOAD_SESSION_TIMEOUT_MS
|
|
272
|
+
);
|
|
273
|
+
if (result === TIMED_OUT) {
|
|
274
|
+
throw new Error(`loadSession timed out after ${LOAD_SESSION_TIMEOUT_MS}ms`);
|
|
275
|
+
}
|
|
276
|
+
this.sessions.set(chatId, persistedId);
|
|
277
|
+
return persistedId;
|
|
278
|
+
} catch (err) {
|
|
279
|
+
await this.appendTranscript({
|
|
280
|
+
role: 'bootstrap',
|
|
281
|
+
kind: 'resume-failed',
|
|
282
|
+
error: err instanceof Error ? err.message : String(err),
|
|
283
|
+
});
|
|
284
|
+
} finally {
|
|
285
|
+
this.replaying = false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const created = await this.conn.newSession(params);
|
|
198
290
|
this.sessions.set(chatId, created.sessionId);
|
|
291
|
+
await this.writePersistedSessionId(created.sessionId);
|
|
199
292
|
return created.sessionId;
|
|
200
293
|
}
|
|
201
294
|
|
|
295
|
+
/** Read the sessionId persisted for this chat by a prior bridge lifetime.
|
|
296
|
+
* Null when there's no sidecar wired (e.g. warm-up before any prompt), the
|
|
297
|
+
* file doesn't exist yet (first-ever turn), it's unreadable/corrupt, or its
|
|
298
|
+
* `sessionId` doesn't look like a real one (defense-in-depth — this file's
|
|
299
|
+
* directory is per-machine/gitignored per DDR-115, but a cloned repo could
|
|
300
|
+
* still ship a tracked file there, so bound what we'll forward into the
|
|
301
|
+
* privileged `loadSession` ACP call rather than trusting its shape blindly). */
|
|
302
|
+
private async readPersistedSessionId(): Promise<string | null> {
|
|
303
|
+
if (!this.sessionStorePath) return null;
|
|
304
|
+
try {
|
|
305
|
+
const raw = await readFile(this.sessionStorePath, 'utf8');
|
|
306
|
+
const data = JSON.parse(raw) as { sessionId?: unknown };
|
|
307
|
+
const id = data.sessionId;
|
|
308
|
+
return typeof id === 'string' && VALID_SESSION_ID.test(id) ? id : null;
|
|
309
|
+
} catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Persist a freshly-created sessionId so the NEXT bridge lifetime (app
|
|
315
|
+
* restart, dev-server restart) can resume this chat instead of starting
|
|
316
|
+
* fresh. Best-effort, like `appendTranscript` — a failed write just means
|
|
317
|
+
* the next restart falls back to a new session. */
|
|
318
|
+
private async writePersistedSessionId(sessionId: string): Promise<void> {
|
|
319
|
+
if (!this.sessionStorePath) return;
|
|
320
|
+
try {
|
|
321
|
+
await mkdir(dirname(this.sessionStorePath), { recursive: true });
|
|
322
|
+
await writeFile(this.sessionStorePath, JSON.stringify({ sessionId, updatedAt: Date.now() }));
|
|
323
|
+
} catch {
|
|
324
|
+
/* best-effort — see doc comment above */
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
202
328
|
private async start(): Promise<void> {
|
|
203
329
|
const adapterEntry = resolveAdapterEntry();
|
|
204
330
|
if (!adapterEntry) {
|
|
@@ -279,6 +405,12 @@ export class AcpBridge {
|
|
|
279
405
|
this.opts.onCommands?.(params.update.availableCommands ?? []);
|
|
280
406
|
return;
|
|
281
407
|
}
|
|
408
|
+
// `loadSession` replays the resumed session's entire prior history back
|
|
409
|
+
// through this SAME callback (claude-agent-acp's replaySessionHistory) to
|
|
410
|
+
// prime its own in-adapter state. That history is already on disk in the
|
|
411
|
+
// transcript and already rendered client-side, so forwarding/re-appending
|
|
412
|
+
// it here would duplicate every message in the panel and the jsonl file.
|
|
413
|
+
if (this.replaying) return;
|
|
282
414
|
this.opts.onUpdate(params.update);
|
|
283
415
|
void this.appendTranscript({ role: 'agent', update: params.update });
|
|
284
416
|
},
|
|
@@ -388,6 +520,13 @@ export class AcpBridge {
|
|
|
388
520
|
this.proc = null;
|
|
389
521
|
this.conn = null;
|
|
390
522
|
this.sessions.clear();
|
|
523
|
+
// Drop any in-flight sessionFor() promises too — they were bound to the
|
|
524
|
+
// now-dead `conn`; a subsequent sessionFor() for the same chatId must
|
|
525
|
+
// establish fresh against the respawned connection, not await a stale
|
|
526
|
+
// reference (each entry's own .finally() would eventually clear it once its
|
|
527
|
+
// bounded loadSession timeout fires, but a call landing before then would
|
|
528
|
+
// otherwise get back a result tied to the connection we just tore down).
|
|
529
|
+
this.sessionPromises.clear();
|
|
391
530
|
this.briefLogged.clear();
|
|
392
531
|
this.currentSession = null;
|
|
393
532
|
}
|
package/apps/studio/acp/index.ts
CHANGED
|
@@ -185,8 +185,16 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
|
|
|
185
185
|
const safe = id.replace(/[^a-z0-9_-]/gi, '').slice(0, 64);
|
|
186
186
|
return safe || 'default';
|
|
187
187
|
}
|
|
188
|
+
function chatFilePathFor(chatId: string, suffix: string): string {
|
|
189
|
+
return join(ctx.paths.designRoot, '_chat', `${sanitizeChatId(chatId)}${suffix}`);
|
|
190
|
+
}
|
|
188
191
|
function transcriptPathFor(chatId: string): string {
|
|
189
|
-
return
|
|
192
|
+
return chatFilePathFor(chatId, '.jsonl');
|
|
193
|
+
}
|
|
194
|
+
// Sidecar persisting this chat's ACP sessionId across restarts (bridge.ts
|
|
195
|
+
// sessionFor's resume path) — the cross-restart memory gap tracked in DDR-125.
|
|
196
|
+
function sessionStorePathFor(chatId: string): string {
|
|
197
|
+
return chatFilePathFor(chatId, '.session.json');
|
|
190
198
|
}
|
|
191
199
|
|
|
192
200
|
async function handlePrompt(
|
|
@@ -198,6 +206,7 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
|
|
|
198
206
|
): Promise<void> {
|
|
199
207
|
const bridge = getOrCreateBridge(ws);
|
|
200
208
|
bridge.setTranscriptPath(transcriptPathFor(chatId));
|
|
209
|
+
bridge.setSessionStorePath(sessionStorePathFor(chatId));
|
|
201
210
|
bridge.setConfig(model, effort);
|
|
202
211
|
try {
|
|
203
212
|
await bridge.ensureStarted();
|
|
@@ -226,6 +235,7 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
|
|
|
226
235
|
effort: AcpEffort
|
|
227
236
|
): Promise<void> {
|
|
228
237
|
const bridge = getOrCreateBridge(ws);
|
|
238
|
+
bridge.setSessionStorePath(sessionStorePathFor(chatId));
|
|
229
239
|
bridge.setConfig(model, effort);
|
|
230
240
|
try {
|
|
231
241
|
await bridge.warmUp(sanitizeChatId(chatId));
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// (DDR-123 guardrail #1, verified end-to-end — the mock echoes its own env).
|
|
5
5
|
|
|
6
6
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
7
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
7
9
|
import { join } from 'node:path';
|
|
8
10
|
|
|
9
11
|
import { AcpBridge } from '../acp/bridge.ts';
|
|
@@ -112,6 +114,136 @@ describe('AcpBridge — round-trip + subscription guardrail', () => {
|
|
|
112
114
|
}, 15000);
|
|
113
115
|
});
|
|
114
116
|
|
|
117
|
+
describe('AcpBridge — cross-restart session resume (DDR-125 gap)', () => {
|
|
118
|
+
let dir: string;
|
|
119
|
+
|
|
120
|
+
afterEach(async () => {
|
|
121
|
+
if (dir) await rm(dir, { recursive: true, force: true });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('a persisted sessionId resumes via loadSession instead of spawning a new session', async () => {
|
|
125
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
126
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
127
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
128
|
+
dir = await mkdtemp(join(tmpdir(), 'acp-bridge-test-'));
|
|
129
|
+
const storePath = join(dir, 'c1.session.json');
|
|
130
|
+
await Bun.write(storePath, JSON.stringify({ sessionId: 'persisted-session-abc' }));
|
|
131
|
+
|
|
132
|
+
const updates: unknown[] = [];
|
|
133
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
|
|
134
|
+
try {
|
|
135
|
+
bridge.setSessionStorePath(storePath);
|
|
136
|
+
await bridge.prompt('pokracuj', 'c1');
|
|
137
|
+
// Resumed the persisted id verbatim — never fell through to session/new
|
|
138
|
+
// (which would have minted a fresh `mock-session-N`).
|
|
139
|
+
expect(bridge.sessionId).toBe('persisted-session-abc');
|
|
140
|
+
// The mock's replay notification must NOT reach the UI sink.
|
|
141
|
+
expect(JSON.stringify(updates)).not.toContain('REPLAYED-HISTORY-SHOULD-NOT-SURFACE');
|
|
142
|
+
} finally {
|
|
143
|
+
await bridge.stop();
|
|
144
|
+
}
|
|
145
|
+
}, 15000);
|
|
146
|
+
|
|
147
|
+
test('a resume that fails (pruned session) falls back to newSession and re-persists the new id', async () => {
|
|
148
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
149
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
150
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
151
|
+
dir = await mkdtemp(join(tmpdir(), 'acp-bridge-test-'));
|
|
152
|
+
const storePath = join(dir, 'c1.session.json');
|
|
153
|
+
await Bun.write(storePath, JSON.stringify({ sessionId: 'not-found' }));
|
|
154
|
+
|
|
155
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
|
|
156
|
+
try {
|
|
157
|
+
bridge.setSessionStorePath(storePath);
|
|
158
|
+
await bridge.prompt('pokracuj', 'c1');
|
|
159
|
+
expect(bridge.sessionId).toBe('mock-session-1'); // fresh session, not the stale id
|
|
160
|
+
const stored = JSON.parse(await readFile(storePath, 'utf8'));
|
|
161
|
+
expect(stored.sessionId).toBe('mock-session-1'); // re-persisted so the NEXT restart resumes it
|
|
162
|
+
} finally {
|
|
163
|
+
await bridge.stop();
|
|
164
|
+
}
|
|
165
|
+
}, 15000);
|
|
166
|
+
|
|
167
|
+
test('no prior chat for this sidecar behaves exactly as before — a brand new session is created and persisted', async () => {
|
|
168
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
169
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
170
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
171
|
+
dir = await mkdtemp(join(tmpdir(), 'acp-bridge-test-'));
|
|
172
|
+
const storePath = join(dir, 'brand-new.session.json');
|
|
173
|
+
|
|
174
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
|
|
175
|
+
try {
|
|
176
|
+
bridge.setSessionStorePath(storePath);
|
|
177
|
+
await bridge.prompt('hi', 'c1');
|
|
178
|
+
expect(bridge.sessionId).toBe('mock-session-1');
|
|
179
|
+
const stored = JSON.parse(await readFile(storePath, 'utf8'));
|
|
180
|
+
expect(stored.sessionId).toBe('mock-session-1');
|
|
181
|
+
} finally {
|
|
182
|
+
await bridge.stop();
|
|
183
|
+
}
|
|
184
|
+
}, 15000);
|
|
185
|
+
|
|
186
|
+
test('no sidecar wired at all (e.g. a warm-up before any prompt) never touches disk and behaves exactly as before', async () => {
|
|
187
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
188
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
189
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
190
|
+
|
|
191
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
|
|
192
|
+
try {
|
|
193
|
+
// setSessionStorePath is never called — sessionStorePath stays null.
|
|
194
|
+
await bridge.prompt('hi', 'c1');
|
|
195
|
+
expect(bridge.sessionId).toBe('mock-session-1');
|
|
196
|
+
} finally {
|
|
197
|
+
await bridge.stop();
|
|
198
|
+
}
|
|
199
|
+
}, 15000);
|
|
200
|
+
|
|
201
|
+
test('a malformed persisted sessionId (not a plausible shape) is rejected — falls back to newSession', async () => {
|
|
202
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
203
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
204
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
205
|
+
dir = await mkdtemp(join(tmpdir(), 'acp-bridge-test-'));
|
|
206
|
+
const storePath = join(dir, 'c1.session.json');
|
|
207
|
+
// Not a plausible sessionId shape (embedded newline + non-UUID charset) —
|
|
208
|
+
// must never reach the wire as a `loadSession` sessionId.
|
|
209
|
+
await Bun.write(storePath, JSON.stringify({ sessionId: 'evil\nsessionId; rm -rf /' }));
|
|
210
|
+
|
|
211
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
|
|
212
|
+
try {
|
|
213
|
+
bridge.setSessionStorePath(storePath);
|
|
214
|
+
await bridge.prompt('hi', 'c1');
|
|
215
|
+
expect(bridge.sessionId).toBe('mock-session-1'); // rejected — fresh session instead
|
|
216
|
+
} finally {
|
|
217
|
+
await bridge.stop();
|
|
218
|
+
}
|
|
219
|
+
}, 15000);
|
|
220
|
+
|
|
221
|
+
test('warm and prompt racing for the same chat share one resume attempt (no replaying race)', async () => {
|
|
222
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
223
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
224
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
225
|
+
dir = await mkdtemp(join(tmpdir(), 'acp-bridge-test-'));
|
|
226
|
+
const storePath = join(dir, 'c1.session.json');
|
|
227
|
+
await Bun.write(storePath, JSON.stringify({ sessionId: 'persisted-race-session' }));
|
|
228
|
+
|
|
229
|
+
const updates: unknown[] = [];
|
|
230
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
|
|
231
|
+
try {
|
|
232
|
+
bridge.setSessionStorePath(storePath);
|
|
233
|
+
// Fire warmUp and prompt concurrently for the same chatId — both call
|
|
234
|
+
// sessionFor('c1') before either has resolved.
|
|
235
|
+
const [, result] = await Promise.all([bridge.warmUp('c1'), bridge.prompt('pokracuj', 'c1')]);
|
|
236
|
+
expect(bridge.sessionId).toBe('persisted-race-session');
|
|
237
|
+
expect(result.stopReason).toBe('end_turn');
|
|
238
|
+
// Only ONE resume attempt happened — the mock's replay text never leaked,
|
|
239
|
+
// and the prompt's own turn still streamed normally.
|
|
240
|
+
expect(JSON.stringify(updates)).not.toContain('REPLAYED-HISTORY-SHOULD-NOT-SURFACE');
|
|
241
|
+
} finally {
|
|
242
|
+
await bridge.stop();
|
|
243
|
+
}
|
|
244
|
+
}, 15000);
|
|
245
|
+
});
|
|
246
|
+
|
|
115
247
|
describe('probeAcpAvailability — not-connected detection', () => {
|
|
116
248
|
test('reports not-available with a Claude-Code reason when the CLI is absent', () => {
|
|
117
249
|
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// the cached list to a freshly-opened socket.
|
|
7
7
|
|
|
8
8
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
9
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
9
11
|
import { join } from 'node:path';
|
|
10
12
|
|
|
11
13
|
import type { ServerWebSocket } from 'bun';
|
|
@@ -73,9 +75,11 @@ describe('AcpBridge.warmUp — publishes the command catalogue without a prompt'
|
|
|
73
75
|
describe('Acp manager — warm frame broadcasts + open replays commands', () => {
|
|
74
76
|
test('a {t:warm} frame yields a {t:commands} frame, replayed to a new socket', async () => {
|
|
75
77
|
useMockAgent();
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
// Isolated tmp designRoot — `warm` now also writes a session-store sidecar
|
|
79
|
+
// (bridge.ts sessionFor's resume path), so this must not land in a real
|
|
80
|
+
// repo path.
|
|
81
|
+
const designRoot = await mkdtemp(join(tmpdir(), 'acp-commands-test-'));
|
|
82
|
+
const ctx = { paths: { repoRoot: process.cwd(), designRoot } } as unknown as Context;
|
|
79
83
|
const acp = createAcp(ctx);
|
|
80
84
|
|
|
81
85
|
const a = fakeWs('ws-a');
|
|
@@ -103,6 +107,7 @@ describe('Acp manager — warm frame broadcasts + open replays commands', () =>
|
|
|
103
107
|
acp.onClose(a.ws);
|
|
104
108
|
// give teardown a tick to kill the subprocess
|
|
105
109
|
await new Promise((r) => setTimeout(r, 50));
|
|
110
|
+
await rm(designRoot, { recursive: true, force: true });
|
|
106
111
|
}
|
|
107
112
|
}, 20000);
|
|
108
113
|
});
|
|
@@ -5,6 +5,16 @@
|
|
|
5
5
|
// `agent_message_chunk` whose text echoes whether ANTHROPIC_API_KEY survived
|
|
6
6
|
// into the child env — it MUST read `<unset>`, proving scrubAgentEnv (DDR-123
|
|
7
7
|
// guardrail #1) stripped it before spawn.
|
|
8
|
+
//
|
|
9
|
+
// session/load simulates the REAL adapter's cross-restart resume (this is a
|
|
10
|
+
// fresh subprocess per test, so it has no memory of a prior session/new call —
|
|
11
|
+
// exactly like the real claude-agent-acp adapter after an app restart, whose
|
|
12
|
+
// resume is backed by the underlying `claude` CLI's own on-disk store, not its
|
|
13
|
+
// own process memory). Any sessionId resolves EXCEPT the `not-found` sentinel
|
|
14
|
+
// (simulates a pruned/unresumable session), so a test controls resume
|
|
15
|
+
// success/failure by choosing which id it persists. It also emits one replay
|
|
16
|
+
// `session/update` BEFORE resolving, mirroring claude-agent-acp's
|
|
17
|
+
// `replaySessionHistory` — bridge.ts must not forward or re-transcript it.
|
|
8
18
|
|
|
9
19
|
import { Readable, Writable } from 'node:stream';
|
|
10
20
|
|
|
@@ -16,7 +26,7 @@ acp
|
|
|
16
26
|
.agent({ name: 'mock-acp-agent' })
|
|
17
27
|
.onRequest('initialize', () => ({
|
|
18
28
|
protocolVersion: acp.PROTOCOL_VERSION,
|
|
19
|
-
agentCapabilities: { loadSession:
|
|
29
|
+
agentCapabilities: { loadSession: true },
|
|
20
30
|
}))
|
|
21
31
|
.onRequest(
|
|
22
32
|
'session/new',
|
|
@@ -25,6 +35,19 @@ acp
|
|
|
25
35
|
return () => ({ sessionId: `mock-session-${++n}` });
|
|
26
36
|
})()
|
|
27
37
|
)
|
|
38
|
+
.onRequest('session/load', async (ctx) => {
|
|
39
|
+
if (ctx.params.sessionId === 'not-found') {
|
|
40
|
+
throw new Error('session not found');
|
|
41
|
+
}
|
|
42
|
+
await ctx.client.notify('session/update', {
|
|
43
|
+
sessionId: ctx.params.sessionId,
|
|
44
|
+
update: {
|
|
45
|
+
sessionUpdate: 'agent_message_chunk',
|
|
46
|
+
content: { type: 'text', text: 'REPLAYED-HISTORY-SHOULD-NOT-SURFACE' },
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
return {};
|
|
50
|
+
})
|
|
28
51
|
.onRequest('session/prompt', async (ctx) => {
|
|
29
52
|
// The per-request handler context exposes the client connection at `.client`.
|
|
30
53
|
await ctx.client.notify('session/update', {
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "acp-chat-cross-restart-resume",
|
|
6
|
+
"version": "0.39.1",
|
|
7
|
+
"date": "2026-07-03",
|
|
8
|
+
"kind": "fix",
|
|
9
|
+
"title": "The Assistant remembers your conversation after a restart",
|
|
10
|
+
"summary": "Killed the app to grab an update, or restarted the dev server mid-chat? The Assistant now picks up the actual conversation where you left off instead of quietly starting fresh while the old messages just sat there.",
|
|
11
|
+
"surface": "design-ui"
|
|
12
|
+
},
|
|
4
13
|
{
|
|
5
14
|
"id": "acp-chat-image-thumbnails",
|
|
6
15
|
"version": "0.39.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1agh/maude",
|
|
3
|
-
"version": "0.39.
|
|
3
|
+
"version": "0.39.1",
|
|
4
4
|
"description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -50,13 +50,13 @@
|
|
|
50
50
|
"test:e2e:desktop:onboarding": "pnpm --filter @maude/desktop-e2e e2e:onboarding"
|
|
51
51
|
},
|
|
52
52
|
"optionalDependencies": {
|
|
53
|
-
"@1agh/maude-darwin-arm64": "0.39.
|
|
54
|
-
"@1agh/maude-darwin-x64": "0.39.
|
|
55
|
-
"@1agh/maude-linux-arm64": "0.39.
|
|
56
|
-
"@1agh/maude-linux-arm64-musl": "0.39.
|
|
57
|
-
"@1agh/maude-linux-x64": "0.39.
|
|
58
|
-
"@1agh/maude-linux-x64-musl": "0.39.
|
|
59
|
-
"@1agh/maude-win32-x64": "0.39.
|
|
53
|
+
"@1agh/maude-darwin-arm64": "0.39.1",
|
|
54
|
+
"@1agh/maude-darwin-x64": "0.39.1",
|
|
55
|
+
"@1agh/maude-linux-arm64": "0.39.1",
|
|
56
|
+
"@1agh/maude-linux-arm64-musl": "0.39.1",
|
|
57
|
+
"@1agh/maude-linux-x64": "0.39.1",
|
|
58
|
+
"@1agh/maude-linux-x64-musl": "0.39.1",
|
|
59
|
+
"@1agh/maude-win32-x64": "0.39.1"
|
|
60
60
|
},
|
|
61
61
|
"files": [
|
|
62
62
|
"cli",
|