mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
* docs(floating-workspace): plan structured native chat in the floating panel
* feat(floating-workspace): resolve the floating workspace so a structured session can run there
* docs(floating-workspace): plan the unification onto the shared workspace surface
* chore(floating-workspace): keep the unification plan out of the branch
* test(floating-workspace): pin the structured route on capability, not workspace kind
The host half removed the 'floating-workspace' blocker; this pin still asserted it and went red
on the merge. Floating now routes like any workspace, so with capabilities un-negotiated the
resolver answers 'runtime-capability-unknown'.
* feat(floating-workspace): resolve the floating workspace's renderer row without a worktree record
The renderer had no Worktree row for the floating id: markdown creation, tab-bar path entry,
native-chat file links, attachments and model discovery all resolve a workspace through
getKnownWorktreeById and silently fail for it. The host already answers with a synthetic row
minted from the resolved floating directory; this gives the renderer the same single seam.
- The repo slice stores the host-resolved floating directory (floatingWorkspacePath); the panel's
existing cwd resolution now writes it there instead of panel-local state, so there is one copy.
- findKnownWorktreeById mints the shared floatingWorkspaceToWorktree row from that path, local
host only, cached while the directory is unchanged — mirroring the folder-workspace branch it
sits beside.
- No consumer changes needed: every row-dependent path already goes through this resolver.
* feat(tabs): scope editor and browser activation to the owning workspace
Shared editor/browser activation always wrote global selection (activeFileId,
activeBrowserTabId, activeTabType). The floating panel is an overlay above the active
workspace, and its active tab must never become global selection — the invariant
documented in agent-auto-ack-targets.ts. Group-scoped activation is a prerequisite for
rendering the floating workspace through the shared tab-group surface.
- setActiveFile and setActiveBrowserTab take an optional target workspace, mirroring
setActiveTabType: global selection moves only when the target IS the globally active
workspace; per-workspace state always updates. Omitted keeps legacy behavior.
- The shared tab-group activation/creation commands pass their workspace, which is a
no-op for the main surface (hidden surfaces are inert, so commands only fire on the
active workspace) and confines floating activation to floating state.
- Terminal setActiveTab was already owner-scoped; now pinned by test.
* refactor(tab-group): separate the workspace drag scope and group tree from main-window chrome
Preparation for hosting the floating workspace on the shared surface, whose shell owns its own
titlebar tab strip and cannot mount the main chrome:
- WorkspaceTabDragLayer: the one dnd-kit scope for a workspace's strips and tree, extracted from
TabGroupSplitLayout as a render-prop layer so a host can put shell-owned chrome (a titlebar
strip) inside the same drag scope as the tree.
- TabGroupSplitNodeTree: the chrome-free recursive group tree, with an explicit tabStrip
composition point on TabGroupPanel ('attached' keeps today's per-group strip; 'external' hosts
render no per-group strip at all — a hidden one would register duplicate drag sortables).
- WorkspacePaneOverlayLayers: the retained pane hosts extracted from WorktreeSplitSurface so any
tree host mounts the same pane lifecycle stack.
- TerminalPaneOverlayLayer gains ownsNativeChatToggleShortcut, following the existing rule that
only one live workspace registers the chord.
TabGroupSplitLayout and WorktreeSplitSurface recompose these parts; the main surface's DOM is
unchanged.
* feat(floating-workspace): decide the panel's emptiness and layout atomically
An empty floating workspace has no tab-group layout at all (the slice starts empty and
hydration skips tab-less workspaces), while the shared group tree requires one. This model
resolves the two together: the panel shows its empty state exactly until the first tab exists,
and mounts the tree only with a layout plus a live focused group (stored focus, else the group
with an active tab, else the first group — matching the shell chrome's focused-group policy).
* feat(floating-workspace): render the floating panel through the shared workspace surface
The panel previously projected exactly one tab group and hand-rendered its panes, so any tab
that left that group — 'Move Tab to Split' creates a second group — stayed alive in state but
vanished from the panel. The body now mounts the shared group tree and retained pane overlay
stack for the floating workspace id, so every group renders, splits work, and structured chat
finally has a floating surface.
- One drag scope: WorkspaceTabDragLayer wraps the titlebar strip AND the body tree, so exactly
one DndContext owns floating tab drag; strip drops reorder, body-edge drops split. The
floating-only FloatingWorkspaceTabDragContext is deleted.
- The titlebar keeps the panel's single TabBar, now fed by the shared per-group workspace model
of the focused group (tabStrip='external' keeps per-group strips out of the shell), with
workspace-scoped activation commands so global selection never moves.
- Panes render through WorkspacePaneOverlayLayers: terminals gate on the resolved cwd and a
settled viewport exactly as before; browser guests ride the shared retention layer; agent
session tabs appear in the strip and render through the structured overlay.
- The empty state renders exactly until the first resolvable tab exists (stale unified entries
still show it), per the atomic layout/emptiness model.
- Deleted: the single-group projection, the hand-written pane maps, FloatingBrowserSlot, and
the floating pane-handle registry.
Known behavior deltas, both narrowing floating onto main-workspace semantics: Cmd/Ctrl+W with
panel (not terminal) focus closes the active tab rather than one pane of a multi-pane tab, and
a pane-owned close of the last tab no longer arms the panel focus reclaim (strip and shortcut
closes still do).
* test: re-register moved pane-ownership sites with the extraction
The retained overlay stack moved into WorkspacePaneOverlayLayers and the floating chrome model
became a guest-paint consumer; the structured-pane layering pin and the browser-guest retention
census both name files, so they follow the seam.
* fix(floating-workspace): mount the shared group tree in a flex frame so panes own their height
The tree's nodes size themselves as flex items, and the floating shell mounted
them in a block container, so every group body — and every pane anchored to
one via anchor-size() — measured 0px tall. Wrap the tree and overlay layers in
the same 'absolute inset-0 flex' frame the worktree split surface uses, record
the host contract on the tree, pin the frame in the panel's element-tree test,
and add a real-renderer e2e spec that measures group-body and pane boxes for
the single-group and split layouts (jsdom computes no layout, which is how the
0px regression passed 50k tests).
* fix(runtime): import the floating worktree minting the structured create path calls
floatingWorkspaceToResolvedWorktree called floatingWorkspaceToWorktree without
importing it. The module is @ts-nocheck (mechanically split runtime), so tsc
never saw the missing import and it only failed at call time — the structured
createSupport probe for the floating workspace threw a ReferenceError. Add the
import and a test that runs the real method, which fails on the missing import
where the typechecker cannot.
* fix(browser-pane): resolve floating and folder workspace paths in notebook navigation
navigateBrowserPageToUrl looked the workspace up in allWorktrees(), which has
no row for folder or floating workspaces, so their notebook navigations ran
with an undefined workspace path. Use getKnownWorktreeById, the same resolver
every other consumer was moved to.
* fix(tabs): keep a workspace's tab selection out of the main window's global selection
* refactor(floating-workspace): launch and close panes like every other workspace
* refactor(tab-group): let the surface that mounts tab groups describe its own chrome
* refactor(floating-workspace): render the floating panel through the shared workspace surface
* fix(floating-workspace): resolve the floating directory once on the host, and keep the controls' inset
* test(floating-workspace): pin the unified floating panel against the shared surface
* fix(floating-workspace): read the floating directory's paths through the app environment
The runtime now resolves the floating directory itself, and the headless
runtime has no Electron app, so the Electron path lookup threw there.
* fix(floating-workspace): keep the main window's frame and shortcuts out of the floating panel
The shared layout drew the main window's 4px titlebar band and sidebar seam
inside the floating panel, and the chat-view toggle fired in every workspace
on screen, so one key press flipped the main window's tab and the panel's.
* fix(floating-workspace): close floating tabs without taking over the main window
An unsaved floating note's save prompt made the floating panel the main
window's active worktree, and never appeared on a fresh launch because the
workbench that owns the prompt was not mounted. The prompt now shows a
floating note in the panel, and the workbench mounts while the panel holds
tabs.
Every close path now reads the same captured emptied-workspace reaction, so a
floating pane closing itself, its shell exiting, a group close, and an unsaved
note close all keep the panel's keyboard ownership, as a tab close already did.
* test(tab-group): pin that a dirty editor close carries its emptied reaction
* refactor(tabs): one rule for when a selection moves the main window's selection
Opening a file kept a floating-only exception while every other writer checked
whether the tab's workspace is the active one. Every writer now asks the same
question, so opening a file in a background worktree no longer points the main
window's editor selection into that worktree.
* refactor(floating-workspace): drop what the shared surface left unused
The terminal pane's imperative close handle lost its last consumer when the
floating panel stopped keeping its own pane registry, and the floating and
folder workspaces built the same lineage-free resolved worktree twice.
* test(floating-workspace): type test fixtures without casts and drop the removed pane ref
* fix(floating-workspace): keep panel focus when a close lands after the panel emptied
Saving the last floating note closes it after an awaited write, so the panel
could render empty before the close armed its keyboard reclaim, leaving the
intent armed with nothing left to consume it. The panel now re-checks whenever
the intent arms as well as when its tab count changes.
A pinned tab's confirmation took focus before the close read panel ownership;
the dispatcher now captures it before any prompt.
* refactor(floating-workspace): finish what the shared surface left behind
- The floating panel no longer accepts AI vault session drops: a session
cannot resume into it, and its window-level drop handler fired beside the
main window's.
- The last global-selection writers that spelled the active-workspace check
inline now use the shared rule.
- Remove the save-dialog hook and item counter the old floating body used, and
correct comments that said the workbench only mounts once a workspace exists.
* fix(floating-workspace): keep a floating leaf detach from changing the main window's tab type
* fix(floating-workspace): give floating chats their workspace identity and directory
* fix(floating-workspace): read one visibility fact for chat attention and auto-ack
* docs(floating-workspace): correct comments the shared surface made stale
* fix(attention): one on-screen rule for every tab's unread marker, main window and floating panel
* fix(agent-session): pin the folder a session launched in; floating chats resume there or refuse
* fix(native-chat): resolve a floating chat's file links, images and skills in its pinned folder
* fix(agent-session): open a floating chat's terminal handoff in its pinned folder
* fix(agent-session): surface refusal text on chat open, require the launch-directory rule, and keep bookkeeping from gating a launch
* fix(floating-workspace): wait for a floating chat's pinned folder and read panel visibility from the store
* test(floating-workspace): assert the toggle event without a cast
* fix(native-chat): show why a chat could not be opened instead of dropping the host's refusal
* fix(floating-workspace): close chat tabs through shared commands
* fix(i18n): remove obsolete floating editor loading key
* fix(floating-workspace): preserve tab ownership on activation and browser close
* fix(floating-workspace): keep terminal activation and focus reclaim in sync
* fix(readme): point translations at tracked media
* fix(floating-workspace): retain split surface with an empty focused group
* docs(tabs): describe workspace-owned activation accurately
* fix(floating-workspace): finish the main merge and name the pin a launch directory
Port what the merge left on main's newer shapes:
- The floating chat's "folder is gone" refusal becomes a typed reason,
launchFolderMissing, worded like every other start refusal (copy, desktop
words, five translations). Older clients drop the unknown reason and show
the code's generic words.
- The PR's launch-folder tests run on main's journal-database record store;
the attach-level case is a row in the pre-spawn refusal table.
- Tests main added since the PR's base learn the PR's close-queue settling,
workspace-scoped notebook lookup and tab-stamped chat host.
- Move the store's visible-tab-index reads into the tab table, so the record
store stays under the line cap with the pin writer in it.
- Drop a duplicate Worktree import the merge left in a @ts-nocheck file.
Rename the persisted field, the status summary field and the renderer slice
from workspacePath to launchDirectory: it records the folder a session first
launched in, not its workspace's path, and it has not shipped yet.
* Fix floating chat visibility, closes, and directory pinning
* fix(i18n): drop the floating save dialog's strings with the dialog
The floating panel now closes through the shared unsaved-changes prompt, so
its own dialog's six strings had no caller and failed the runtime-catalog
check.
* fix(native-chat): pin floating founders and retain pinned editors
* test: align auto-ack fixtures with unified tab visibility
* test: give the forget-status attach fixture a location
Attach now reads params.location to decide whether a new record is a floating founder, so the partial fixture needs the location real attach params always carry.
* test: type the forget-status attach fixture instead of casting it
The cast tripped the changed-code type-assertion gate.
* fix(native-chat): read a floating chat's model defaults in the folder it was created in
The model picker read the floating setting's current folder, so a chat
pinned to another folder could show that folder's project default.
* test(cross-version): give the stub host the record store the catalog read now checks
* test(claude): give main's thinking-display launch stub the store shape the resolver takes
305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
import { mkdtempSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
|
|
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
|
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
|
|
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
|
import { createCodexStructuredLaunchResolver } from './codex-structured-launch-resolution'
|
|
import { codexStructuredPermissionPolicyForSettings } from './codex-structured-permission-policy'
|
|
import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
|
|
|
|
const SESSION_ID = 'session-1'
|
|
const IDENTITY = { sessionId: SESSION_ID } as Parameters<
|
|
ReturnType<typeof createCodexStructuredLaunchResolver>
|
|
>[0]['identity']
|
|
|
|
async function withPlatform<T>(platform: NodeJS.Platform, run: () => Promise<T>): Promise<T> {
|
|
const original = process.platform
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
|
try {
|
|
return await run()
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: original })
|
|
}
|
|
}
|
|
|
|
function record(overrides: Partial<AgentSessionRecord> = {}): AgentSessionRecord {
|
|
return {
|
|
sessionId: SESSION_ID,
|
|
provider: 'codex',
|
|
location: {
|
|
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
|
wslDistro: null,
|
|
workspaceId: 'workspace-1',
|
|
workspaceKind: 'git-worktree'
|
|
},
|
|
accountHome: { variable: 'CODEX_HOME', path: '/home/work/.codex' },
|
|
providerHandleChain: [],
|
|
...overrides
|
|
} as AgentSessionRecord
|
|
}
|
|
|
|
function resolverFor(
|
|
value: AgentSessionRecord | null,
|
|
resolveWorkspacePath: (workspaceId: string) => Promise<string> = async (id) => `/repos/${id}`,
|
|
resolveRollout: () => Promise<string | null> = async () => null,
|
|
agentDefaultArgs: Record<string, string> = { codex: '' }
|
|
) {
|
|
return createCodexStructuredLaunchResolver({
|
|
store: { getRecord: () => value, pinLaunchDirectory: vi.fn() },
|
|
resolveWorkspacePath,
|
|
resolveCommand: () => '/usr/local/bin/codex',
|
|
resolveRollout,
|
|
isWindowsProcessStartTimeAvailable: () => true,
|
|
resolvePermissionPolicy: () => codexStructuredPermissionPolicyForSettings({ agentDefaultArgs })
|
|
})
|
|
}
|
|
|
|
describe('codex structured launch resolution', () => {
|
|
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')
|
|
const floating = record({
|
|
location: { ...record().location, workspaceId: FLOATING_TERMINAL_WORKTREE_ID },
|
|
launchDirectory: pinned
|
|
})
|
|
|
|
const launch = await resolverFor(floating, resolveWorkspacePath)({ identity: IDENTITY })
|
|
|
|
expect(launch.cwd).toBe(pinned)
|
|
expect(resolveWorkspacePath).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('repairs the first launch directory of an unpinned legacy floating session', async () => {
|
|
const pinLaunchDirectory = vi.fn()
|
|
const resolveLaunch = createCodexStructuredLaunchResolver({
|
|
store: {
|
|
getRecord: () =>
|
|
record({
|
|
location: { ...record().location, workspaceId: FLOATING_TERMINAL_WORKTREE_ID }
|
|
}),
|
|
pinLaunchDirectory
|
|
},
|
|
resolveWorkspacePath: async () => '/floating/start-folder',
|
|
resolveCommand: () => '/usr/local/bin/codex',
|
|
isWindowsProcessStartTimeAvailable: () => true
|
|
})
|
|
|
|
await expect(resolveLaunch({ identity: IDENTITY })).resolves.toMatchObject({
|
|
cwd: '/floating/start-folder'
|
|
})
|
|
expect(pinLaunchDirectory).toHaveBeenCalledExactlyOnceWith(SESSION_ID, '/floating/start-folder')
|
|
})
|
|
|
|
it('launches the app server in the workspace and account home the record pinned', async () => {
|
|
const launch = await resolverFor(record())({ identity: IDENTITY })
|
|
|
|
expect(launch).toEqual({
|
|
command: '/usr/local/bin/codex',
|
|
args: ['app-server'],
|
|
cwd: '/repos/workspace-1',
|
|
codexHome: '/home/work/.codex',
|
|
resumeThreadId: null,
|
|
// Every launch now carries a posture; neither one is left for config.toml to decide.
|
|
permissionPolicy: { approvalPolicy: 'on-request', sandbox: 'workspace-write' }
|
|
})
|
|
})
|
|
|
|
it('passes a Windows .cmd path containing cmd syntax directly to the safe spawn layer', async () => {
|
|
const command = String.raw`C:\Users\r&d\npm-prefix\codex.cmd`
|
|
|
|
await withPlatform('win32', async () => {
|
|
const resolveLaunch = createCodexStructuredLaunchResolver({
|
|
store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() },
|
|
resolveWorkspacePath: async () => String.raw`C:\workspaces\orca`,
|
|
resolveCommand: () => command,
|
|
isWindowsProcessStartTimeAvailable: () => true
|
|
})
|
|
|
|
await expect(resolveLaunch({ identity: IDENTITY })).resolves.toMatchObject({
|
|
command,
|
|
args: ['app-server']
|
|
})
|
|
})
|
|
})
|
|
|
|
it('fails closed before resolving a Windows launch without creation-time proof', async () => {
|
|
await withPlatform('win32', async () => {
|
|
const resolveWorkspacePath = vi.fn(async () => String.raw`C:\workspaces\orca`)
|
|
const resolveLaunch = createCodexStructuredLaunchResolver({
|
|
store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() },
|
|
resolveWorkspacePath,
|
|
isWindowsProcessStartTimeAvailable: () => false
|
|
})
|
|
|
|
await expect(resolveLaunch({ identity: IDENTITY })).rejects.toThrow(
|
|
'Windows process creation-time proof'
|
|
)
|
|
expect(resolveWorkspacePath).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
it('resumes the last thread this session actually proved, not one a caller names', async () => {
|
|
const launch = await resolverFor(
|
|
record({
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle, so the link's other fields stay unset.
|
|
providerHandleChain: [
|
|
{ handle: codexProviderHandle('thread-old') },
|
|
{ handle: codexProviderHandle('thread-current') }
|
|
] as AgentSessionRecord['providerHandleChain']
|
|
})
|
|
)({ identity: IDENTITY })
|
|
|
|
expect(launch.resumeThreadId).toBe('thread-current')
|
|
})
|
|
|
|
it('lets only a thread this session created be superseded when Codex never saved it', async () => {
|
|
const link = (
|
|
origin: AgentSessionProviderHandleLink['origin'],
|
|
mintedAtFence: number
|
|
): AgentSessionProviderHandleLink => ({
|
|
linkId: `link-${mintedAtFence}`,
|
|
handle: codexProviderHandle('t'),
|
|
origin,
|
|
mintedAtFence,
|
|
observedAt: 1
|
|
})
|
|
const chainFor = (origin: 'created' | 'resumed' | 'adopted') =>
|
|
origin === 'resumed' ? [link('created', 1), link('resumed', 2)] : [link(origin, 1)]
|
|
|
|
const created = await resolverFor(record({ providerHandleChain: chainFor('created') }))({
|
|
identity: IDENTITY
|
|
})
|
|
expect(created).toMatchObject({ resumeThreadId: 't', supersedeIfUnsaved: true })
|
|
for (const origin of ['resumed', 'adopted'] as const) {
|
|
const launch = await resolverFor(record({ providerHandleChain: chainFor(origin) }))({
|
|
identity: IDENTITY
|
|
})
|
|
expect(launch.resumeThreadId).toBe('t')
|
|
expect(launch).not.toHaveProperty('supersedeIfUnsaved')
|
|
}
|
|
const fresh = await resolverFor(record())({ identity: IDENTITY })
|
|
expect(fresh).not.toHaveProperty('supersedeIfUnsaved')
|
|
})
|
|
|
|
// Agent Permissions is the only thing derived from the arguments field. app-server owns it on
|
|
// the thread RPC rather than through the interactive CLI's process flags.
|
|
it('resolves the bypass posture as app-server thread policy', async () => {
|
|
const launch = await resolverFor(record(), undefined, undefined, {
|
|
codex: '--dangerously-bypass-approvals-and-sandbox --model gpt-5.6-sol'
|
|
})({ identity: IDENTITY })
|
|
|
|
expect(launch.args).toEqual(['app-server'])
|
|
expect(launch.permissionPolicy).toEqual({
|
|
approvalPolicy: 'never',
|
|
sandbox: 'danger-full-access'
|
|
})
|
|
})
|
|
|
|
it('bypasses approvals for a profile that never opened Agent settings', async () => {
|
|
const launch = await resolverFor(record(), undefined, undefined, {})({ identity: IDENTITY })
|
|
|
|
expect(launch.args).toEqual(['app-server'])
|
|
expect(launch.permissionPolicy).toEqual({
|
|
approvalPolicy: 'never',
|
|
sandbox: 'danger-full-access'
|
|
})
|
|
})
|
|
|
|
// Stated, not omitted: app-server resolves an absent field through the mirrored config.toml,
|
|
// so a Manual session on a home carrying `approval_policy = "never"` never prompted at all.
|
|
it('states the approval posture under Manual', async () => {
|
|
const launch = await resolverFor(record())({ identity: IDENTITY })
|
|
|
|
expect(launch.args).toEqual(['app-server'])
|
|
expect(launch.permissionPolicy).toEqual({
|
|
approvalPolicy: 'on-request',
|
|
sandbox: 'workspace-write'
|
|
})
|
|
})
|
|
|
|
// A thread opened on the configured default and then given a turn on the saved model reads to
|
|
// Codex as a model switch, and it injects the saved model's whole prompt a second time.
|
|
it('opens the thread on the model the record saved', async () => {
|
|
const launch = await resolverFor(
|
|
record({ options: { model: 'gpt-chosen', effort: 'high', fastMode: 'false' } })
|
|
)({ identity: IDENTITY })
|
|
|
|
expect(launch.model).toBe('gpt-chosen')
|
|
})
|
|
|
|
// The configured CLI arguments are a terminal concern: a durable record written before they
|
|
// stopped being read must not smuggle one back into app-server's argv.
|
|
it("ignores the record's durable launch arguments", async () => {
|
|
const launch = await resolverFor(
|
|
record({ launchArgs: ['--profile', 'review', '-c', 'model_reasoning_effort=high'] })
|
|
)({ identity: IDENTITY })
|
|
|
|
expect(launch.args).toEqual(['app-server'])
|
|
})
|
|
|
|
it('pins resume to the rollout file that proved the durable thread', async () => {
|
|
const resolveRollout = vi.fn(async () => '/home/work/.codex/sessions/rollout.jsonl')
|
|
const launch = await resolverFor(
|
|
record({
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle, so the link's other fields stay unset.
|
|
providerHandleChain: [
|
|
{ handle: codexProviderHandle('thread-current') }
|
|
] as AgentSessionRecord['providerHandleChain']
|
|
}),
|
|
async (id) => `/repos/${id}`,
|
|
resolveRollout
|
|
)({ identity: IDENTITY })
|
|
|
|
expect(resolveRollout).toHaveBeenCalledWith('/home/work/.codex', 'thread-current')
|
|
expect(launch.resumePath).toBe('/home/work/.codex/sessions/rollout.jsonl')
|
|
})
|
|
|
|
it('refuses a session pinned to another host rather than starting a second writer here', async () => {
|
|
await expect(
|
|
resolverFor(
|
|
record({
|
|
location: { ...record().location, executionHostId: 'ssh:build-box' }
|
|
} as Partial<AgentSessionRecord>)
|
|
)({ identity: IDENTITY })
|
|
).rejects.toThrow(/local host/)
|
|
})
|
|
|
|
it('refuses a WSL session, which is a separate filesystem and process namespace', async () => {
|
|
await expect(
|
|
resolverFor(record({ location: { ...record().location, wslDistro: 'Ubuntu' } }))({
|
|
identity: IDENTITY
|
|
})
|
|
).rejects.toThrow(/local host/)
|
|
})
|
|
|
|
it('refuses a record this adapter does not speak for', async () => {
|
|
await expect(
|
|
resolverFor(record({ provider: 'claude' } as Partial<AgentSessionRecord>))({
|
|
identity: IDENTITY
|
|
})
|
|
).rejects.toThrow(/is a claude session/)
|
|
await expect(
|
|
resolverFor(
|
|
record({ accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/.claude' } })
|
|
)({
|
|
identity: IDENTITY
|
|
})
|
|
).rejects.toThrow(/CODEX_HOME/)
|
|
})
|
|
|
|
it('refuses to launch for a session the store has no record of', async () => {
|
|
await expect(resolverFor(null)({ identity: IDENTITY })).rejects.toThrow(/no durable/)
|
|
})
|
|
|
|
it('surfaces a workspace that no longer resolves instead of falling back to a default cwd', async () => {
|
|
await expect(
|
|
resolverFor(record(), async () => {
|
|
throw new Error('workspace-1 is gone')
|
|
})({ identity: IDENTITY })
|
|
).rejects.toThrow('workspace-1 is gone')
|
|
})
|
|
})
|