Skip to content

Commit 897b0db

Browse files
cursoragentgaoyu06
andcommitted
Keep restored session ids and ACP agents
Co-authored-by: Gao Yu <gaoyu06@users.noreply.github.com>
1 parent 4bac127 commit 897b0db

4 files changed

Lines changed: 105 additions & 12 deletions

File tree

src/lib/session.svelte.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,15 @@ export interface SavedProject {
2525
id: string;
2626
name: string;
2727
path: string;
28-
tabs?: ({ id?: string; sid?: string; title: string; backend?: string; archived?: boolean } & SavedTabChrome)[];
28+
tabs?: ({
29+
id?: string;
30+
sid?: string;
31+
title: string;
32+
backend?: string;
33+
/** backend 为 'acp' 时:驱动该会话的 registry agent(重启动/恢复时必需)。 */
34+
acpAgent?: { id: string; name: string };
35+
archived?: boolean;
36+
} & SavedTabChrome)[];
2937
/** 并行任务 worktree 项目的元数据(isWorktree/mainRepoPath/branch/baseBranch/slug)。 */
3038
worktree?: WorktreeMeta;
3139
/** 本项目最近一次新建会话所用的引擎后端(缺省 = jucode)。 */
@@ -287,9 +295,10 @@ export class SessionStore {
287295
backend: BackendId = 'jucode',
288296
archived = false,
289297
chrome?: SavedTabChrome,
290-
reuseId?: string
298+
reuseId?: string,
299+
acpAgent?: { id: string; name: string }
291300
) {
292-
const s = this.#newSession(backend, undefined, reuseId);
301+
const s = this.#newSession(backend, backend === 'acp' ? acpAgent : undefined, reuseId);
293302
if (title) s.chat.title = title;
294303
s.archived = archived;
295304
if (chrome?.color) s.color = chrome.color;
@@ -321,9 +330,10 @@ export class SessionStore {
321330
title: string,
322331
backend: BackendId = 'jucode',
323332
archived = false,
324-
chrome?: SavedTabChrome
333+
chrome?: SavedTabChrome,
334+
acpAgent?: { id: string; name: string }
325335
) {
326-
const s = this.#newSession(backend, undefined, reuseId);
336+
const s = this.#newSession(backend, backend === 'acp' ? acpAgent : undefined, reuseId);
327337
if (title) s.chat.title = title;
328338
s.archived = archived;
329339
if (chrome?.color) s.color = chrome.color;
@@ -561,8 +571,11 @@ export class SessionStore {
561571
/** Snapshot of the layout + open tabs for persistence. Every session is
562572
* written (empty windows survive a workspace switch under their desktop
563573
* id); `sid` only when the engine actually persisted the conversation —
564-
* never `/resume` one it didn't. The backend id is only written when it
565-
* isn't the default, so pre-existing layouts stay byte-identical. */
574+
* never `/resume` one it didn't. A restored session keeps its `sid` even
575+
* while its replayed transcript is still empty (replay is async and may
576+
* fail; the engine-side conversation exists regardless). The backend id
577+
* is only written when it isn't the default, so pre-existing layouts stay
578+
* byte-identical; 'acp' tabs also carry their agent so restore can respawn. */
566579
serialize(): SavedProject[] {
567580
return this.projects.map((p) => ({
568581
id: p.id,
@@ -574,9 +587,10 @@ export class SessionStore {
574587
tabs: p.sessions
575588
.map((s) => ({
576589
id: s.id,
577-
...(s.chat.resumable ? { sid: s.chat.sessionId } : {}),
590+
...(s.chat.sessionId && (s.chat.resumable || s.restored) ? { sid: s.chat.sessionId } : {}),
578591
title: s.chat.title,
579592
...(s.backendId !== 'jucode' ? { backend: s.backendId } : {}),
593+
...(s.backendId === 'acp' && s.acpAgent ? { acpAgent: s.acpAgent } : {}),
580594
...(s.archived ? { archived: true } : {}),
581595
...(s.color ? { color: s.color } : {}),
582596
...(s.icon ? { icon: s.icon } : {}),
@@ -619,7 +633,19 @@ export class SessionStore {
619633
// Tabs saved before multi-backend support carry no backend field →
620634
// jucode (normalizeBackendId maps unknown/missing to the default).
621635
// Chrome fields are re-validated here (the file is user-editable).
622-
const backend = normalizeBackendId(t.backend);
636+
let backend = normalizeBackendId(t.backend);
637+
// An 'acp' tab needs its agent back to respawn; older files carry
638+
// none on the tab → fall back to the project's last agent. Without
639+
// any, never spawn a bare 'acp' (create_session rejects it).
640+
const savedAgent =
641+
t.acpAgent && typeof t.acpAgent.id === 'string' && typeof t.acpAgent.name === 'string'
642+
? { id: t.acpAgent.id, name: t.acpAgent.name }
643+
: undefined;
644+
let acpAgent = backend === 'acp' ? (savedAgent ?? proj.lastAcpAgent) : undefined;
645+
if (backend === 'acp' && !acpAgent) {
646+
backend = 'jucode';
647+
acpAgent = undefined;
648+
}
623649
const chrome = {
624650
color: normalizeColor(t.color),
625651
icon: parseTabIcon(t.icon),
@@ -628,8 +654,8 @@ export class SessionStore {
628654
// With a conversation to resume, resume it; an empty window spawns
629655
// fresh. Both keep the saved desktop id (pre-id files mint anew).
630656
const id = t.sid
631-
? this.restoreSession(proj, t.sid, t.title, backend, !!t.archived, chrome, t.id)
632-
: this.#spawnSaved(proj, t.id!, t.title, backend, !!t.archived, chrome);
657+
? this.restoreSession(proj, t.sid, t.title, backend, !!t.archived, chrome, t.id, acpAgent)
658+
: this.#spawnSaved(proj, t.id!, t.title, backend, !!t.archived, chrome, acpAgent);
633659
if (!first && !t.archived) first = id;
634660
}
635661
}

src/lib/session.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ vi.mock('./protocol', () => ({
77
sendOp: vi.fn(() => Promise.resolve()),
88
projectRoot: vi.fn(() => Promise.resolve('/tmp/demo')),
99
writeConfig: vi.fn(() => Promise.resolve()),
10-
git: vi.fn(() => Promise.resolve(''))
10+
git: vi.fn(() => Promise.resolve('')),
11+
claudeSessionTranscript: vi.fn(() => Promise.resolve([]))
1112
}));
1213

1314
import { SessionStore } from './session.svelte';
@@ -177,6 +178,61 @@ describe('SessionStore lifecycle', () => {
177178
]);
178179
});
179180

181+
it('a restored session serializes its sid even before the replay lands', () => {
182+
const store = new SessionStore();
183+
const p = proj();
184+
store.projects.push(p);
185+
// claude restore pins chat.sessionId immediately; the transcript replay is
186+
// async (and may fail), so messages are still empty here.
187+
const id = store.restoreSession(p, 'sid-r', 'old', 'claude');
188+
const s = p.sessions[0];
189+
expect(s.restored).toBe(true);
190+
expect(s.chat.sessionId).toBe('sid-r');
191+
expect(s.chat.messages.some((m) => m.kind === 'user')).toBe(false);
192+
expect(s.chat.resumable).toBe(false);
193+
const tab = store.serialize()[0].tabs![0];
194+
expect(tab).toEqual({ id, sid: 'sid-r', title: 'old', backend: 'claude' });
195+
});
196+
197+
it('serialize includes the acp agent and restore reapplies it on the session', async () => {
198+
const store = new SessionStore();
199+
const p = proj();
200+
store.projects.push(p);
201+
const agent = { id: 'gemini', name: 'Gemini CLI' };
202+
const id = store.addSession(p, undefined, 'acp', agent);
203+
const snap = store.serialize();
204+
expect(snap[0].tabs).toEqual([{ id, title: 'New session', backend: 'acp', acpAgent: agent }]);
205+
206+
const store2 = new SessionStore();
207+
await store2.restore(snap);
208+
const s = store2.projects[0].sessions[0];
209+
expect(s.backendId).toBe('acp');
210+
expect(s.acpAgent).toEqual(agent);
211+
expect(s.chat.acpAgentId).toBe('gemini');
212+
expect(s.chat.acpAgentName).toBe('Gemini CLI');
213+
// The spawn carried the agent option so create_session can look it up.
214+
const call = (createSession as unknown as { mock: { calls: unknown[][] } }).mock.calls.at(-1)!;
215+
expect(call[2]).toBe('acp');
216+
expect((call[3] as { agent?: string }).agent).toBe('gemini');
217+
});
218+
219+
it('acp tabs without a saved agent fall back to the project lastAcpAgent', async () => {
220+
const agent = { id: 'g', name: 'G' };
221+
const store = new SessionStore();
222+
await store.restore([
223+
{
224+
id: 'p1',
225+
name: 'p1',
226+
path: '/tmp/p1',
227+
lastBackend: 'acp',
228+
lastAcpAgent: agent,
229+
tabs: [{ id: 't1', title: 'A', backend: 'acp' }]
230+
}
231+
]);
232+
expect(store.projects[0].sessions[0].acpAgent).toEqual(agent);
233+
expect(store.projects[0].sessions[0].backendId).toBe('acp');
234+
});
235+
180236
it('restore seeds a default project when nothing is saved', async () => {
181237
const store = new SessionStore();
182238
await store.restore([]);

src/lib/workbench/tabChrome.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ describe('sanitizeSvg', () => {
3838
expect(sanitizeSvg('<svg><rect x="0" y="0" stroke="URL(http://evil)"/></svg>')).toBeNull();
3939
});
4040

41+
it('rejects entity-encoded attribute values (url&#40; bypass)', () => {
42+
expect(sanitizeSvg('<svg><path fill="url&#40;https://evil#p&#41;" d="M0 0"/></svg>')).toBeNull();
43+
expect(sanitizeSvg('<svg><path fill="url&#x28;#x&#x29;" d="M0 0"/></svg>')).toBeNull();
44+
expect(sanitizeSvg('<svg><path fill="url&lpar;#x&rpar;" d="M0 0"/></svg>')).toBeNull();
45+
expect(sanitizeSvg('<svg><path fill="a&amp;b" d="M0 0"/></svg>')).toBeNull();
46+
});
47+
4148
it('rejects non-svg roots, comments and oversized markup', () => {
4249
expect(sanitizeSvg('<div>hi</div>')).toBeNull();
4350
expect(sanitizeSvg('<svg><!-- sneaky --><path d="M0 0"/></svg>')).toBeNull();

src/lib/workbench/tabChrome.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ export function sanitizeSvg(markup: string): string | null {
6363
const name = a[1].toLowerCase();
6464
if (name.startsWith('on') || !SVG_ATTRS.has(name)) return null;
6565
const value = (a[2] ?? a[3] ?? a[4] ?? '').toLowerCase();
66+
// Entity sequences (&#40; → '(', &lpar; …) decode in the browser and
67+
// would smuggle url(...) past the raw-string checks below. No allowed
68+
// attribute legitimately needs '&', so reject it outright — never repair.
69+
if (value.includes('&')) return null;
6670
// url(...) paint servers can reference external resources.
6771
if (value.includes('javascript:') || value.includes('data:') || value.includes('url(')) return null;
6872
}

0 commit comments

Comments
 (0)