Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/plans/2026-09-26-undo-close-derived-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Undo Close keeps a derived TLDR identity (#1347)

## Problem
Undo Close respawns a closed agent with `tldrIdentity: meta.tldrIdentity`. An agent whose identity is DERIVED has no explicit field. That happens when reporting domains are on but main created the agent, or its metadata arrived after it acquired TLDR or Goal. The rule is `tldrIdentityForSession`: the identity is the session id. Undo Close passes `undefined` for such an agent, so `spawn` mints a fresh UUID: the restored agent gets a new TLDR/Goal identity, and Agent Analytics (#1342 keys by it) splits its time. Reload Agents already passes `tldrIdentityForSession(oldId, meta)`.

## Decision
The Undo Close respawn uses `tldrIdentityForSession(closedSessionId, meta)`, the same rule as reload. `respawn` now takes the closed id; every caller has it (a single pane's entry, a project member).

## Test
`undoCloseFailure.renderer.test.tsx`: a closed agent with a reporting domain and no explicit identity is respawned with its closed session id as `tldrIdentity`; an explicit identity is still passed through unchanged. Red on main.
14 changes: 10 additions & 4 deletions src/renderer/src/workspace/hook/actions/undoClose.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { tldrIdentityForSession } from '@renderer/features/tldr/identity'
import { carryOrchestrationParents, carryWorkflowRuns, handOverGoalLoops, stopGoalLoops } from '@renderer/workspace/hook/actions/successorCarry'
import { carriedRelationships } from '@renderer/workspace/idRemap'
import { sessionDisplayTitle } from '@renderer/workspace/sessionDisplayTitle'
Expand Down Expand Up @@ -195,7 +196,7 @@ export function useUndoCloseAction(
// when main refused because the cwd is gone (see RespawnOutcome). `spawned`
// says whether a backend now exists that a bail-out must kill.
const respawn = useCallback(
async (meta: SessionMeta): Promise<RespawnOutcome> => {
async (meta: SessionMeta, closedSessionId: SessionId): Promise<RespawnOutcome> => {
const kind: SessionKind = meta.kind ?? DEFAULT_PROVIDER
if (kind === 'extension-view') {
return { sessionId: crypto.randomUUID() as SessionId, spawned: false }
Expand All @@ -217,7 +218,12 @@ export function useUndoCloseAction(
// undo-restored transcript silently lose tools. The effective list is
// deliberately not restored — the restored session is a NEW provider
// process, so it resolves those choices against current Settings.
tldrIdentity: meta.tldrIdentity,
// The same rule as Reload Agents (#1347): an identity can be
// DERIVED (reporting domains on, no explicit field), and then it
// is the closed session id. Passing only the explicit field gave
// such an agent a fresh identity: new TLDR/Goal claims, and its
// analytics split in two.
tldrIdentity: tldrIdentityForSession(closedSessionId, meta),
builtInMcpOverrides: sessionMcpOverrides(meta),
})
return { sessionId, spawned: true }
Expand Down Expand Up @@ -260,7 +266,7 @@ export function useUndoCloseAction(
if (projectId === undefined) return 'stale'
if (!refs.stateRef.current.tabs.some(tab => tab.id === projectId)) return 'stale'

const respawned = await respawn(meta)
const respawned = await respawn(meta, entry.sessionId)
if (respawned === 'folder-missing') {
// WHY 'stale' and not 'retryable-failure' (#1264 R2-1): the entry
// would be pushed back and re-fail forever — a worktree removed after
Expand Down Expand Up @@ -374,7 +380,7 @@ export function useUndoCloseAction(
// missing session could corrupt, so the gentler rule covers everyone.
let foldersMissing = 0
for (const member of entry.sessions) {
const respawned = await respawn(member.meta)
const respawned = await respawn(member.meta, member.sessionId)
if (respawned === 'folder-missing') foldersMissing++
if (typeof respawned === 'string') continue
idMap.set(member.sessionId, respawned.sessionId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,47 @@ it('does not push a deleted-folder member back with its group when a sibling fai
expect(harness.showToast).toHaveBeenLastCalledWith('Could not restore "ProviderDown": Session failed to start. Check provider setup and retry.', 8000)
harness.unmount()
})

// #1347: an agent whose TLDR/Goal identity is DERIVED (a reporting domain on,
// no explicit field) is identified by its session id (tldrIdentityForSession).
// Undo Close passed only the explicit field, so the restored agent got a fresh
// identity and Agent Analytics split its time; Reload Agents already used the
// derived rule.
it('restores an agent with its derived TLDR identity, and an explicit one unchanged', async () => {
const { state, refs, writer } = setup()
refs.undoStackRef.current.pop()
refs.undoStackRef.current.push({
type: 'session', closedAt: Date.now(), sessionId: 'explicit-pane',
sessionMeta: { cwd: '/projects/agent-code', kind: 'claude', title: 'Explicit', projectId: 'tab-parent', joinedAt: 1, tldrIdentity: 'tldr-explicit', builtInMcpDomains: ['tldr'] },
})
refs.undoStackRef.current.push({
type: 'session', closedAt: Date.now(), sessionId: 'derived-pane',
sessionMeta: { cwd: '/projects/agent-code', kind: 'claude', title: 'Derived', projectId: 'tab-parent', joinedAt: 1, builtInMcpDomains: ['tldr'] },
})
const spawn = vi.fn().mockResolvedValueOnce('restored-derived').mockResolvedValueOnce('restored-explicit')
const harness = mount(state, refs, writer, spawn)
await act(async () => { await harness.undo() })
await act(async () => { await harness.undo() })
expect(spawn.mock.calls.map(([, opts]) => (opts as { tldrIdentity?: string }).tldrIdentity)).toEqual(['derived-pane', 'tldr-explicit'])
harness.unmount()
})

// #1356 review b: the project-restore call site passes each MEMBER's closed id;
// a fresh id there survived the single-pane case above.
it('restores each agent of a project with its own derived TLDR identity', async () => {
const { state, refs, writer } = setup()
refs.undoStackRef.current.pop()
const meta = { cwd: '/projects/agent-code', kind: 'claude' as const, projectId: 'closed-tab', joinedAt: 1, builtInMcpDomains: ['goal'] as never }
refs.undoStackRef.current.push({
type: 'tab', closedAt: Date.now(), tab: { id: 'closed-tab', title: 'agent-code' }, tabIndex: 0,
// Two DERIVED members (#1356 verification c): with only one, passing the
// first member's closed id for every member went unnoticed.
sessions: [{ sessionId: 'derived-member', meta }, { sessionId: 'second-derived-member', meta }, { sessionId: 'explicit-member', meta: { ...meta, tldrIdentity: 'tldr-explicit-member' } }],
})
const spawn = vi.fn().mockResolvedValueOnce('restored-one').mockResolvedValueOnce('restored-two').mockResolvedValueOnce('restored-three')
const harness = mount(state, refs, writer, spawn)
await act(async () => { await harness.undo() })
expect(spawn.mock.calls.map(([, opts]) => (opts as { tldrIdentity?: string }).tldrIdentity)).toEqual(['derived-member', 'second-derived-member', 'tldr-explicit-member'])
harness.unmount()
})

Loading