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
4 changes: 3 additions & 1 deletion config/scripts/vitest-sqlite-database-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -196,5 +196,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [
'src/main/runtime/structured-worker-at-rest.test.ts',
'src/main/ssh/ssh-remote-orca-cli.test.ts',
'src/main/ssh/ssh-remote-orchestration-compatibility.test.ts',
'tests/e2e/completed-worker-retirement-resume.unit.test.ts'
'tests/e2e/completed-worker-retirement-resume.unit.test.ts',
'tests/e2e/cross-version-wire/agent-session-clear-old-client.unit.test.ts',
'tests/e2e/cross-version-wire/agent-session-clear-old-tab-mirror.unit.test.ts'
]
2 changes: 2 additions & 0 deletions config/scripts/vitest-sqlite-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { SQLITE_DATABASE_RUNTIME_INCLUDE } from './vitest-sqlite-database-runtim
// Real SQLite fixtures keep Node publication and close semantics.
export const SQLITE_RUNTIME_INCLUDE = [
...SQLITE_DATABASE_RUNTIME_INCLUDE,
'src/main/native-chat/agent-session-wire/structured-agent-session-clear-queued-command.test.ts',
'src/main/acp/acp-structured-sign-in.test.ts',
'src/main/acp/acp-structured-auth.test.ts',
'src/main/acp/acp-timeline-turn-failures.test.ts',
Expand Down Expand Up @@ -124,6 +125,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts',
Expand Down
24 changes: 24 additions & 0 deletions mobile/src/session/MobileNativeChatMessage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,30 @@ describe('MobileNativeChatMessage', () => {
const textIn = (node: ReactTestInstance): string[] =>
node.findAllByType('Text' as never).map((text) => String(text.children.join('')))

it('renders the shared clear boundary as one muted divider with prior history retained', () => {
const message = projectStructuredItemToNativeChat({
itemId: 'clear',
revision: 1,
sequence: 5,
observedAt: 5,
body: {
kind: 'status',
text: 'Context cleared',
presentation: 'context-cleared',
contextClear: { operationId: 'clear', afterFence: 3, clearedAt: 5 }
}
})!
const tree = render(message)
expect(textIn(tree.root)).toEqual(['Context cleared'])
expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0)
expect(
tree.root.findAll(
(node) => String(node.type) === 'View' && node.props.style === styles.contextBoundary
)
).toHaveLength(1)
expect(styles.contextBoundary.borderTopColor).toBe(colors.borderSubtle)
})

it.each(['system', 'user'] as const)(
'renders a %s host notice as selectable muted text rather than a markdown answer',
(role) => {
Expand Down
13 changes: 13 additions & 0 deletions mobile/src/session/MobileNativeChatMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isAgentSessionProviderContextBoundary } from '../../../src/shared/agent-session-provider-context'
import { MobileSelectableText as Text } from '../components/MobileSelectableText'
import { memo, useCallback, useContext, useState } from 'react'
import { Image, Text as NativeText, View } from 'react-native'
Expand Down Expand Up @@ -57,6 +58,18 @@ function Prose({
onLongPress?: () => void
}): React.JSX.Element | null {
if (isTextBlock(block)) {
if (isAgentSessionProviderContextBoundary(block.contextClear)) {
return (
<View style={styles.contextBoundary}>
<Text
selectable={INLINE_TEXT_SELECTION}
style={[styles.hostNotice, { fontSize: TEXT_SIZE * fontScale }]}
>
{block.text}
</Text>
</View>
)
}
if (isAgentSessionHostStatusPresentation(block.presentation)) {
return (
<Text
Expand Down
5 changes: 5 additions & 0 deletions mobile/src/session/mobile-native-chat-message-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export const styles = StyleSheet.create({
lineHeight: TEXT_SIZE + 6,
fontWeight: '500'
},
contextBoundary: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle,
paddingTop: spacing.sm
},
hostNotice: {
color: colors.textMuted,
fontSize: TEXT_SIZE,
Expand Down
60 changes: 60 additions & 0 deletions src/main/acp/acp-structured-context-clear.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { expect, it } from 'vitest'
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
import { ACP_LAUNCH_SPECS } from './acp-launch-specs'
import { createAcpStructuredLaunchResolver } from './acp-structured-launch-resolution'
import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home'

it.each(ACP_LAUNCH_SPECS)(
'$agent starts a new session without probing pre-clear history',
async (spec) => {
const base = agentSessionRecordFixture()
const record = {
...base,
provider: spec.agent,
accountHome:
'accountLocatorKind' in spec.account.pin
? { kind: 'opencode' as const, locator: { kind: 'unmanaged' as const } }
: { variable: spec.account.pin.accountHomeVariable, path: '/fixture/account' },
providerContextBoundary: { operationId: 'clear', afterFence: 2, clearedAt: 100 },
providerHandleChain: []
}
const account =
spec.agent === 'opencode'
? openCodeAcpAccountBinding(() => ({
list: () => {
throw new Error('launch must not change account selection')
},
restoreOriginalEnvironment: () => {},
environmentForAccount: () => {
throw new Error('unmanaged fixture must stay unmanaged')
}
}))
: spec.account
const resolve = createAcpStructuredLaunchResolver(
{ ...spec, account },
{
store: { getRecord: () => record },
readJournal: () => {
throw new Error('pre-clear history must not be sampled')
},
resolveWorkspacePath: async () => '/repo/worktree',
resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/test' }),
resolveCommand: () => `/fixture/${spec.command}`,
probeVersion: async () => true,
inheritedEnv: {}
}
)
const launch = await resolve({
identity: {
sessionId: record.sessionId,
workspaceId: record.location.workspaceId,
hostId: record.location.executionHostId,
agent: spec.agent,
providerHandle: null
}
})
expect(launch.resume).toBeNull()
expect(launch.spec.dialect).toBe(spec.dialect)
expect(launch.args).toEqual(spec.args({ fullAccess: false, pluginDir: null }))
}
)
12 changes: 12 additions & 0 deletions src/main/acp/acp-structured-launch-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,18 @@ describe('ACP launch resolution', () => {
expect(searched[0]).toContain('/home/user/.grok-work/bin')
})

it('creates a new ACP session after clear instead of probing the old session', async () => {
const value = grokRecord({
providerContextBoundary: { operationId: 'clear', afterFence: 2, clearedAt: 100 },
providerHandleChain: []
})
const readJournal = () => {
throw new Error('old history must not be sampled for resume')
}
const launch = await resolver(value, false, readJournal).resolve({ identity })
expect(launch.resume).toBeNull()
})

it('asks the agent to approve everything only under full access', async () => {
const launch = await resolver(grokRecord(), true).resolve({ identity })
expect(launch.args).toContain('--always-approve')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) {
const resting = {
child: null,
params: { provider: 'claude' },
journal: { threadGoal: () => null, contextUsage: () => null }
journal: { threadGoal: () => null, contextUsage: () => null, context: { floor: () => null } }
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members.
const context = {
Expand Down
45 changes: 45 additions & 0 deletions src/main/claude/claude-structured-launch-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,51 @@ describe('claude structured launch resolution', () => {
expect(first.env).toMatchObject({ [CLAUDE_SESSION_STATE_EVENTS_ENV]: '1' })
})

it.each([false, true])(
'starts only the clear context, recovering its saved transcript: %s',
async (saved) => {
const boundary = { operationId: 'clear-one', afterFence: 2, clearedAt: 100 }
const value = record({
providerContextBoundary: boundary,
providerHandleChain: []
})
const hasTranscript = vi.fn(async () => saved)
const launch = await resolverFor(
value,
undefined,
false,
undefined,
hasTranscript
)({ identity: identityAt('old-leaf') })
const expected = claudeSessionIdForOrcaSession(SESSION_ID, boundary.operationId)
expect(launch.providerSessionId).toBe(expected)
expect(expected).not.toBe(claudeSessionIdForOrcaSession(SESSION_ID))
expect(launch.continuesChain).toBe(false)
expect(launch.resumeLeafUuid).toBeNull()
expect(launch.resumesTranscript).toBe(saved)
expect(launch.options).toMatchObject(saved ? { resume: expected } : { sessionId: expected })
expect(hasTranscript).toHaveBeenCalledWith(
expect.objectContaining({ providerSessionId: expected })
)
const retry = await resolverFor(
value,
undefined,
false,
undefined,
hasTranscript
)({ identity: IDENTITY })
expect(retry.providerSessionId).toBe(expected)
const next = await resolverFor(
{ ...value, providerContextBoundary: { ...boundary, operationId: 'clear-two' } },
undefined,
false,
undefined,
async () => false
)({ identity: IDENTITY })
expect(next.providerSessionId).not.toBe(expected)
}
)

it('resumes the durable chain head by session id and carries its leaf as bookkeeping', async () => {
const launch = await resolverFor(
record({
Expand Down
16 changes: 9 additions & 7 deletions src/main/claude/claude-structured-launch-resolution.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { activeProviderContext } from '../../shared/agent-session-provider-context'
import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router'
import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home'
import type {
Options as ClaudeAgentSdkOptions,
PermissionMode
} from '@anthropic-ai/claude-agent-sdk'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import {
agentSessionProviderHandleChainHead,
agentSessionProviderHandleRoot
} from '../../shared/agent-session-provider-handle'
import { agentSessionProviderHandleRoot } from '../../shared/agent-session-provider-handle'
import { claudeProviderHandleLeafUuid } from '../../shared/agent-session-provider-handle-encoding'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env'
Expand Down Expand Up @@ -256,7 +254,8 @@ export function createClaudeStructuredLaunchResolver(
)
}
// A Claude record's chain holds only Claude handles; the attach admission refuses anything else.
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle ?? null
const active = activeProviderContext(record)
const head = active.head?.handle ?? null
if (
head &&
(!identity.providerHandle ||
Expand All @@ -267,7 +266,10 @@ export function createClaudeStructuredLaunchResolver(
}
const providerSessionId = head
? head.nativeId
: claudeSessionIdForOrcaSession(identity.sessionId)
: claudeSessionIdForOrcaSession(
identity.sessionId,
active.pendingClear ? record.providerContextBoundary?.operationId : undefined
)
const continuesChain = head !== null
const cwd = await resolveAgentSessionLaunchDirectory(deps, record)
const sources = await resolveClaudeChildEnvSources(deps)
Expand All @@ -288,7 +290,7 @@ export function createClaudeStructuredLaunchResolver(
// one exits; launch that id fresh instead. With a transcript, `--session-id` would collide.
const leafUuid = head ? claudeProviderHandleLeafUuid(head) : null
const resumes = async (claudeConfigDir: string): Promise<boolean> =>
head !== null &&
(head !== null || active.pendingClear) &&
(await claudeLaunchResumesTranscript({
router,
leafUuid,
Expand Down
10 changes: 8 additions & 2 deletions src/main/claude/claude-structured-session-id.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { createHash } from 'node:crypto'

/** A stable UUID (v4 layout) for the Claude conversation behind an Orca chat. */
export function claudeSessionIdForOrcaSession(sessionId: string): string {
const bytes = createHash('sha256').update(`orca-claude:${sessionId}`).digest().subarray(0, 16)
export function claudeSessionIdForOrcaSession(
sessionId: string,
clearOperationId?: string
): string {
const identity = clearOperationId
? `orca-claude:${sessionId}:context-clear:${clearOperationId}`
: `orca-claude:${sessionId}`
const bytes = createHash('sha256').update(identity).digest().subarray(0, 16)
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80
const hex = bytes.toString('hex')
Expand Down
15 changes: 15 additions & 0 deletions src/main/codex/codex-structured-launch-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,21 @@ describe('codex structured launch resolution', () => {
}
)

it('starts a new thread after clear without resolving the old rollout', async () => {
const resolveRollout = vi.fn(async () => '/old/rollout.jsonl')
const value = record({
providerContextBoundary: { operationId: 'clear', afterFence: 2, clearedAt: 100 },
providerHandleChain: []
})
const launch = await resolverFor(
value,
undefined,
resolveRollout
)({ identity: { ...IDENTITY, providerHandle: codexProviderHandle('old-thread') } })
expect(launch.resumeThreadId).toBeNull()
expect(resolveRollout).not.toHaveBeenCalled()
})

it('resumes a floating session in its pinned folder, not the current floating setting', async () => {
const pinned = mkdtempSync(join(tmpdir(), 'orca-codex-floating-'))
const resolveWorkspacePath = vi.fn(async () => '/floating/current-setting')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ describe('claims written with the message', () => {
).toEqual([UPLOAD_A, UPLOAD_B].sort())
})

it("refuses a client's draft naming a gone upload, and the /clear carry claims for its new chat", async () => {
it("refuses a gone upload and keeps a waiting card's attachment claim across clear", async () => {
const journal = await open()
const missing = message({ type: 'text', text: `@${join(storeRoot, UPLOAD_B, 'a.txt')}` })
const refused = await journal.queuedMessages
Expand All @@ -233,18 +233,15 @@ describe('claims written with the message', () => {
hostInstance: 'p',
requireAttachments: true
})
const replacement = await open('session-d')
await replacement.queuedMessages.insert({
messageId: 'draft-1',
body,
fingerprint: 'fp-d',
hostInstance: 'p',
carriedFrom: IDENTITY.sessionId
})
expect(claims()).toEqual([
{ upload_id: UPLOAD_A, session_id: IDENTITY.sessionId },
{ upload_id: UPLOAD_A, session_id: 'session-d' }
])
await journal.context.clear(
{ operationId: 'clear', afterFence: 0, clearedAt: 1000 },
{ write: () => {}, committed: () => {} },
'caller-clear'
)
await journals.closeAll()
const reopened = await open()
expect(reopened.queuedMessages.get('draft-1')).toMatchObject({ body, carriedFrom: null })
expect(claims()).toEqual([{ upload_id: UPLOAD_A, session_id: IDENTITY.sessionId }])
})

it("does not refuse a draft's conversion: the draft claimed its uploads when it was written", async () => {
Expand Down
Loading
Loading