mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/loop18519
This commit is contained in:
@@ -14,6 +14,7 @@ const projectDir = resolve(__dirname, '..')
|
||||
const requireFromProject = createRequire(join(projectDir, 'package.json'))
|
||||
|
||||
const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
|
||||
'@anthropic-ai/claude-agent-sdk',
|
||||
'@electron-toolkit/utils',
|
||||
'@linear/sdk',
|
||||
'@parcel/watcher',
|
||||
@@ -56,6 +57,11 @@ const ELECTRON_ARCHITECTURE_BY_ENUM = {
|
||||
4: 'universal'
|
||||
}
|
||||
const PACKAGED_NATIVE_ARCHITECTURES = new Set(['ia32', 'x64', 'arm', 'arm64'])
|
||||
const PACKAGED_MAIN_REQUIRED_FILES = [
|
||||
'out/main/index.js',
|
||||
'out/main/agent-hooks/managed-agent-hook-controls.js'
|
||||
]
|
||||
const PACKAGED_MAIN_SOURCE_RE = /^out\/main\/.+\.js$/
|
||||
const TYPE_DECLARATION_ARTIFACT_RE = /\.d\.(?:c|m)?ts(?:\.map)?$/
|
||||
const JS_SOURCE_MAP_ARTIFACT_RE = /\.(?:c|m)?js\.map$/
|
||||
const VERSIONED_ONNXRUNTIME_DYLIB_RE = /^libonnxruntime\.\d[\d.]*\.dylib$/
|
||||
@@ -223,22 +229,39 @@ function verifyPackagedMainRuntimeDeps(resourcesDir, asar = require('@electron/a
|
||||
return
|
||||
}
|
||||
|
||||
const mainFiles = ['out/main/index.js', 'out/main/agent-hooks/managed-agent-hook-controls.js']
|
||||
const entries = asar.listPackage(asarPath)
|
||||
const missing = new Set()
|
||||
|
||||
for (const file of mainFiles) {
|
||||
const entry = findAsarEntry(entries, file)
|
||||
if (!entry) {
|
||||
for (const file of PACKAGED_MAIN_REQUIRED_FILES) {
|
||||
if (!findAsarEntry(entries, file)) {
|
||||
throw new Error(`Packaged main file ${file} was not found in ${asarPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
const missing = new Set()
|
||||
// Why every emitted main file rather than the entry points alone: rolldown hoists
|
||||
// modules shared by two entries into out/main/chunks, so an entry's own bare imports
|
||||
// move out from under a fixed file list and silently stop being checked.
|
||||
for (const entry of entries) {
|
||||
if (!PACKAGED_MAIN_SOURCE_RE.test(normalizeAsarEntryPath(entry))) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Why: @electron/asar lists entries with host separators; Windows returns
|
||||
// backslashes, and extractFile expects that same host-style path.
|
||||
const internalPath = entry.replace(/^[\\/]+/, '')
|
||||
const source = asar.extractFile(asarPath, internalPath).toString('utf8')
|
||||
for (const match of source.matchAll(/require\(["']([^"']+)["']\)/g)) {
|
||||
const specifier = match[1]
|
||||
// Why the lookbehind: Orca has its own registry methods named `require`, so a
|
||||
// minified `registry.require('some-id')` must not read as a bare specifier.
|
||||
// Why it readmits `...`: a dot that ends a spread is not member access, and
|
||||
// the two error directions are not symmetric -- a false positive fails the
|
||||
// release build loudly, a false negative is this guard going blind.
|
||||
// Known limit: a specifier inside an embedded source string counts too, and
|
||||
// ssh-relay-deploy's remote probe names node-pty that way. A remote-only
|
||||
// dependency added to that script would fail desktop packaging here; telling
|
||||
// the two apart needs a parser, not a wider pattern.
|
||||
for (const match of source.matchAll(
|
||||
/(?:(?<![.\w])|(?<=\.\.\.))(?:require|import)\s*\(\s*(["'`])([^"'`$]+)\1\s*\)/g
|
||||
)) {
|
||||
const specifier = match[2]
|
||||
if (!isPackagedExternalSpecifier(specifier)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -44,6 +44,135 @@ describe('packaged runtime resources', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies literal dynamic imports from the packaged main bundle', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-dynamic-imports-'))
|
||||
try {
|
||||
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
|
||||
|
||||
// The first is the exact shape oxc emits for the memoized SDK import in a
|
||||
// shipped build; the second is the spaced variant the pattern also accepts.
|
||||
const sources = new Map([
|
||||
[
|
||||
'out/main/index.js',
|
||||
'let p=null;function q(){return p??=import(`@anthropic-ai/claude-agent-sdk`),p}'
|
||||
],
|
||||
[
|
||||
'out/main/agent-hooks/managed-agent-hook-controls.js',
|
||||
'import (`@anthropic-ai/claude-agent-sdk`)'
|
||||
]
|
||||
])
|
||||
const asar = {
|
||||
listPackage: () => [...sources.keys()].map((entry) => `/${entry}`),
|
||||
extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8')
|
||||
}
|
||||
|
||||
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).toThrow(
|
||||
/@anthropic-ai\/claude-agent-sdk/
|
||||
)
|
||||
|
||||
await mkdir(join(resourcesDir, 'node_modules', '@anthropic-ai', 'claude-agent-sdk'), {
|
||||
recursive: true
|
||||
})
|
||||
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow()
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('still fails when a required packaged main entry is missing entirely', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-missing-entry-'))
|
||||
try {
|
||||
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
|
||||
|
||||
const asar = {
|
||||
listPackage: () => ['/out/main/index.js'],
|
||||
extractFile: () => Buffer.from('', 'utf8')
|
||||
}
|
||||
|
||||
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).toThrow(
|
||||
/managed-agent-hook-controls\.js was not found/
|
||||
)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies bare imports that rolldown hoisted into a shared main chunk', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-chunk-imports-'))
|
||||
try {
|
||||
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
|
||||
|
||||
// The entry points themselves carry no specifier; only the shared chunk does.
|
||||
const sources = new Map([
|
||||
['out/main/index.js', ''],
|
||||
['out/main/agent-hooks/managed-agent-hook-controls.js', ''],
|
||||
['out/main/chunks/managed-agent-hook-controls-CWf8D-KR.js', 'require(`jsonc-parser`)']
|
||||
])
|
||||
// Real listPackage emits directory nodes too, and extractFile throws on them,
|
||||
// so the `.js` anchor is load-bearing -- keep the mock able to catch that.
|
||||
const directories = ['/out', '/out/main', '/out/main/chunks']
|
||||
const asar = {
|
||||
listPackage: () => [...directories, ...[...sources.keys()].map((entry) => `/${entry}`)],
|
||||
extractFile: (_asarPath, internalPath) => {
|
||||
const source = sources.get(internalPath)
|
||||
if (source === undefined) {
|
||||
throw new Error(`Expected to find file at: ${internalPath} but found a directory`)
|
||||
}
|
||||
return Buffer.from(source, 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).toThrow(/jsonc-parser/)
|
||||
|
||||
await mkdir(join(resourcesDir, 'node_modules', 'jsonc-parser'), { recursive: true })
|
||||
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow()
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('reads a spread require, whose leading dots are not member access', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-spread-require-'))
|
||||
try {
|
||||
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
|
||||
|
||||
const sources = new Map([
|
||||
['out/main/index.js', 'const all=[...require("jsonc-parser")]'],
|
||||
['out/main/agent-hooks/managed-agent-hook-controls.js', '']
|
||||
])
|
||||
const asar = {
|
||||
listPackage: () => [...sources.keys()].map((entry) => `/${entry}`),
|
||||
extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8')
|
||||
}
|
||||
|
||||
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).toThrow(/jsonc-parser/)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores member calls onto Orca methods that are themselves named require', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-member-require-'))
|
||||
try {
|
||||
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
|
||||
|
||||
// electron-sidecar-tab-registry and browser-execution-host-grant-registry both
|
||||
// expose require(key); a literal key must never read as a packaged specifier.
|
||||
const sources = new Map([
|
||||
['out/main/index.js', 'registry.require("public-a");grants.require(`host-key`)'],
|
||||
['out/main/agent-hooks/managed-agent-hook-controls.js', 'state.import("android-sdk")']
|
||||
])
|
||||
const asar = {
|
||||
listPackage: () => [...sources.keys()].map((entry) => `/${entry}`),
|
||||
extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8')
|
||||
}
|
||||
|
||||
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow()
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes host-specific asar entry separators', () => {
|
||||
expect(findAsarEntry(['\\out\\main\\index.js'], 'out/main/index.js')).toBe(
|
||||
'\\out\\main\\index.js'
|
||||
@@ -134,6 +263,15 @@ describe('packaged runtime resources', () => {
|
||||
expect(packagedTargets).toContain(join('node_modules', 'proper-lockfile'))
|
||||
})
|
||||
|
||||
it('includes the Claude agent SDK in every desktop package plan', () => {
|
||||
for (const platform of ['darwin', 'linux', 'win32']) {
|
||||
const packagedTargets = createPackagedRuntimeNodeModuleResources(platform).map(
|
||||
(resource) => resource.to
|
||||
)
|
||||
expect(packagedTargets).toContain(join('node_modules', '@anthropic-ai', 'claude-agent-sdk'))
|
||||
}
|
||||
})
|
||||
|
||||
it('prunes non-target @parcel/watcher architecture subpackages', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-'))
|
||||
try {
|
||||
|
||||
+67
-1
@@ -28,7 +28,8 @@ afterEach(async () => {
|
||||
|
||||
function attachParams(
|
||||
operationId: string,
|
||||
expectedRuntimeFence: number | null
|
||||
expectedRuntimeFence: number | null,
|
||||
options?: Readonly<Record<string, string>>
|
||||
): AgentSessionAttachParams {
|
||||
const params: AgentSessionAttachParams = {
|
||||
envelope: {
|
||||
@@ -47,6 +48,7 @@ function attachParams(
|
||||
agent: 'codex',
|
||||
accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' },
|
||||
runtimeKind: 'native',
|
||||
...(options ? { options } : {}),
|
||||
providerHandle: { kind: 'codex', threadId: 'legacy-thread' }
|
||||
}
|
||||
return {
|
||||
@@ -101,6 +103,70 @@ function expectSettledAttachLease(record: AgentSessionRecord | null): void {
|
||||
}
|
||||
|
||||
describe('structured session acquisition options', () => {
|
||||
it('persists create defaults before the first provider acquisition', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-create-options-'))
|
||||
const store = await AgentSessionRecordStore.open({
|
||||
directory: join(root, 'store'),
|
||||
hostId: 'local'
|
||||
})
|
||||
const sessionAdapter = adapter({ origin: 'created' })
|
||||
const options = { model: 'gpt-5.6-sol', effort: 'medium' }
|
||||
|
||||
const created = await performAttach({
|
||||
store,
|
||||
adapter: sessionAdapter,
|
||||
journalRoot: root,
|
||||
authority: {
|
||||
spawnToken: 'spawn-a',
|
||||
claimKeyId: 'key-1',
|
||||
handoffOperationId: CREATE_OPERATION,
|
||||
probe: { outcome: 'reservation-unused' }
|
||||
},
|
||||
callerKey: 'client-1',
|
||||
params: attachParams(CREATE_OPERATION, null, options),
|
||||
now: () => NOW,
|
||||
onAttached: () => {}
|
||||
})
|
||||
|
||||
expect(created).toMatchObject({ ok: true })
|
||||
expect(sessionAdapter.acquire).toHaveBeenCalledWith(expect.objectContaining({ options }))
|
||||
expect(store.getRecord(SESSION)?.options).toEqual(options)
|
||||
})
|
||||
|
||||
it('replays a create retried after the host re-resolved different options', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-create-retry-'))
|
||||
const store = await AgentSessionRecordStore.open({
|
||||
directory: join(root, 'store'),
|
||||
hostId: 'local'
|
||||
})
|
||||
const sessionAdapter = adapter({ origin: 'created' })
|
||||
const attempt = async (options: Readonly<Record<string, string>>, spawnToken: string) =>
|
||||
performAttach({
|
||||
store,
|
||||
adapter: sessionAdapter,
|
||||
journalRoot: root!,
|
||||
authority: {
|
||||
spawnToken,
|
||||
claimKeyId: 'key-1',
|
||||
handoffOperationId: CREATE_OPERATION,
|
||||
probe: { outcome: 'reservation-unused' }
|
||||
},
|
||||
callerKey: 'client-1',
|
||||
params: attachParams(CREATE_OPERATION, null, options),
|
||||
now: () => NOW,
|
||||
onAttached: () => {}
|
||||
})
|
||||
|
||||
const created = await attempt({ model: 'gpt-5.6-sol', effort: 'medium' }, 'spawn-a')
|
||||
// Why: the user may reselect a model between an unknown-outcome create and the
|
||||
// retry that reuses its operation id; the retry must replay, not conflict.
|
||||
const retried = await attempt({ model: 'gpt-5.5', effort: 'high' }, 'spawn-b')
|
||||
|
||||
expect(created).toMatchObject({ ok: true })
|
||||
expect(retried).toMatchObject({ ok: true })
|
||||
expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5.6-sol', effort: 'medium' })
|
||||
})
|
||||
|
||||
it('persists provider options before proving a resumed legacy record', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-'))
|
||||
const storeDir = join(root, 'store')
|
||||
|
||||
@@ -54,6 +54,8 @@ export type AgentSessionAttachParams = {
|
||||
agent: AgentSessionHandleProvider
|
||||
accountHome: AgentSessionAccountHome
|
||||
runtimeKind: AgentSessionOwnerRuntimeKind
|
||||
/** Host-resolved defaults for a create-by-intent; remote attach schemas do not accept them. */
|
||||
options?: Readonly<Record<string, string>>
|
||||
/** Omitted only for create-by-intent; the adapter proves the durable handle. */
|
||||
providerHandle?: Exclude<AgentSessionProviderHandle, { kind: 'opaque' }>
|
||||
}
|
||||
@@ -70,7 +72,10 @@ export type AgentSessionAttachAuthority = {
|
||||
|
||||
/** The fields that define WHICH session this call would attach to. Deliberately
|
||||
* excludes the spawn token and the probe: those differ between a first attempt
|
||||
* and its retry, and a retry must replay rather than conflict. */
|
||||
* and its retry, and a retry must replay rather than conflict. `options` is
|
||||
* excluded for the same reason — it is the session's initial state, not its
|
||||
* identity, and the host re-resolves it from settings the user may have changed
|
||||
* between an unknown-outcome attempt and its retry. */
|
||||
export function attachFingerprintFields(params: AgentSessionAttachParams): Record<string, unknown> {
|
||||
return {
|
||||
location: params.location,
|
||||
@@ -191,6 +196,7 @@ export function reserveRequestFor(input: {
|
||||
location: params.location,
|
||||
provider: params.provider,
|
||||
accountHome: params.accountHome,
|
||||
...(params.options ? { options: params.options } : {}),
|
||||
...(authority.launchArgs ? { launchArgs: authority.launchArgs } : {}),
|
||||
...(authority.launchEnv ? { launchEnv: authority.launchEnv } : {}),
|
||||
runtimeKind: params.runtimeKind,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
agentSessionExecutionLocationsEqual,
|
||||
isAgentSessionLaunchArgs,
|
||||
isAgentSessionLaunchEnv,
|
||||
isAgentSessionOptions,
|
||||
type AgentSessionAccountHome,
|
||||
type AgentSessionExecutionLocation,
|
||||
type AgentSessionLaunchArgs,
|
||||
@@ -43,6 +44,8 @@ export type AgentSessionReserveRequest = {
|
||||
launchArgs?: AgentSessionLaunchArgs
|
||||
/** Current launch input validated here but never written to the durable record. */
|
||||
launchEnv?: AgentSessionLaunchEnv
|
||||
/** Initial provider options persisted before the first process is acquired. */
|
||||
options?: Readonly<Record<string, string>>
|
||||
runtimeKind: AgentSessionReservation['runtimeKind']
|
||||
/** Null when the session does not exist yet; otherwise the fence the caller last observed. */
|
||||
expectedFence: number | null
|
||||
@@ -131,6 +134,9 @@ export function applyAgentSessionReservation(
|
||||
if (request.launchArgs && !isAgentSessionLaunchArgs(request.launchArgs)) {
|
||||
throw new Error('agent_session_launch_args_invalid')
|
||||
}
|
||||
if (request.options && !isAgentSessionOptions(request.options)) {
|
||||
throw new Error('agent_session_options_invalid')
|
||||
}
|
||||
const reservation: AgentSessionReservation = {
|
||||
runtimeKind: request.runtimeKind,
|
||||
spawnToken:
|
||||
@@ -186,6 +192,7 @@ function createAgentSessionRecord(
|
||||
provider: request.provider,
|
||||
providerHandleChain: [],
|
||||
accountHome: request.accountHome,
|
||||
...(request.options ? { options: { ...request.options } } : {}),
|
||||
...(request.launchArgs ? { launchArgs: [...request.launchArgs] } : {}),
|
||||
createdAt: request.now,
|
||||
updatedAt: request.now,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-option
|
||||
import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach'
|
||||
import { getSystemCodexHomePath } from '../codex/codex-home-paths'
|
||||
import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults'
|
||||
import { resolveStructuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults'
|
||||
import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime'
|
||||
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
|
||||
import { homedir } from 'node:os'
|
||||
@@ -183,6 +184,10 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca
|
||||
}
|
||||
const settings = this.requireStore().getSettings()
|
||||
const launchEnv = resolveTuiAgentLaunchEnv(input.agent, settings.agentDefaultEnv)
|
||||
const options = resolveStructuredLaunchSeedOptions(
|
||||
settings.nativeChatSessionOptions,
|
||||
input.agent
|
||||
)
|
||||
const location = await this.resolveStructuredAgentSessionLocation(input.worktree)
|
||||
const workspacePath = (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path
|
||||
return {
|
||||
@@ -199,6 +204,7 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca
|
||||
variable: input.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME',
|
||||
path: await resolveAccountHomePath({ workspacePath, launchEnv, location })
|
||||
},
|
||||
...(options ? { options } : {}),
|
||||
runtimeKind: 'native'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,15 @@ describe('structured agent-session create intent', () => {
|
||||
const runtime = new OrcaRuntimeService(
|
||||
{
|
||||
getSettings: () => ({
|
||||
agentDefaultEnv: { codex: { CODEX_HOME: '/configured/home' } }
|
||||
agentDefaultEnv: { codex: { CODEX_HOME: '/configured/home' } },
|
||||
nativeChatSessionOptions: {
|
||||
codex: {
|
||||
model: 'gpt-5.6-sol',
|
||||
valuesByModel: {
|
||||
'gpt-5.6-sol': { effort: 'medium', fastMode: true, personality: 'concise' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} as never,
|
||||
undefined,
|
||||
@@ -51,6 +59,7 @@ describe('structured agent-session create intent', () => {
|
||||
variable: 'CODEX_HOME',
|
||||
path: '/accounts/selected/home'
|
||||
})
|
||||
expect(intent.options).toEqual({ model: 'gpt-5.6-sol', effort: 'medium' })
|
||||
})
|
||||
|
||||
it('pins the configured Claude launch home without Codex launch preparation', async () => {
|
||||
@@ -60,6 +69,12 @@ describe('structured agent-session create intent', () => {
|
||||
getSettings: () => ({
|
||||
agentDefaultEnv: {
|
||||
claude: { CLAUDE_CONFIG_DIR: '/configured/claude-home' }
|
||||
},
|
||||
nativeChatSessionOptions: {
|
||||
claude: {
|
||||
model: 'opus',
|
||||
valuesByModel: { opus: { effort: 'high', fastMode: true } }
|
||||
}
|
||||
}
|
||||
})
|
||||
} as never,
|
||||
@@ -101,6 +116,7 @@ describe('structured agent-session create intent', () => {
|
||||
variable: 'CLAUDE_CONFIG_DIR',
|
||||
path: '/configured/claude-home'
|
||||
})
|
||||
expect(intent.options).toEqual({ model: 'opus', effort: 'high' })
|
||||
})
|
||||
|
||||
it('uses the managed Claude launch home before falling back to ~/.claude', async () => {
|
||||
|
||||
@@ -200,6 +200,10 @@ function dispatcher(runtimeOverrides: Record<string, unknown> = {}): RpcDispatch
|
||||
variable: params.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME',
|
||||
path: params.agent === 'claude' ? '/host/.claude' : '/host/.codex'
|
||||
},
|
||||
options:
|
||||
params.agent === 'claude'
|
||||
? { model: 'opus', effort: 'high' }
|
||||
: { model: 'gpt-5.6-sol', effort: 'medium' },
|
||||
runtimeKind: 'native'
|
||||
})),
|
||||
publishStructuredAgentSessionTab: vi.fn()
|
||||
@@ -482,7 +486,8 @@ describe('method routing', () => {
|
||||
expect(hostCalls.attach).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' }
|
||||
accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' },
|
||||
options: { model: 'gpt-5.6-sol', effort: 'medium' }
|
||||
})
|
||||
)
|
||||
expect(hostCalls.attach.mock.calls[0]?.[1]).not.toHaveProperty('providerHandle')
|
||||
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
structuredCallerFor as callerFor,
|
||||
supportsStructuredSessions
|
||||
} from './structured-agent-session-gate'
|
||||
import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach'
|
||||
import {
|
||||
attachFingerprintFields,
|
||||
type AgentSessionAttachParams
|
||||
} from '../../../native-chat/agent-session-wire/structured-agent-session-attach'
|
||||
import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-session-hold'
|
||||
import { STRUCTURED_AGENT_SESSION_REVEAL_METHODS } from './structured-agent-session-reveal'
|
||||
import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal'
|
||||
@@ -111,14 +114,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
|
||||
const hostFingerprint = computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.attach',
|
||||
sessionId: params.envelope.sessionId,
|
||||
fields: {
|
||||
location: resolved.location,
|
||||
provider: resolved.provider,
|
||||
agent: resolved.agent,
|
||||
accountHome: resolved.accountHome,
|
||||
runtimeKind: resolved.runtimeKind,
|
||||
expectedRuntimeFence: null
|
||||
}
|
||||
fields: attachFingerprintFields({ ...resolved, envelope: params.envelope })
|
||||
})
|
||||
await ensureHostInstalled(ctx)
|
||||
const { agent: _resolvedAgent, provider: _resolvedProvider, ...resolvedAttach } = resolved
|
||||
|
||||
@@ -113,20 +113,20 @@ export function CommitNotices({
|
||||
role={createPrIntentNotice.tone === 'destructive' ? 'alert' : 'status'}
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
'mt-1 flex min-w-0 items-center gap-1.5 text-[11px]',
|
||||
'mt-1 flex min-w-0 flex-col items-start gap-1 text-[11px]',
|
||||
createPrIntentNotice.tone === 'destructive'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{/* Why: Create Review blockers carry recovery steps; truncating hides the action the user needs in a narrow sidebar. */}
|
||||
<span className="min-w-0 flex-1 break-words leading-4 [overflow-wrap:anywhere]">
|
||||
<span className="min-w-0 self-stretch break-words leading-4 [overflow-wrap:anywhere]">
|
||||
{createPrIntentNotice.message}
|
||||
</span>
|
||||
{createPrIntentNotice.action === 'settings' && onOpenSourceControlAiSettings ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
|
||||
className="text-left font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
|
||||
onClick={() => onOpenSourceControlAiSettings()}
|
||||
>
|
||||
{translate(
|
||||
|
||||
@@ -30,6 +30,9 @@ describe('resolveAgentLaunchRoute', () => {
|
||||
'routes a supported local %s launch to structured native chat',
|
||||
(agent) => {
|
||||
expect(route({ agent })).toBe('structured-native-chat')
|
||||
expect(route({ agent, initialSessionOptions: { model: 'gpt-5.6-sol' } })).toBe(
|
||||
'structured-native-chat'
|
||||
)
|
||||
expect(
|
||||
route({ agent, launchText: 'explain this change', promptDelivery: 'auto-submit' })
|
||||
).toBe('structured-native-chat')
|
||||
@@ -76,7 +79,6 @@ describe('resolveAgentLaunchRoute', () => {
|
||||
expect(route({ agent: 'openclaude' })).toBe('legacy-native-chat')
|
||||
expect(route({ agent: 'grok' })).toBe('legacy-native-chat')
|
||||
expect(route({ requiresTuiLaunchCustomization: true })).toBe('legacy-native-chat')
|
||||
expect(route({ initialSessionOptions: { model: 'gpt-5.6-sol' } })).toBe('legacy-native-chat')
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -88,15 +88,11 @@ export function resolveAgentLaunchRoute(input: AgentLaunchRoutingInput): AgentLa
|
||||
const projectRuntime = input.projectRuntime
|
||||
const runtimeRefused =
|
||||
projectRuntime?.status === 'repair-required' || projectRuntime?.runtime.kind === 'wsl'
|
||||
const hasInitialSessionOptions = Boolean(
|
||||
input.initialSessionOptions && Object.keys(input.initialSessionOptions).length > 0
|
||||
)
|
||||
const structuredSupported =
|
||||
isAgentSessionHandleProvider(input.agent) &&
|
||||
input.promptDelivery !== 'draft' &&
|
||||
input.workspaceKind !== 'floating' &&
|
||||
input.requiresTuiLaunchCustomization !== true &&
|
||||
!hasInitialSessionOptions &&
|
||||
input.executionHostId === 'local' &&
|
||||
// Windows eligibility is not a client-side platform guess for either provider: the executing
|
||||
// host measures it at create time (agentSession.createSupport), because only that host knows
|
||||
|
||||
@@ -43,7 +43,13 @@ const store = {
|
||||
activeRuntimeEnvironmentId: null,
|
||||
experimentalNativeChat: true,
|
||||
experimentalStructuredNativeChat: true,
|
||||
openAgentTabsInChatByDefault: true
|
||||
openAgentTabsInChatByDefault: true,
|
||||
nativeChatSessionOptions: undefined as
|
||||
| Record<
|
||||
string,
|
||||
{ model?: string; valuesByModel?: Record<string, Record<string, string | boolean>> }
|
||||
>
|
||||
| undefined
|
||||
},
|
||||
projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' as const } }],
|
||||
repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }],
|
||||
@@ -153,6 +159,7 @@ describe('structured chat adoption guard on the launch path', () => {
|
||||
mockToastError.mockReset()
|
||||
hostCapabilities = STRUCTURED_HOST_CAPABILITIES
|
||||
store.settings.openAgentTabsInChatByDefault = true
|
||||
store.settings.nativeChatSessionOptions = undefined
|
||||
})
|
||||
|
||||
it('takes the structured path when the chat-default view is selected', async () => {
|
||||
@@ -175,6 +182,23 @@ describe('structured chat adoption guard on the launch path', () => {
|
||||
expect(mockWaitForAgentReady).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Routing only: the host seeds the saved values, so preservation is pinned there.
|
||||
it('takes the structured path when a Codex model and effort are already saved', async () => {
|
||||
store.settings.nativeChatSessionOptions = {
|
||||
codex: {
|
||||
model: 'gpt-5.6-sol',
|
||||
valuesByModel: { 'gpt-5.6-sol': { effort: 'medium' } }
|
||||
}
|
||||
}
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
|
||||
|
||||
expect(result).toMatchObject({ tabId: null, focusAfterMenuClose: 'structured-session' })
|
||||
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledWith('wt-1', 'codex')
|
||||
expect(mockCreateTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('takes the structured path for Claude, naming Claude as the create provider', async () => {
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ function isAgentSessionAccountHome(value: unknown): value is AgentSessionAccount
|
||||
)
|
||||
}
|
||||
|
||||
function isAgentSessionOptions(value: unknown): value is Record<string, string> {
|
||||
export function isAgentSessionOptions(value: unknown): value is Record<string, string> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clearNativeChatSessionOptionModel,
|
||||
resolveNativeChatSessionOptionDefaults,
|
||||
resolveStructuredLaunchSeedOptions,
|
||||
updateNativeChatSessionOptionDefaults
|
||||
} from './native-chat-session-option-defaults'
|
||||
import type { PersistedNativeChatSessionOptions } from './native-chat-session-options'
|
||||
@@ -74,3 +75,66 @@ describe('resolveNativeChatSessionOptionDefaults', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveStructuredLaunchSeedOptions', () => {
|
||||
const persistedCodex = (
|
||||
valuesByModel: Record<string, Record<string, string | boolean>>
|
||||
): PersistedNativeChatSessionOptions =>
|
||||
({ codex: { model: 'gpt-5.6-sol', valuesByModel } }) as PersistedNativeChatSessionOptions
|
||||
|
||||
it('seeds the saved model and effort a structured create must apply', () => {
|
||||
expect(
|
||||
resolveStructuredLaunchSeedOptions(
|
||||
persistedCodex({ 'gpt-5.6-sol': { effort: 'medium' } }),
|
||||
'codex'
|
||||
)
|
||||
).toEqual({ model: 'gpt-5.6-sol', effort: 'medium' })
|
||||
})
|
||||
|
||||
it('drops ids the providers only accept mid-session', () => {
|
||||
// `fastMode` is a boolean and `personality` is settable only mid-session;
|
||||
// neither belongs in the reservation's Record<string, string>.
|
||||
expect(
|
||||
resolveStructuredLaunchSeedOptions(
|
||||
persistedCodex({
|
||||
'gpt-5.6-sol': { effort: 'high', fastMode: true, personality: 'concise' }
|
||||
}),
|
||||
'codex'
|
||||
)
|
||||
).toEqual({ model: 'gpt-5.6-sol', effort: 'high' })
|
||||
})
|
||||
|
||||
it('drops a seeded id whose persisted value is not a usable string', () => {
|
||||
// settings.json is user-writable, so a non-string `effort` must not reach a
|
||||
// record typed Record<string, string> and be emitted as a turn option.
|
||||
expect(
|
||||
resolveStructuredLaunchSeedOptions(
|
||||
persistedCodex({ 'gpt-5.6-sol': { effort: true } }),
|
||||
'codex'
|
||||
)
|
||||
).toEqual({ model: 'gpt-5.6-sol' })
|
||||
expect(
|
||||
resolveStructuredLaunchSeedOptions(
|
||||
persistedCodex({ 'gpt-5.6-sol': { effort: ' ' } }),
|
||||
'codex'
|
||||
)
|
||||
).toEqual({ model: 'gpt-5.6-sol' })
|
||||
})
|
||||
|
||||
it('seeds nothing when the stored values empty the model out', () => {
|
||||
// `valuesByModel` is merged over the resolved model, so a stored `model` key
|
||||
// can blank it. Emitting `{ model: '' }` fails the record's bounded-string
|
||||
// guard, and that throw is not a wire refusal code — it escapes as a raw
|
||||
// error the client reads as unknown, stranding the launch with no fallback.
|
||||
expect(
|
||||
resolveStructuredLaunchSeedOptions(persistedCodex({ 'gpt-5.6-sol': { model: '' } }), 'codex')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('seeds nothing until a model is picked, so the CLI default survives', () => {
|
||||
expect(resolveStructuredLaunchSeedOptions(undefined, 'codex')).toBeUndefined()
|
||||
expect(
|
||||
resolveStructuredLaunchSeedOptions({ codex: { valuesByModel: {} } }, 'codex')
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,33 @@ export function resolveNativeChatSessionOptionDefaults(
|
||||
return values
|
||||
}
|
||||
|
||||
/** Why only these two: they are the only ids the picker persists into
|
||||
* `nativeChatSessionOptions` that both structured providers also accept as
|
||||
* strings. Claude's `fastMode` is a boolean the durable `Record<string, string>`
|
||||
* record cannot carry, and the providers' remaining keys are settable only
|
||||
* mid-session, never seeded at launch. */
|
||||
const STRUCTURED_LAUNCH_SEED_OPTION_IDS = ['model', 'effort'] as const
|
||||
|
||||
/** The saved selection a structured create seeds into its reservation, narrowed
|
||||
* to the wire-safe string subset the durable record and both providers accept. */
|
||||
export function resolveStructuredLaunchSeedOptions(
|
||||
persisted: PersistedNativeChatSessionOptions | null | undefined,
|
||||
agent: AgentType
|
||||
): Record<string, string> | undefined {
|
||||
const defaults = resolveNativeChatSessionOptionDefaults(persisted, agent)
|
||||
if (!defaults) {
|
||||
return undefined
|
||||
}
|
||||
const seeded: Record<string, string> = {}
|
||||
for (const id of STRUCTURED_LAUNCH_SEED_OPTION_IDS) {
|
||||
const value = defaults[id]
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
seeded[id] = value
|
||||
}
|
||||
}
|
||||
return Object.keys(seeded).length > 0 ? seeded : undefined
|
||||
}
|
||||
|
||||
/** Why: an authoritative probe proved this id gone, and a stale `model` is emitted
|
||||
* verbatim as a launch flag — grok exits fatally on an unknown one. Dropping only
|
||||
* `model` keeps the per-model option values for a later reselect. */
|
||||
|
||||
Executable → Regular
+9
-2
@@ -3,8 +3,15 @@
|
||||
const READY_MARKER = 'GOLDEN_STUB_AGENT_READY'
|
||||
const EXIT_MARKER = 'GOLDEN_STUB_AGENT_EXITED'
|
||||
|
||||
// The interactive fixture does not implement Codex's JSONL app-server API.
|
||||
if (process.argv[2] === 'app-server') {
|
||||
process.stderr.write("error: unrecognized subcommand 'app-server'\n")
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const ESC = '\x1b'
|
||||
const keyboardProtocolMode = process.argv.includes('--keyboard-protocol')
|
||||
const keyboardProtocolAgent = process.argv.includes('--grok') ? 'Grok' : 'Codex'
|
||||
// Both match the bytes after ESC, so the control character stays out of the
|
||||
// pattern: a CSI/SS3 introducer still missing its final byte, and a complete
|
||||
// CSI/SS3 sequence. Shift+Enter is matched before either is consulted.
|
||||
@@ -20,7 +27,7 @@ function render() {
|
||||
const lines = composer.split('\n')
|
||||
const renderedComposer = lines.map((line, index) => `${index === 0 ? '> ' : ' '}${line}`)
|
||||
process.stdout.write(
|
||||
`${keyboardProtocolMode ? '\x1b]0;\u280b Codex is thinking\x07\x1b[>1u' : '\x1b]0;Golden Stub Agent\x07'}${[
|
||||
`${keyboardProtocolMode ? `\x1b]0;\u280b ${keyboardProtocolAgent} is thinking\x07\x1b[>1u` : '\x1b]0;Golden Stub Agent\x07'}${[
|
||||
'\x1b[H\x1b[2JGolden Stub Agent',
|
||||
`[${READY_MARKER}]`,
|
||||
'',
|
||||
@@ -40,7 +47,7 @@ function exitCleanly() {
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(false)
|
||||
}
|
||||
const idleTitle = keyboardProtocolMode ? '\x1b]0;Codex\x07' : ''
|
||||
const idleTitle = keyboardProtocolMode ? `\x1b]0;${keyboardProtocolAgent}\x07` : ''
|
||||
process.stdout.write(`${idleTitle}\x1b[?1049l[${EXIT_MARKER}]\r\n`, () => process.exit(0))
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { readFileSync, existsSync, realpathSync, rmSync } from 'node:fs'
|
||||
import { TEST_REPO_PATH_FILE } from './global-setup'
|
||||
|
||||
export function linkedWorktreePaths(testRepoDir: string): string[] {
|
||||
const root = realpathSync(testRepoDir)
|
||||
const root = realpathSync.native(testRepoDir)
|
||||
const output = execFileSync('git', ['-C', testRepoDir, 'worktree', 'list', '--porcelain'], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
@@ -23,7 +23,7 @@ export function linkedWorktreePaths(testRepoDir: string): string[] {
|
||||
if (!existsSync(recordedPath)) {
|
||||
continue
|
||||
}
|
||||
const canonicalPath = realpathSync(recordedPath)
|
||||
const canonicalPath = realpathSync.native(recordedPath)
|
||||
if (canonicalPath !== root) {
|
||||
linked.add(canonicalPath)
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export function linkedWorktreePaths(testRepoDir: string): string[] {
|
||||
}
|
||||
|
||||
export function cleanupTestRepository(testRepoDir: string): void {
|
||||
const root = realpathSync(testRepoDir)
|
||||
const root = realpathSync.native(testRepoDir)
|
||||
let worktreePaths: string[] = []
|
||||
try {
|
||||
worktreePaths = linkedWorktreePaths(root)
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('E2E global teardown ownership', () => {
|
||||
git(repoPath, ['worktree', 'add', '-b', 'second-owned', secondWorktreePath])
|
||||
|
||||
expect(new Set(linkedWorktreePaths(repoPath))).toEqual(
|
||||
new Set([realpathSync(firstWorktreePath), realpathSync(secondWorktreePath)])
|
||||
new Set([realpathSync.native(firstWorktreePath), realpathSync.native(secondWorktreePath)])
|
||||
)
|
||||
cleanupTestRepository(repoPath)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export function getGoldenStubAgentLaunchEnv(): NodeJS.ProcessEnv {
|
||||
export async function configureGoldenStubAgent(
|
||||
page: Page,
|
||||
options: {
|
||||
agent?: (typeof GOLDEN_STUB_AGENTS)[number]['id']
|
||||
agent?: (typeof GOLDEN_STUB_AGENTS)[number]['id'] | 'grok'
|
||||
agentArgs?: string
|
||||
/** Windows default shell the launch command must survive; ignored elsewhere. */
|
||||
windowsShell?: BuiltInWindowsTerminalShell
|
||||
|
||||
@@ -41,7 +41,7 @@ const BACKUP_EXISTING_STUB_SCRIPT =
|
||||
// The marker is written first so stale-lock recovery only removes a stub this helper wrote.
|
||||
const STAGE_SCRIPT =
|
||||
`mkdir -p /usr/local/bin && : > ${WSL_STUB_STAGED_MARKER} && ` +
|
||||
`printf '#!/bin/sh\\necho GOLDEN_STUB_AGENT_READY\\nexec sleep 3600\\n' > ${WSL_STUB_PATH} && ` +
|
||||
`printf '#!/bin/sh\\nif [ "$1" = app-server ]; then echo "error: unrecognized subcommand app-server" >&2; exit 2; fi\\necho GOLDEN_STUB_AGENT_READY\\nexec sleep 3600\\n' > ${WSL_STUB_PATH} && ` +
|
||||
`chmod 0755 ${WSL_STUB_PATH}`
|
||||
|
||||
// The marker is written before the link so a crashed run over-reports rather than leaks a link.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* The Create PR intent notice pairs a wrapping sentence with a "Source Control
|
||||
* AI settings" link. In a minimum-width sidebar the link must not share the
|
||||
* message's row, or it squeezes the sentence into a one-word-per-line column.
|
||||
*/
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
createStagedCommitMessageChange,
|
||||
openSourceControl,
|
||||
seedCreatePrComposer
|
||||
} from './helpers/source-control-ai-generation'
|
||||
import { RIGHT_SIDEBAR_MIN_WIDTH } from '../../src/renderer/src/components/right-sidebar/right-sidebar-width'
|
||||
|
||||
test.describe('Source Control Create PR intent notice layout', () => {
|
||||
test('keeps the settings link off the message row at the minimum sidebar width', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
const { prWorktreeId, prWorktreePath, primaryBranch } = await seedCreatePrComposer(orcaPage)
|
||||
// A real staged change with no commit draft is what routes the intent run
|
||||
// into the "configure Source Control AI" notice, which carries the link.
|
||||
createStagedCommitMessageChange(prWorktreePath)
|
||||
|
||||
await orcaPage.evaluate(
|
||||
({ prWorktreeId, primaryBranch }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
store.setState((current) => ({
|
||||
// Unconfigured Source Control AI is what routes the run into the
|
||||
// "configure Source Control AI" notice rather than a failure notice.
|
||||
settings: current.settings
|
||||
? { ...current.settings, sourceControlAi: undefined, commitMessageAi: undefined }
|
||||
: current.settings,
|
||||
repos: current.repos.map((repo) => ({ ...repo, sourceControlAi: undefined })),
|
||||
// Blocked-on-push keeps "Create PR" running the intent flow instead of
|
||||
// opening the composer form.
|
||||
getHostedReviewCreationEligibility: async () => ({
|
||||
provider: 'github' as const,
|
||||
review: null,
|
||||
reviewLookupOutcome: 'not_found' as const,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push' as const,
|
||||
nextAction: 'push' as const,
|
||||
defaultBaseRef: primaryBranch,
|
||||
head: 'e2e-secondary'
|
||||
}),
|
||||
fetchHostedReviewForBranch: async () => null,
|
||||
fetchPRForBranch: async () => null,
|
||||
pushBranch: async (worktreeId: string) => {
|
||||
if (worktreeId !== prWorktreeId) {
|
||||
throw new Error(`Create PR intent pushed unexpected worktree ${worktreeId}`)
|
||||
}
|
||||
}
|
||||
}))
|
||||
},
|
||||
{ prWorktreeId, primaryBranch }
|
||||
)
|
||||
|
||||
await openSourceControl(orcaPage, prWorktreeId)
|
||||
await orcaPage.evaluate((minWidth) => {
|
||||
window.__store?.getState().setRightSidebarWidth(minWidth)
|
||||
}, RIGHT_SIDEBAR_MIN_WIDTH)
|
||||
|
||||
const createPr = orcaPage.getByRole('button', { name: 'Create PR' }).first()
|
||||
await expect(createPr).toBeVisible({ timeout: 10_000 })
|
||||
await expect(createPr).toBeEnabled()
|
||||
await createPr.click()
|
||||
|
||||
const notice = orcaPage.locator('#commit-area-create-pr-intent')
|
||||
const settingsLink = notice.getByRole('button', { name: 'Source Control AI settings' })
|
||||
await expect(settingsLink).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
if (process.env.ORCA_PR_INTENT_NOTICE_SCREENSHOT_PATH) {
|
||||
await orcaPage.evaluate(() => document.documentElement.classList.add('dark'))
|
||||
await notice.screenshot({ path: process.env.ORCA_PR_INTENT_NOTICE_SCREENSHOT_PATH })
|
||||
}
|
||||
|
||||
// The layout contract: the link starts below the message's last line.
|
||||
const messageBox = await notice.locator('span').first().boundingBox()
|
||||
const linkBox = await settingsLink.boundingBox()
|
||||
expect(messageBox).not.toBeNull()
|
||||
expect(linkBox).not.toBeNull()
|
||||
expect(linkBox!.y).toBeGreaterThanOrEqual(messageBox!.y + messageBox!.height)
|
||||
// A squeezed message wraps far taller than the ~4 lines this sentence needs.
|
||||
expect(messageBox!.height).toBeLessThan(70)
|
||||
})
|
||||
})
|
||||
@@ -54,13 +54,19 @@ test('resets standard keyboard bytes after a protocol-mode agent exits on ConPTY
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await configureGoldenStubAgent(orcaPage, { agentArgs: '--keyboard-protocol' })
|
||||
await launchGoldenStubAgentFromNewTab(orcaPage)
|
||||
// Grok is the supported native ConPTY exception to Kitty protocol withholding.
|
||||
await configureGoldenStubAgent(orcaPage, {
|
||||
agent: 'grok',
|
||||
agentArgs: '--keyboard-protocol --grok'
|
||||
})
|
||||
await launchGoldenStubAgentFromNewTab(orcaPage, /^Grok(?:\s|$)/i)
|
||||
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
await expect.poll(() => getKittyKeyboardFlags(orcaPage), { timeout: 10_000 }).toBe(1)
|
||||
|
||||
await clearTerminalPtyWriteLog(electronApp)
|
||||
// Kitty flag 1 preserves plain Enter; modified Enter proves CSI-u input.
|
||||
await orcaPage.keyboard.press('Shift+Enter')
|
||||
await orcaPage.keyboard.type('exit')
|
||||
await orcaPage.keyboard.press('Enter')
|
||||
await waitForTerminalOutput(orcaPage, GOLDEN_STUB_EXIT_MARKER, 15_000)
|
||||
@@ -68,7 +74,8 @@ test('resets standard keyboard bytes after a protocol-mode agent exits on ConPTY
|
||||
.filter((entry) => entry.id === ptyId)
|
||||
.map((entry) => entry.data)
|
||||
.join('')
|
||||
expect(protocolWrites.includes('\x1b[13u') || protocolWrites.includes('\x1b[13;1u')).toBe(true)
|
||||
expect(protocolWrites).toContain('\x1b[13;2u')
|
||||
expect(protocolWrites).toContain('\r')
|
||||
await expect.poll(() => getKittyKeyboardFlags(orcaPage), { timeout: 10_000 }).toBe(0)
|
||||
|
||||
await clearTerminalPtyWriteLog(electronApp)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { getFirstWslDistro, useWslRuntimeForActiveProject } from './helpers/wsl-golden-stub-agent'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
execInTerminal,
|
||||
@@ -27,7 +28,9 @@ test.describe('Windows terminal env and shell identity', () => {
|
||||
await waitForTerminalOutput(orcaPage, marker, 15_000)
|
||||
})
|
||||
|
||||
test('Windows tab icons stay pinned to the shell used at tab creation', async ({ orcaPage }) => {
|
||||
test('native Windows tab icons stay pinned to the effective shell at tab creation', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
test.skip(process.platform !== 'win32', 'Windows shell icons only render on Windows')
|
||||
|
||||
const tabIds = await orcaPage.evaluate(() => {
|
||||
@@ -41,10 +44,11 @@ test.describe('Windows terminal env and shell identity', () => {
|
||||
throw new Error('No active worktree')
|
||||
}
|
||||
|
||||
// Native project ownership makes a global WSL shell fall back to PowerShell.
|
||||
store.setState({
|
||||
settings: { ...state.settings!, terminalWindowsShell: 'wsl.exe' }
|
||||
})
|
||||
const wslTab = store.getState().createTab(worktreeId, undefined, undefined, {
|
||||
const fallbackTab = store.getState().createTab(worktreeId, undefined, undefined, {
|
||||
activate: false
|
||||
})
|
||||
|
||||
@@ -55,33 +59,68 @@ test.describe('Windows terminal env and shell identity', () => {
|
||||
activate: false
|
||||
})
|
||||
|
||||
return { wslTabId: wslTab.id, cmdTabId: cmdTab.id }
|
||||
return { fallbackTabId: fallbackTab.id, cmdTabId: cmdTab.id }
|
||||
})
|
||||
|
||||
const tabSnapshot = await orcaPage.evaluate(({ wslTabId, cmdTabId }) => {
|
||||
const tabSnapshot = await orcaPage.evaluate(({ fallbackTabId, cmdTabId }) => {
|
||||
const state = window.__store!.getState()
|
||||
const tabs = Object.values(state.tabsByWorktree).flat()
|
||||
return {
|
||||
wslShell: tabs.find((tab) => tab.id === wslTabId)?.shellOverride,
|
||||
fallbackShell: tabs.find((tab) => tab.id === fallbackTabId)?.shellOverride,
|
||||
cmdShell: tabs.find((tab) => tab.id === cmdTabId)?.shellOverride
|
||||
}
|
||||
}, tabIds)
|
||||
|
||||
expect(tabSnapshot).toEqual({
|
||||
wslShell: 'wsl.exe',
|
||||
fallbackShell: 'powershell.exe',
|
||||
cmdShell: 'cmd.exe'
|
||||
})
|
||||
|
||||
const wslTab = orcaPage.locator(
|
||||
`[data-testid="sortable-tab"][data-tab-id="${tabIds.wslTabId}"]`
|
||||
const fallbackTab = orcaPage.locator(
|
||||
`[data-testid="sortable-tab"][data-tab-id="${tabIds.fallbackTabId}"]`
|
||||
)
|
||||
const cmdTab = orcaPage.locator(
|
||||
`[data-testid="sortable-tab"][data-tab-id="${tabIds.cmdTabId}"]`
|
||||
)
|
||||
await expect(wslTab).toBeVisible()
|
||||
await expect(fallbackTab).toBeVisible()
|
||||
await expect(cmdTab).toBeVisible()
|
||||
|
||||
await expect(wslTab.locator('[data-shell-icon]')).toHaveAttribute('data-shell-icon', 'wsl.exe')
|
||||
await expect(fallbackTab.locator('[data-shell-icon]')).toHaveAttribute(
|
||||
'data-shell-icon',
|
||||
'powershell.exe'
|
||||
)
|
||||
await expect(cmdTab.locator('[data-shell-icon]')).toHaveAttribute('data-shell-icon', 'cmd.exe')
|
||||
})
|
||||
|
||||
test('WSL project tab icons retain runtime ownership across global shell changes', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
test.skip(process.platform !== 'win32', 'WSL shell icons require Windows')
|
||||
const distro = await getFirstWslDistro(orcaPage)
|
||||
test.skip(!distro, 'WSL icon coverage requires an installed distro')
|
||||
await useWslRuntimeForActiveProject(orcaPage, distro!)
|
||||
|
||||
const tabIds = await orcaPage.evaluate(async () => {
|
||||
const store = window.__store!
|
||||
const worktreeId = store.getState().activeWorktreeId!
|
||||
const ids: string[] = []
|
||||
for (const shell of ['powershell.exe', 'cmd.exe'] as const) {
|
||||
await store.getState().updateSettings({ terminalWindowsShell: shell })
|
||||
ids.push(
|
||||
store.getState().createTab(worktreeId, undefined, undefined, { activate: false }).id
|
||||
)
|
||||
}
|
||||
return ids
|
||||
})
|
||||
const shells = await orcaPage.evaluate((ids) => {
|
||||
const tabs = Object.values(window.__store!.getState().tabsByWorktree).flat()
|
||||
return ids.map((id) => tabs.find((tab) => tab.id === id)?.shellOverride)
|
||||
}, tabIds)
|
||||
expect(shells).toEqual(['wsl.exe', 'wsl.exe'])
|
||||
for (const id of tabIds) {
|
||||
const tab = orcaPage.locator(`[data-testid="sortable-tab"][data-tab-id="${id}"]`)
|
||||
await expect(tab).toBeVisible()
|
||||
await expect(tab.locator('[data-shell-icon]')).toHaveAttribute('data-shell-icon', 'wsl.exe')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user