fix(native-chat): honour notify on the relabel path and emit the stored snapshot

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.
This commit is contained in:
Merge Sim
2026-09-07 11:31:06 -07:00
parent 12e1f913f8
commit bd0807e1e5
5 changed files with 90 additions and 8 deletions
@@ -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 {
@@ -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
@@ -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<string, unknown> }
).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({
@@ -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
+1 -1
View File
@@ -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