From bd0807e1e5dec96f615469fe36f0174aa802ed0b Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Mon, 7 Sep 2026 11:31:06 -0700 Subject: [PATCH] fix(native-chat): honour notify on the relabel path and emit the stored snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup sweep republishes every restored session with notify: false, but the relabel branch never consulted it and emitted a full tab list per named session — the desktop renderer is a real subscriber on that feed. It also emitted the pre-store candidate, although the store can hand back a different object, so a client mirror could retain a replaced tab under an identical snapshotVersion; the sibling replace path already got this right. Also corrects the merge comment that claimed a background republish cannot re-surface a lost mirror, moves the doc block that landed between an existing comment and the field it described, and routes the replacement tab's placeholder through defaultAgentChatLabel. --- .../runtime/orca-runtime-get-worktree-ps.ts | 12 +++- ...tore-structured-agent-session-tabs-once.ts | 11 +-- ...runtime-structured-session-restore.test.ts | 70 +++++++++++++++++++ ...structured-conversation-tab-replacement.ts | 3 +- src/shared/agent-status-types.ts | 2 +- 5 files changed, 90 insertions(+), 8 deletions(-) diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index f755885d090..d4f8bdd3abe 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -233,6 +233,9 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent workspaceId: string sessionId: string conversationName: string | null + /** False for a background republish, which must store the name without + * pushing a full tab list at every live subscriber. */ + notify?: boolean }): void { const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId) const id = `agent-session:${input.sessionId}` @@ -249,8 +252,13 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent snapshotVersion: existing.snapshotVersion + 1, tabs: existing.tabs.map((tab) => (tab.id === id ? { ...tab, title } : tab)) } - this.storeMobileSessionSnapshot(input.workspaceId, snapshot) - this.emitMobileSessionTabsSnapshot(snapshot) + // Emit what was STORED, not the local candidate: the store may hand back a + // different object, and a client mirror would then hold a replaced tab under + // an identical snapshotVersion. Matches replaceStructuredAgentSessionTab. + const stored = this.storeMobileSessionSnapshot(input.workspaceId, snapshot) + if (input.notify !== false) { + this.emitMobileSessionTabsSnapshot(stored) + } } protected createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport { diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index c1d24ab962f..6fc460d0002 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -116,9 +116,9 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu const id = `agent-session:${input.sessionId}` if (existing?.tabs.some((tab) => tab.id === id)) { const conversationName = title - // A background re-publish only relabels an existing tab — it never re-adds one — so it - // cannot re-surface a client whose mirror lost the tab; healing one needs `activate` or an - // explicit republish. + // A background re-publish only relabels an existing tab — it never re-adds one — and with + // `notify: false` it emits nothing at all, so it cannot re-surface a client whose mirror + // lost the tab; healing one needs `activate` or an explicit republish. if (!input.activate) { // Republishing an already-open tab is how a restored session hands over // the name it was persisted with; the rest of the snapshot is unchanged. @@ -126,7 +126,10 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu this.applyStructuredAgentSessionConversationName({ workspaceId: input.workspaceId, sessionId: input.sessionId, - conversationName + conversationName, + // The startup sweep runs once per session; without this each named + // one would push the whole tab list at every live subscriber. + ...(input.notify !== undefined ? { notify: input.notify } : {}) }) } return diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index d20f86c4da2..3623c075704 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -437,6 +437,76 @@ describe('structured session cold restoration', () => { expect(after.snapshotVersion).toBeGreaterThan(before.snapshotVersion ?? 0) }) + it('stores a background relabel without pushing a snapshot at every subscriber', async () => { + const runtime = new OrcaRuntimeService() + await runtime.publishStructuredAgentSessionTab({ + workspaceId: 'workspace-1', + sessionId: 'session-1', + agent: 'codex', + activate: true + }) + const emit = vi.fn() + ;( + runtime as unknown as { emitMobileSessionTabsSnapshot: unknown } + ).emitMobileSessionTabsSnapshot = emit + + // The startup sweep's own call shape, once per restored session. + await runtime.publishStructuredAgentSessionTab({ + workspaceId: 'workspace-1', + sessionId: 'session-1', + agent: 'codex', + activate: false, + notify: false, + title: 'Fix the lease probe' + }) + + // The desktop renderer subscribes to this feed, so a sweep landing after it + // connects would fan out one full tab list per named session. + expect(emit).not.toHaveBeenCalled() + const after = await runtime.listMobileSessionTabs('id:workspace-1') + expect(after.tabs[0]).toMatchObject({ title: 'Fix the lease probe' }) + }) + + it('emits the snapshot the store kept, not the candidate handed to it', async () => { + const runtime = new OrcaRuntimeService() + await runtime.publishStructuredAgentSessionTab({ + workspaceId: 'workspace-1', + sessionId: 'session-1', + agent: 'codex', + activate: true + }) + const emitted: unknown[] = [] + ;( + runtime as unknown as { emitMobileSessionTabsSnapshot: unknown } + ).emitMobileSessionTabsSnapshot = (snapshot: unknown) => emitted.push(snapshot) + // A pending conversation replacement rewrites every snapshot on its way into + // the store, so the candidate and the stored object are not the same object. + setStructuredAgentSessionHost({ + conversationReplacements: () => [ + { + workspaceId: 'workspace-1', + sourceSessionId: 'session-1', + sessionId: 'session-2', + agent: 'codex' + } + ] + } as never) + + runtime.applyStructuredAgentSessionConversationName({ + workspaceId: 'workspace-1', + sessionId: 'session-1', + conversationName: 'Fix the lease probe' + }) + + // A client mirror fed the pre-store candidate would keep a replaced tab + // under an identical snapshotVersion. + const kept = ( + runtime as unknown as { mobileSessionTabsByWorktree: Map } + ).mobileSessionTabsByWorktree.get('workspace-1') + expect(emitted).toHaveLength(1) + expect(emitted[0]).toBe(kept) + }) + it('publishes nothing when the name it was given is the one already shown', async () => { const runtime = new OrcaRuntimeService() await runtime.publishStructuredAgentSessionTab({ diff --git a/src/main/runtime/structured-conversation-tab-replacement.ts b/src/main/runtime/structured-conversation-tab-replacement.ts index 94d9bdf52d9..7384a374c91 100644 --- a/src/main/runtime/structured-conversation-tab-replacement.ts +++ b/src/main/runtime/structured-conversation-tab-replacement.ts @@ -1,3 +1,4 @@ +import { defaultAgentChatLabel } from '../../shared/agent-session-chat-label' import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import type { ConversationReplacement } from '../native-chat/agent-session-wire/structured-conversation-command' @@ -30,7 +31,7 @@ export function replaceConversationInSnapshot( id, sessionId: replacement.sessionId, agent: replacement.agent, - title: replacement.agent === 'claude' ? 'Claude Chat' : 'Codex Chat', + title: defaultAgentChatLabel(replacement.agent), replacesSessionId: replacement.sourceSessionId } : tab diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index f596f941056..9c9f4882c69 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -166,12 +166,12 @@ export type AgentStatusEntry = { /** Provider-owned conversation/session id captured from hook payloads. * Used only for exact CLI resume; Orca terminal ids are not agent-session ids. */ providerSession?: AgentProviderSessionMetadata - /** False when the status belongs to a non-terminal owner that restores itself. */ /** A structured chat's settled name, already resolved between the user's rename * and the provider's. Carried apart from `terminalTitle` because that field is * laundered through heuristics built for OSC titles scraped off a pty, which * null a legitimate name like `auth/login`. This one is authoritative. */ conversationName?: string + /** False when the status belongs to a non-terminal owner that restores itself. */ terminalResumeEligible?: false /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ promptInteractionKey?: string