mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
fix(chat-rename): skip invisible legacy tab names during rename migration (#33678)
* fix(chat-rename): skip invisible legacy tab names during rename migration * test(chat-rename): bound legacy migration reconciliation work
This commit is contained in:
@@ -4,6 +4,8 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { Tab } from '../../../shared/tab-types'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { createGlobalSettingsFixture } from '../../../shared/global-settings-test-fixture'
|
||||
import * as conversationNames from '../../../shared/agent-session/agent-session-conversation-name'
|
||||
import * as structuredTabs from '../../../shared/structured-agent-session-tab'
|
||||
import { brandEphemeralSetupTerminalWorktreeId } from '../../../shared/ephemeral-setup-terminal-worktree-id'
|
||||
import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire'
|
||||
import type * as LayoutRouting from '@/store/terminals/tab-change-through-layout-owner'
|
||||
@@ -150,6 +152,163 @@ afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('keeps invisible aliases without retrying them while valid migration and explicit renames still work', async () => {
|
||||
const blank = chat({ executionHostId: 'runtime:paired', customLabel: '\u200B' })
|
||||
const joiners = chat({
|
||||
id: 'joiners-tab',
|
||||
entityId: 'joiners-session',
|
||||
executionHostId: 'runtime:paired',
|
||||
customLabel: '\u200C\u200D'
|
||||
})
|
||||
const valid = chat({
|
||||
id: 'valid-tab',
|
||||
entityId: 'valid-session',
|
||||
executionHostId: 'runtime:paired',
|
||||
customLabel: ' Visible\u200B name '
|
||||
})
|
||||
seed(blank)
|
||||
useAppStore.setState({
|
||||
unifiedTabsByWorktree: { [workspace.id]: [blank, joiners, valid] },
|
||||
groupsByWorktree: saveWorkspaceLayout(loadChat(blank, 'runtime:paired')).tabGroups ?? {}
|
||||
})
|
||||
let acknowledge = () => {}
|
||||
mock.rpc.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
acknowledge = () => resolve({ applied: true })
|
||||
})
|
||||
)
|
||||
stops.push(installLegacyChatRenameMigration())
|
||||
await waitFor(() => expect(mock.rpc).toHaveBeenCalledTimes(1))
|
||||
expect(mock.rpc).toHaveBeenLastCalledWith(
|
||||
{ kind: 'environment', environmentId: 'paired' },
|
||||
'agentSession.rename',
|
||||
{ sessionId: valid.entityId, name: 'Visible name', legacyAlias: true }
|
||||
)
|
||||
// Contact retries wait for the valid neighbor's in-flight acknowledgment.
|
||||
mock.contacts.forEach((regained) => regained())
|
||||
await act(async () => acknowledge())
|
||||
const current = (index: number) =>
|
||||
useAppStore.getState().unifiedTabsByWorktree[workspace.id][index]
|
||||
await waitFor(() => expect(current(2).customLabel).toBeNull())
|
||||
expect(mock.rpc).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
buildPersistedUnifiedTabSessionData(useAppStore.getState()).unifiedTabs?.[workspace.id]?.map(
|
||||
(tab) => tab.customLabel
|
||||
)
|
||||
).toEqual(['\u200B', '\u200C\u200D', null])
|
||||
|
||||
useAppStore.getState().setTabCustomTitle(blank.id, 'Visible replacement')
|
||||
await waitFor(() => expect(mock.rpc).toHaveBeenCalledTimes(2))
|
||||
expect(mock.rpc).toHaveBeenLastCalledWith(expect.anything(), 'agentSession.rename', {
|
||||
sessionId: blank.entityId,
|
||||
name: 'Visible replacement'
|
||||
})
|
||||
useAppStore.getState().setTabCustomLabel(blank.id, '\u200D')
|
||||
await act(async () => acknowledge())
|
||||
expect(current(0).customLabel).toBe('\u200D')
|
||||
expect(mock.layout).toHaveBeenCalledTimes(1)
|
||||
|
||||
useAppStore.getState().setTabCustomTitle(blank.id, 'Final visible name')
|
||||
await waitFor(() => expect(mock.rpc).toHaveBeenCalledTimes(3))
|
||||
acknowledge()
|
||||
await waitFor(() => expect(current(0).customLabel).toBeNull())
|
||||
const cleanup = mock.layout.mock.calls[1]
|
||||
expect(cleanup[2](blank.id)).toEqual({
|
||||
type: 'renameTab',
|
||||
tabId: blank.id,
|
||||
kind: 'custom',
|
||||
title: null,
|
||||
expected: {
|
||||
entityId: blank.entityId,
|
||||
executionHostId: 'runtime:paired',
|
||||
customTitle: '\u200D'
|
||||
}
|
||||
})
|
||||
expect(current(1).customLabel).toBe(joiners.customLabel)
|
||||
})
|
||||
|
||||
it('bounds reconciliation scans linearly at N and 10N without resending unchanged or in-flight aliases', async () => {
|
||||
const normalize = vi.spyOn(conversationNames, 'normalizeAgentSessionConversationName')
|
||||
const enumerate = vi.spyOn(structuredTabs, 'isStructuredTab')
|
||||
const aliases = ['x'.repeat(5000), '\u200B'.repeat(5000), '\u200C\u200D'.repeat(2500), null]
|
||||
for (const count of [40, 400]) {
|
||||
const tabs = Array.from({ length: count }, (_, index) =>
|
||||
chat({
|
||||
id: `scale-tab-${index}`,
|
||||
entityId: `scale-session-${index}`,
|
||||
executionHostId: 'runtime:paired',
|
||||
customLabel: aliases[index % aliases.length]
|
||||
})
|
||||
)
|
||||
seed(tabs[0])
|
||||
useAppStore.setState({
|
||||
unifiedTabsByWorktree: { [workspace.id]: tabs },
|
||||
groupsByWorktree: {
|
||||
[workspace.id]: [
|
||||
{
|
||||
id: 'group',
|
||||
worktreeId: workspace.id,
|
||||
activeTabId: tabs[0].id,
|
||||
tabOrder: tabs.map((tab) => tab.id)
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
mock.rpc.mockReset()
|
||||
const acknowledge: (() => void)[] = []
|
||||
mock.rpc.mockImplementation(
|
||||
() => new Promise((resolve) => acknowledge.push(() => resolve({ applied: true })))
|
||||
)
|
||||
const expectScan = (population: readonly Tab[]) => {
|
||||
expect(enumerate).toHaveBeenCalledTimes(population.length)
|
||||
expect(normalize).toHaveBeenCalledTimes(population.length)
|
||||
expect(normalize.mock.calls.map(([alias]) => alias)).toEqual(
|
||||
population.map((tab) => tab.customLabel ?? '')
|
||||
)
|
||||
enumerate.mockClear()
|
||||
normalize.mockClear()
|
||||
}
|
||||
enumerate.mockClear()
|
||||
normalize.mockClear()
|
||||
const stop = installLegacyChatRenameMigration()
|
||||
stops.push(stop)
|
||||
expectScan(tabs)
|
||||
await waitFor(() => expect(mock.rpc).toHaveBeenCalledTimes(count / 4))
|
||||
expect(mock.rpc.mock.calls.map(([, , params]) => params)).toEqual(
|
||||
tabs
|
||||
.filter((_, index) => index % 4 === 0)
|
||||
.map((tab) => ({ sessionId: tab.entityId, name: 'x'.repeat(200), legacyAlias: true }))
|
||||
)
|
||||
|
||||
// A new snapshot with identical candidates still scans, but must not send again.
|
||||
useAppStore.setState({ unifiedTabsByWorktree: { [workspace.id]: [...tabs] } })
|
||||
expectScan(tabs)
|
||||
// Changing the candidate set reaches the in-flight check rather than the unchanged shortcut.
|
||||
useAppStore.setState({ unifiedTabsByWorktree: { [workspace.id]: tabs.slice(1) } })
|
||||
expectScan(tabs.slice(1))
|
||||
useAppStore.setState({ unifiedTabsByWorktree: { [workspace.id]: [...tabs] } })
|
||||
expectScan(tabs)
|
||||
await act(async () => {})
|
||||
expect(mock.rpc).toHaveBeenCalledTimes(count / 4)
|
||||
expect(
|
||||
buildPersistedUnifiedTabSessionData(useAppStore.getState()).unifiedTabs?.[workspace.id]?.map(
|
||||
(tab) => tab.customLabel
|
||||
)
|
||||
).toEqual(tabs.map((tab) => tab.customLabel))
|
||||
|
||||
stop()
|
||||
// Refusing cleanup lets outstanding requests settle without unrelated per-alias store writes.
|
||||
mock.layout.mockImplementation((_state, _target, _change, _local, answers) => {
|
||||
answers?.refused?.()
|
||||
return true
|
||||
})
|
||||
await act(async () => acknowledge.forEach((resolve) => resolve()))
|
||||
expect(mock.layout).toHaveBeenCalledTimes(count / 4)
|
||||
mock.layout.mockReset()
|
||||
}
|
||||
})
|
||||
|
||||
it('migrates an inactive restored alias after reconnect and clears both saved copies only after acknowledgment', async () => {
|
||||
const alias = 'x'.repeat(5000)
|
||||
const tab = chat({
|
||||
@@ -212,7 +371,9 @@ it('retains a replacement alias when main receives an older session cleanup', as
|
||||
answers?.refused?.()
|
||||
return true
|
||||
})
|
||||
expect(await uploadLegacyStructuredChatRename(useAppStore.getState, tab, 'Legacy')).toBe(false)
|
||||
expect(
|
||||
await uploadLegacyStructuredChatRename(useAppStore.getState, tab, 'Legacy', 'Legacy')
|
||||
).toBe(false)
|
||||
expect(saveWorkspaceLayout(loaded).unifiedTabs?.[workspace.id]?.[0]?.customLabel).toBe('Legacy')
|
||||
expect(useAppStore.getState().unifiedTabsByWorktree[workspace.id]?.[0]?.customLabel).toBe(
|
||||
'Legacy'
|
||||
@@ -230,7 +391,9 @@ it('transfers an unstamped alias once its single local owner is known and reload
|
||||
mock.snapshot.set(tab.entityId, name(tab, 'History name'))
|
||||
mock.rpc.mockResolvedValue({ name: 'History name', applied: false })
|
||||
stops.push(installLegacyChatRenameMigration())
|
||||
expect(await uploadLegacyStructuredChatRename(useAppStore.getState, tab, 'Legacy')).toBe(false)
|
||||
expect(
|
||||
await uploadLegacyStructuredChatRename(useAppStore.getState, tab, 'Legacy', 'Legacy')
|
||||
).toBe(false)
|
||||
expect(mock.rpc).not.toHaveBeenCalled()
|
||||
const groups = saveWorkspaceLayout(loadChat(tab, 'local')).tabGroups?.[tab.worktreeId] ?? []
|
||||
useAppStore.setState({ groupsByWorktree: { [tab.worktreeId]: groups } })
|
||||
@@ -528,7 +691,12 @@ it('traces legacy runtime storage movement, action routing and switch-away befor
|
||||
expect(useAppStore.getState().workspaceSessionReady).toBe(true)
|
||||
stops.push(installLegacyChatRenameMigration())
|
||||
expect(
|
||||
await uploadLegacyStructuredChatRename(useAppStore.getState, current(), 'Legacy paired')
|
||||
await uploadLegacyStructuredChatRename(
|
||||
useAppStore.getState,
|
||||
current(),
|
||||
'Legacy paired',
|
||||
'Legacy paired'
|
||||
)
|
||||
).toBe(false)
|
||||
expect(mock.rpc).not.toHaveBeenCalled()
|
||||
expect(current().customLabel).toBe('Legacy paired')
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { shallow } from 'zustand/shallow'
|
||||
import { getStructuredAgentSessionTabs } from '@/components/native-chat/structured-agent-session-tabs'
|
||||
import { normalizeAgentSessionConversationName } from '../../../shared/agent-session/agent-session-conversation-name'
|
||||
import { subscribeLocalRuntimeCapabilitiesKnown } from './local-runtime-capabilities'
|
||||
import { subscribeRuntimeHostContactRegained } from './runtime-host-contact-regained'
|
||||
import {
|
||||
@@ -27,14 +28,16 @@ export function installLegacyChatRenameMigration(): () => void {
|
||||
const state = useAppStore.getState()
|
||||
const pending = state.workspaceSessionReady
|
||||
? getStructuredAgentSessionTabs(state.unifiedTabsByWorktree).flatMap((tab) => {
|
||||
const alias = tab.customLabel
|
||||
const alias = tab.customLabel ?? ''
|
||||
const name = normalizeAgentSessionConversationName(alias)
|
||||
const owner = legacyStructuredChatRenameOwner(state, tab)
|
||||
const target = structuredAgentSessionTargetForHost(owner)
|
||||
return alias && target
|
||||
return name && target
|
||||
? [
|
||||
{
|
||||
tab,
|
||||
alias,
|
||||
name,
|
||||
target,
|
||||
key: JSON.stringify([owner, tab.worktreeId, tab.id, tab.entityId, alias])
|
||||
}
|
||||
@@ -61,11 +64,11 @@ export function installLegacyChatRenameMigration(): () => void {
|
||||
return
|
||||
}
|
||||
previousCandidates = candidates
|
||||
for (const { tab, alias, key } of pending) {
|
||||
for (const { tab, alias, name, key } of pending) {
|
||||
if (inFlight.has(key)) {
|
||||
continue
|
||||
}
|
||||
const request = uploadLegacyStructuredChatRename(useAppStore.getState, tab, alias)
|
||||
const request = uploadLegacyStructuredChatRename(useAppStore.getState, tab, alias, name)
|
||||
.catch((error: unknown) => console.warn('[structured-chat-rename] migration failed', error))
|
||||
.finally(() => inFlight.delete(key))
|
||||
inFlight.set(key, request)
|
||||
|
||||
@@ -106,7 +106,8 @@ async function submitRename(
|
||||
export async function uploadLegacyStructuredChatRename(
|
||||
get: () => AppState,
|
||||
tab: Tab,
|
||||
alias: string
|
||||
alias: string,
|
||||
name: string
|
||||
): Promise<boolean> {
|
||||
const target = structuredAgentSessionTargetForHost(legacyStructuredChatRenameOwner(get(), tab))
|
||||
if (!target || !(await supportsStructuredAgentSessionRename(target))) {
|
||||
@@ -115,7 +116,7 @@ export async function uploadLegacyStructuredChatRename(
|
||||
try {
|
||||
await callRuntimeRpc<RenameResult>(target, 'agentSession.rename', {
|
||||
sessionId: tab.entityId,
|
||||
name: normalizeAgentSessionConversationName(alias) ?? '',
|
||||
name,
|
||||
legacyAlias: true
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user