Files
orca/src/main/runtime/agent-session-conversation-clear-commit.test.ts
T
Brennan Benson 56025f21a4 Give native chats a fresh automatic name after /clear (#32287)
* Chat renames are saved as the chat's name, so Session History shows them after the tab closes

Renaming a native chat tab only changed that window's tab. Session History, phones and other windows read the host's saved name, so the rename vanished from History once the tab closed or Orca restarted.

A rename now goes to the host that owns the chat (agentSession.rename) and is saved as the chat's one name, the field History, tabs, sidebars and phones already read. The record remembers that the user chose it, so automatic naming never replaces it. A window's pre-upgrade tab rename is uploaded once and only fills a name Orca chose. Hosts without the new capability keep the old tab-only rename. Every native agent's History row now shows the chat's name once it has one.

* Keep saved chat names consistent across readers and reconnects

* Carry qualified chat names into cached readers

* Reuse the history recorder context across platforms

* Use recorded chat ownership for legacy name transfer

* Transfer legacy chat names only from known catalog ownership

* Correct recorder path matching and History subscription order

* Require complete evidence for bridged parity exclusions

* Preserve sparse array lengths in complete recording comparisons

* Initialize History observation before loading workspace context

* Integrate measured History recordings and scoped parity evidence

* fix chat naming retries and certify both replay modes

* fix ambiguous chat rename labels after navigation

* preserve chat rename intent across tab updates

* type dashboard snapshot fixtures without assertions

* fix: align chat name observation with store contracts

* test: update chat name consumer fixtures

* test: distinguish sender naming metadata from status

* Preserve saved Activity chat names across tab focus

* Clarify Activity tab projection identity

* Remove package-manager lockfile churn from main merge

* Refresh automatic chat names after clearing context

* fix(runtime): validate tab property edits before saving chat names

* Preserve naming eligibility and refresh cleared chat titles

* Fix checked naming projection types and logging metadata

* Supply context floor in status-feed journal fixtures

* fix: refresh History ownership after conversation clear

* fix: commit History request identity before refresh effects

* Fix History scan scope typing and integration test placement

* test: cover saved chat name reader contracts

* fix: bound chat naming work through generation settlement

* Remove duplicate History name-transition refresh

* Restore cache option in tab History test

* Consolidate naming test imports

* test: align naming fixtures with checked contracts

* test: name the discovery child fixture contract

* test: initialize writable child fixture streams

* test: account for ownership head and key reads
2026-10-26 00:31:24 -07:00

224 lines
8.2 KiB
TypeScript

import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication'
import { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store'
import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support'
import {
providerContextBoundaryForClear,
type AgentSessionConversationClear
} from './agent-session-conversation-command-record'
import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals'
import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
import type { AgentSessionRecordStore } from './agent-session-record-store'
import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness'
const NOW = 1_800_000_000_000
const SOURCE = 'session-alpha'
const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' }
let directory: string
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-clear-commit-'))
})
afterEach(async () => {
await rm(directory, { recursive: true, force: true })
})
const open = () => openTestAgentSessionRecordStore(directory)
async function storeWithSource(): Promise<AgentSessionRecordStore> {
const store = await open()
await store.reserveOwner({
sessionId: SOURCE,
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'folder'
},
provider: 'claude',
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' },
launchArgs: ['--flag'],
options: { model: 'sonnet' },
expectedFence: null,
spawnToken: 'spawn-a',
claimKeyId: 'key-1',
handoffOperationId: null,
probe: INDETERMINATE,
operation: {
callerKey: 'client-1',
operationId: `${NOW}-${'1'.repeat(32)}`,
fingerprint: 'fp'
},
now: NOW
})
const fence = store.getRecord(SOURCE)!.lease.runtimeFence
await store.commitProcessIdentity({
sessionId: SOURCE,
fence,
process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken: 'spawn-a' },
now: NOW
})
await store.proveOwner({
sessionId: SOURCE,
fence,
link: {
linkId: 'pre-clear-proof',
origin: 'created',
mintedAtFence: fence,
observedAt: NOW,
handle: claudeProviderHandle('pre-clear-provider-context', null)
},
now: NOW
})
await store.setSessionTabVisibility(SOURCE, true, 'tab-alpha')
return store
}
function clear(fence: number): AgentSessionConversationClear {
return {
sessionId: SOURCE,
fence,
now: NOW + 5,
command: {
command: 'clear',
state: 'completed',
phase: 'committed',
runtimeFence: fence,
operationId: `${NOW}-${'2'.repeat(32)}`,
callerKey: 'client-1'
}
}
}
async function prepare(released = true) {
const store = await storeWithSource()
if (released) {
await store.evictProvenDeadOwner({
sessionId: SOURCE,
expectedFence: store.getRecord(SOURCE)!.lease.runtimeFence,
probe: { outcome: 'exit-observed' },
now: NOW + 1
})
}
const journal = new AgentSessionJournal({
database: openTestJournalHostDatabase(directory),
identity: {
sessionId: SOURCE,
workspaceId: 'workspace-1',
hostId: 'local',
agent: 'claude',
providerHandle: null
},
now: () => NOW + 5
})
await journal.open()
const completed = clear(store.getRecord(SOURCE)!.lease.runtimeFence)
const operation = {
callerKey: completed.command.callerKey,
operationId: completed.command.operationId
}
await store.admitOperation({ ...operation, fingerprint: 'clear', now: NOW })
const append = (input = completed) =>
journal.context.clear(
providerContextBoundaryForClear(input),
store.conversationReceipts.clear(() => input, operation)
)
return { store, journal, completed, operation, append }
}
async function queueClearCards(journal: AgentSessionJournal) {
for (const messageId of ['first', 'command', 'last']) {
await journal.queuedMessages.insert({
messageId,
body: {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: messageId }],
...(messageId === 'command' ? { command: { name: 'compact' } } : {})
}
})
}
}
describe('clear transaction', () => {
it.each([false, true])('clears only automatic names (user=%s)', async (user) => {
const { store, journal, completed, operation, append } = await prepare()
await (user
? store.setUserConversationName(SOURCE, 'Saved name', 'rename')
: store.setConversationName(SOURCE, 'Saved name'))
const before = store.getRecord(SOURCE)!
expect(before.providerHandleChain).toHaveLength(1)
await queueClearCards(journal)
const epoch = journal.cursor().epoch
await append()
const reopened = await open()
expect(reopened.listRecords()).toHaveLength(1)
expect(reopened.getRecord(SOURCE)).toMatchObject({
providerContextBoundary: providerContextBoundaryForClear(completed),
providerHandleChain: [],
lease: { provenHandleLinkId: null },
accountHome: before.accountHome,
options: before.options,
conversationCommand: completed.command
})
expect(reopened.getRecord(SOURCE)?.conversationName).toBe(user ? 'Saved name' : undefined)
const context = providerContextBoundaryForClear(completed).operationId
expect(
await reopened.compareAndSetConversationName(SOURCE, 'Saved name', null, context)
).toEqual(user ? null : expect.objectContaining({ conversationName: 'Saved name' }))
expect(
await reopened.compareAndSetConversationName(SOURCE, 'Stale result', 'Saved name', null)
).toBeNull()
expect(
await reopened.compareAndSetConversationName(SOURCE, 'Saved name', 'Saved name', null)
).toBeNull()
expect((await open()).getRecord(SOURCE)?.conversationName).toBe('Saved name')
expect(reopened.getSessionTabId(SOURCE)).toBe('tab-alpha')
expect(JSON.stringify(reopened.getRecord(SOURCE))).not.toContain('pre-clear-provider-context')
expect(journal.cursor().epoch).toBe(epoch)
expect(journal.queuedMessages.get('command')).toMatchObject({ state: 'withdrawn' })
expect(journal.queuedMessages.pauses()).toMatchObject([{ messageIds: ['first', 'last'] }])
expect(
reopened.getOperationRow(operation.callerKey, operation.operationId)?.outcome.status
).toBe('succeeded')
})
it('rolls the divider and receipt back when the fence moved', async () => {
const { store, journal, completed, operation, append } = await prepare()
await queueClearCards(journal)
await store.setConversationName(SOURCE, 'Saved name')
const before = journal.cursor()
const previousChain = store.getRecord(SOURCE)!.providerHandleChain
const refused = await append({ ...completed, fence: completed.fence + 1 }).catch(
(error: unknown) => error
)
expect(isAgentSessionRefusalError(refused)).toBe(true)
expect(journal.cursor()).toEqual(before)
expect((await open()).getRecord(SOURCE)?.conversationName).toBe('Saved name')
expect(
journal.queuedMessages.list().map(({ messageId, state }) => ({ messageId, state }))
).toEqual(['first', 'command', 'last'].map((messageId) => ({ messageId, state: 'waiting' })))
expect(journal.queuedMessages.pauses()).toEqual([])
expect((await open()).getRecord(SOURCE)?.providerContextBoundary).toBeUndefined()
expect((await open()).getRecord(SOURCE)?.providerHandleChain).toEqual(previousChain)
expect(store.getOperationRow(operation.callerKey, operation.operationId)?.outcome.status).toBe(
'pending'
)
})
it('refuses a boundary while ownership is still reserved', async () => {
const { store, journal, append } = await prepare(false)
const before = journal.cursor()
await expect(append()).rejects.toMatchObject({
refusal: { code: 'agent_session_ownership_unknown' }
})
expect(journal.cursor()).toEqual(before)
expect(store.getRecord(SOURCE)?.providerContextBoundary).toBeUndefined()
})
})