Files
orca/src/shared/structured-agent-session-mutation.ts
T
Brennan Benson 094f589977 Floating-workspace chats of every native-chat agent keep links in the folder they started in (#26981)
* fix(native-chat): resolve every registered agent's floating chat to its pinned folder

Drop the unused showStructuredAgentSessionChoice and say what the built-in agent check means.

* fix(native-chat): launch floating OpenCode, Grok, OMP and Pi chats in their pinned folder

The renderer now resolves every floating chat's links, images and skills
against the folder the chat was pinned to. The ACP (OpenCode/Grok/OMP) and
Pi launchers still started the agent in the current floating folder
setting, so after the setting moved and the agent relaunched, the agent ran
in one folder while links resolved against another (and Pi forked its
conversation into the new folder).

Both launchers now use resolveAgentSessionLaunchDirectory, the helper Claude
and Codex already use: launch in the pin, refuse with the existing
"folder this chat ran in no longer exists" message when it is gone, and pin
an unpinned floating record on first launch. Worktree and folder chats are
unchanged.

The tab-directory reader now calls the status bridge's own isStructuredTab
instead of restating its predicate inline.
2026-10-09 16:57:24 -07:00

81 lines
2.7 KiB
TypeScript

import { sha256 } from './sha256'
import type { StructuredAgentSessionFirstMessage } from './structured-agent-session-create'
import type { StructuredAgentId } from './agent-session-provider-handle'
function canonicalize(value: unknown): string {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value ?? null)
}
if (Array.isArray(value)) {
return `[${value.map(canonicalize).join(',')}]`
}
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, entry]) => entry !== undefined)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalize(entry)}`).join(',')}}`
}
export function structuredAgentSessionPayloadFingerprint(input: {
method: string
sessionId: string
fields: Record<string, unknown>
}): string {
const bytes = sha256(
new TextEncoder().encode(
canonicalize({ method: input.method, sessionId: input.sessionId, fields: input.fields })
)
)
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
}
export function structuredAgentSessionDomainFingerprint(input: {
domain: string
sessionId: string
fields: Record<string, unknown>
}): string {
return structuredAgentSessionPayloadFingerprint({
method: input.domain,
sessionId: input.sessionId,
fields: input.fields
})
}
export function structuredAgentSessionCreateFingerprint(input: {
sessionId: string
worktree: string
agent: StructuredAgentId
resumeFrom?: { providerSessionId: string }
tabId?: string
firstMessage?: StructuredAgentSessionFirstMessage
options?: Readonly<Record<string, string>>
}): string {
return structuredAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: input.sessionId,
fields: {
worktree: input.worktree,
agent: input.agent,
// `canonicalize` drops undefined, so a plain create keeps the digest it has always had.
// Adopting a conversation is a different intent and must not replay as a blank create.
resumeFrom: input.resumeFrom,
// The host digests the same field; a retry naming another tab still replays with the
// recorded one, since the host owns the id.
tabId: input.tabId,
firstMessage: input.firstMessage,
options: input.options
}
})
}
export function createStructuredAgentSessionOperationId(
randomUuid: () => string,
now: number = Date.now()
): string {
const timestamp = Math.trunc(now).toString()
const entropy = randomUuid().replaceAll('-', '').toLowerCase()
if (!/^\d{13}$/.test(timestamp) || !/^[0-9a-f]{32}$/.test(entropy)) {
throw new Error('Unable to create a durable operation id')
}
return `${timestamp}-${entropy}`
}