Skip to content

Commit 5c1b08b

Browse files
committed
fix(session-ref): preserve stable session ref on provider re-emit
- Only update sessionRef if provider session ID changes, not on metadata updates - Add stableSessionRef tracking to ACP session to maintain original discovery timestamp - Implement currentSessionRef() method to return stable refs without recreation
1 parent a144457 commit 5c1b08b

4 files changed

Lines changed: 87 additions & 10 deletions

File tree

src/renderer/state/appStore.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,43 @@ describe("appStore runtime config sync", () => {
497497
expect(useAppStore.getState().threads[0]?.status).toBe("idle");
498498
});
499499

500+
it("preserves the stored session ref when runtime re-emits the same provider id", () => {
501+
const project = useAppStore.getState().addProject({
502+
kind: "windows",
503+
path: "C:\\repo",
504+
});
505+
const thread = useAppStore.getState().createThread({
506+
projectId: project.id,
507+
agentKind: "gemini",
508+
config: { model: "gemini-test" },
509+
prompt: "a",
510+
});
511+
512+
useAppStore.getState().updateThreadRuntime(thread.id, {
513+
status: "idle",
514+
attention: "none",
515+
canResumeWithConfig: true,
516+
sessionRef: {
517+
providerSessionId: "gemini-session-1",
518+
discoveredAt: "2026-05-01T12:00:00.000Z",
519+
},
520+
});
521+
useAppStore.getState().updateThreadRuntime(thread.id, {
522+
status: "idle",
523+
attention: "none",
524+
canResumeWithConfig: true,
525+
sessionRef: {
526+
providerSessionId: "gemini-session-1",
527+
discoveredAt: "2026-05-01T12:05:00.000Z",
528+
},
529+
});
530+
531+
expect(useAppStore.getState().threads[0]?.sessionRef).toEqual({
532+
providerSessionId: "gemini-session-1",
533+
discoveredAt: "2026-05-01T12:00:00.000Z",
534+
});
535+
});
536+
500537
it("openThread on finished thread transitions to idle", () => {
501538
const project = useAppStore.getState().addProject({
502539
kind: "windows",

src/renderer/state/slices/threadSlice.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -387,8 +387,9 @@ export const createThreadSlice: SliceCreator<ThreadSlice> = (set) => ({
387387

388388
const sessionRefChanged =
389389
input.sessionRef !== undefined &&
390-
(thread.sessionRef?.providerSessionId !== input.sessionRef.providerSessionId ||
391-
thread.sessionRef?.discoveredAt !== input.sessionRef.discoveredAt);
390+
thread.sessionRef?.providerSessionId !== input.sessionRef.providerSessionId;
391+
const nextSessionRef =
392+
input.sessionRef && sessionRefChanged ? input.sessionRef : thread.sessionRef;
392393

393394
const statusSourceMatch =
394395
input.threadStatusSource === undefined ||
@@ -439,7 +440,7 @@ export const createThreadSlice: SliceCreator<ThreadSlice> = (set) => ({
439440
...(input.threadStatusSource !== undefined
440441
? { threadStatusSource: input.threadStatusSource }
441442
: {}),
442-
...(input.sessionRef ? { sessionRef: input.sessionRef } : {}),
443+
...(nextSessionRef ? { sessionRef: nextSessionRef } : {}),
443444
...(input.slashCommands !== undefined ? { slashCommands: input.slashCommands } : {}),
444445
...(input.status === "working" && thread.status !== "working"
445446
? { updatedAt: nowIso }

src/supervisor/agents/acp/session.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,21 @@ describe("ACP turn config sync", () => {
477477
expect(listener.onUpdate).not.toHaveBeenCalled();
478478
});
479479

480+
it("continues suppressing late Gemini loadSession history replay after the RPC resolves", () => {
481+
const { listener, session } = makeConfigSyncSession();
482+
(session as unknown as Record<string, unknown>)["replayHistoryUntil"] = Date.now() + 500;
483+
484+
session.handleSessionUpdate({
485+
update: {
486+
sessionUpdate: "agent_message_chunk",
487+
content: { type: "text", text: "restored assistant message" },
488+
},
489+
});
490+
491+
expect(listener.onRuntimeEvent).not.toHaveBeenCalled();
492+
expect(listener.onUpdate).not.toHaveBeenCalled();
493+
});
494+
480495
it("surfaces available ACP slash commands from session updates", () => {
481496
const { listener, session } = makeConfigSyncSession();
482497

src/supervisor/agents/acp/session.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,7 @@ export class AcpStructuredSession implements StructuredSessionHandle {
601601
private currentAttention: ThreadAttention = "none";
602602
private spawnReady: Promise<void> = Promise.resolve();
603603
private currentTurnId: string | undefined;
604+
private stableSessionRef: SessionRef | undefined;
604605
/**
605606
* True while a `connection.prompt()` call is in flight (between issue and
606607
* resolution). Used together with `pendingPromptInterrupt` to close the
@@ -635,6 +636,7 @@ export class AcpStructuredSession implements StructuredSessionHandle {
635636
* and let normal mapping resume once the load completes.
636637
*/
637638
private isReplayingHistory = false;
639+
private replayHistoryUntil = 0;
638640

639641
private constructor(
640642
child: ChildProcess,
@@ -677,11 +679,12 @@ export class AcpStructuredSession implements StructuredSessionHandle {
677679
}
678680

679681
private emitCurrentState(listener: StructuredSessionListener): void {
682+
const sessionRef = this.currentSessionRef();
680683
listener.onUpdate({
681684
status: this.currentStatus,
682685
attention: this.currentAttention,
683686
...(this.currentConfig ? { config: this.currentConfig } : {}),
684-
...(this.sessionId ? { sessionRef: createKnownSessionRef(this.sessionId) } : {}),
687+
...(sessionRef ? { sessionRef } : {}),
685688
...(this.currentSlashCommands !== undefined
686689
? { slashCommands: this.currentSlashCommands }
687690
: {}),
@@ -693,15 +696,29 @@ export class AcpStructuredSession implements StructuredSessionHandle {
693696
return;
694697
}
695698
this.currentSlashCommands = commands;
699+
const sessionRef = this.currentSessionRef();
696700
this.emitListenerUpdate({
697701
status: this.currentStatus,
698702
attention: this.currentAttention,
699703
...(this.currentConfig ? { config: this.currentConfig } : {}),
700-
...(this.sessionId ? { sessionRef: createKnownSessionRef(this.sessionId) } : {}),
704+
...(sessionRef ? { sessionRef } : {}),
701705
slashCommands: commands,
702706
});
703707
}
704708

709+
private currentSessionRef(): SessionRef | undefined {
710+
if (!this.sessionId) return undefined;
711+
if (this.stableSessionRef?.providerSessionId !== this.sessionId) {
712+
this.stableSessionRef = createKnownSessionRef(this.sessionId);
713+
}
714+
return this.stableSessionRef;
715+
}
716+
717+
private adoptSessionRef(sessionRef: SessionRef): void {
718+
this.sessionId = sessionRef.providerSessionId;
719+
this.stableSessionRef = sessionRef;
720+
}
721+
705722
private rememberSessionOptions(availableModeIds: string[], configOptions: unknown): void {
706723
this.availableModeIds = availableModeIds;
707724
this.currentConfigOptions = Array.isArray(configOptions) ? configOptions : [];
@@ -976,17 +993,19 @@ export class AcpStructuredSession implements StructuredSessionHandle {
976993
if (sessionRef) {
977994
console.log("[acp] loading session:", sessionRef.providerSessionId);
978995
this.isReplayingHistory = true;
996+
this.replayHistoryUntil = Infinity;
979997
try {
980998
const result = await this.connection.loadSession({
981999
sessionId: sessionRef.providerSessionId,
9821000
cwd: this.cwd,
9831001
mcpServers: [],
9841002
});
985-
this.sessionId = sessionRef.providerSessionId;
1003+
this.adoptSessionRef(sessionRef);
9861004
availableModeIds = result.modes?.availableModes?.map((m) => m.id) ?? [];
9871005
configOptions = result.configOptions ?? [];
9881006
} finally {
9891007
this.isReplayingHistory = false;
1008+
this.replayHistoryUntil = Date.now() + 500;
9901009
}
9911010
} else {
9921011
console.log("[acp] creating new session in", this.cwd);
@@ -995,6 +1014,7 @@ export class AcpStructuredSession implements StructuredSessionHandle {
9951014
mcpServers: [],
9961015
});
9971016
this.sessionId = result.sessionId;
1017+
this.stableSessionRef = createKnownSessionRef(result.sessionId);
9981018
availableModeIds = result.modes?.availableModes?.map((m) => m.id) ?? [];
9991019
configOptions = result.configOptions ?? [];
10001020
console.log("[acp] session created:", this.sessionId, "modes:", availableModeIds);
@@ -1003,7 +1023,9 @@ export class AcpStructuredSession implements StructuredSessionHandle {
10031023
this.rememberSessionOptions(availableModeIds, configOptions);
10041024
await this.applyTurnConfig(config);
10051025

1006-
this.launchOptions = { ...this.launchOptions, resumeThreadId: this.sessionId };
1026+
if (this.sessionId) {
1027+
this.launchOptions = { ...this.launchOptions, resumeThreadId: this.sessionId };
1028+
}
10071029
return this.sessionId!;
10081030
}
10091031

@@ -1306,7 +1328,7 @@ export class AcpStructuredSession implements StructuredSessionHandle {
13061328
// `session/update` notifications. Lightcode already has those messages
13071329
// in its own DB, so we skip canonical mapping for the replay window to
13081330
// avoid duplicating every message in the chat pane.
1309-
if (!this.isReplayingHistory) {
1331+
if (!this.isReplayingHistory && Date.now() >= (this.replayHistoryUntil || 0)) {
13101332
const events = mapAcpSessionUpdate(params, this.ensureMapperState());
13111333
if (events.length > 0) this.emitRuntimeEvents(events);
13121334
} else {
@@ -1358,14 +1380,15 @@ export class AcpStructuredSession implements StructuredSessionHandle {
13581380
const nextConfig = applyAcpModeUpdateToConfig(this.currentConfig, update.currentModeId);
13591381
if (!isThreadConfigEqual(this.currentConfig, nextConfig)) {
13601382
this.currentConfig = nextConfig;
1383+
const sessionRef = this.currentSessionRef();
13611384
// Mode-change confirmations are metadata, not turn boundaries —
13621385
// preserve the live status so the renderer's working-time clock
13631386
// doesn't reset when the agent echoes back a setSessionMode call.
13641387
this.emitListenerUpdate({
13651388
status: this.currentStatus,
13661389
attention: this.currentAttention,
13671390
config: nextConfig,
1368-
...(this.sessionId ? { sessionRef: createKnownSessionRef(this.sessionId) } : {}),
1391+
...(sessionRef ? { sessionRef } : {}),
13691392
});
13701393
}
13711394
}
@@ -1381,11 +1404,12 @@ export class AcpStructuredSession implements StructuredSessionHandle {
13811404
) {
13821405
const nextConfig = { ...this.currentConfig, effort: thoughtLevelConfig.currentValue };
13831406
this.currentConfig = nextConfig;
1407+
const sessionRef = this.currentSessionRef();
13841408
this.emitListenerUpdate({
13851409
status: this.currentStatus,
13861410
attention: this.currentAttention,
13871411
config: nextConfig,
1388-
...(this.sessionId ? { sessionRef: createKnownSessionRef(this.sessionId) } : {}),
1412+
...(sessionRef ? { sessionRef } : {}),
13891413
});
13901414
}
13911415
}

0 commit comments

Comments
 (0)