mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 19:17:28 +00:00
Each native Claude and Codex chat now has its own permission mode (Ask for approval, Accept edits for Claude, Approve for me, Full access), shown and changed from a button right after "+" in the composer. A change takes effect before the next message: the agent restarts quietly and resumes the same conversation, never interrupting a reply or background work. New chats start from Settings → Chat → Permissions, which is seeded once from Agent Permissions. The mode is stored as an ordinary chat option, and the restart reuses the existing "replace the agent before the next message" step.
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
CodexAppServerFrameSizeError,
|
|
CodexAppServerRequestError,
|
|
openCodexAppServerConnection,
|
|
type CodexAppServerConnection
|
|
} from './codex-app-server-connection'
|
|
import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
|
|
import { openCodexThread } from './codex-structured-thread-open'
|
|
|
|
function connectionFor(
|
|
request: CodexAppServerConnection['request']
|
|
): Pick<CodexAppServerConnection, 'request'> {
|
|
return { request }
|
|
}
|
|
|
|
describe('openCodexThread', () => {
|
|
it('applies the resolved permission policy when starting and resuming a thread', async () => {
|
|
const request = vi.fn(async (method: string) => ({
|
|
thread: { id: method === 'thread/start' ? 'thread-created' : 'thread-existing' }
|
|
}))
|
|
const connection = connectionFor(request)
|
|
const permissionPolicy = codexStructuredPermissionPolicy('full-access')
|
|
|
|
await openCodexThread(
|
|
connection,
|
|
{ cwd: '/workspace', resumeThreadId: null, permissionPolicy },
|
|
2_000
|
|
)
|
|
await openCodexThread(
|
|
connection,
|
|
{ cwd: '/workspace', resumeThreadId: 'thread-existing', permissionPolicy },
|
|
2_000
|
|
)
|
|
|
|
expect(request).toHaveBeenNthCalledWith(
|
|
1,
|
|
'thread/start',
|
|
{
|
|
cwd: '/workspace',
|
|
approvalPolicy: 'never',
|
|
sandbox: 'danger-full-access',
|
|
approvalsReviewer: 'user'
|
|
},
|
|
{ timeoutMs: 2_000 }
|
|
)
|
|
expect(request).toHaveBeenNthCalledWith(
|
|
2,
|
|
'thread/resume',
|
|
{
|
|
threadId: 'thread-existing',
|
|
cwd: '/workspace',
|
|
approvalPolicy: 'never',
|
|
sandbox: 'danger-full-access',
|
|
approvalsReviewer: 'user',
|
|
excludeTurns: true
|
|
},
|
|
{ timeoutMs: 2_000 }
|
|
)
|
|
})
|
|
|
|
// The regression this pins: an omitted policy falls back to the config.toml of the home Orca
|
|
// mirrors from the user's ~/.codex, and a resume inherits the policy it last ran with. With
|
|
// `approval_policy = "never"` there, an Ask chat would never prompt.
|
|
it('sends Ask as an explicit policy on resume, not as absent fields', async () => {
|
|
const request = vi.fn(async (_method: string, _params?: Record<string, unknown>) => ({
|
|
thread: { id: 'thread-existing' }
|
|
}))
|
|
const permissionPolicy = codexStructuredPermissionPolicy('ask')
|
|
|
|
await openCodexThread(
|
|
connectionFor(request),
|
|
{ cwd: '/workspace', resumeThreadId: 'thread-existing', permissionPolicy },
|
|
2_000
|
|
)
|
|
|
|
const params = request.mock.calls[0]?.[1] ?? {}
|
|
expect(params).toMatchObject({
|
|
approvalPolicy: 'on-request',
|
|
sandbox: 'workspace-write',
|
|
approvalsReviewer: 'user'
|
|
})
|
|
// Absence is the bug, so assert the keys are carried, not merely that they are not Full access.
|
|
expect(Object.keys(params)).toEqual(
|
|
expect.arrayContaining(['approvalPolicy', 'sandbox', 'approvalsReviewer'])
|
|
)
|
|
expect(params).not.toHaveProperty('sandboxPolicy')
|
|
})
|
|
|
|
it('preserves an explicitly reported service tier, including Standard', async () => {
|
|
const priority = vi.fn(async () => ({
|
|
thread: { id: 'thread-fast' },
|
|
serviceTier: 'priority-live'
|
|
}))
|
|
await expect(
|
|
openCodexThread(connectionFor(priority), { cwd: '/workspace', resumeThreadId: null }, 2_000)
|
|
).resolves.toMatchObject({ threadId: 'thread-fast', serviceTier: 'priority-live' })
|
|
|
|
const standard = vi.fn(async () => ({ thread: { id: 'thread-standard' }, serviceTier: null }))
|
|
await expect(
|
|
openCodexThread(connectionFor(standard), { cwd: '/workspace', resumeThreadId: null }, 2_000)
|
|
).resolves.toEqual({
|
|
threadId: 'thread-standard',
|
|
thread: { id: 'thread-standard' },
|
|
serviceTier: null
|
|
})
|
|
})
|
|
|
|
it('requests metadata-only state when resuming an existing thread', async () => {
|
|
const request = vi.fn(async () => ({
|
|
thread: { id: 'thread-1', path: '/history/thread-1.jsonl' },
|
|
model: 'gpt-live'
|
|
}))
|
|
|
|
await expect(
|
|
openCodexThread(
|
|
connectionFor(request),
|
|
{ cwd: '/workspace', resumeThreadId: 'thread-1', resumePath: '/history/thread-1.jsonl' },
|
|
2_000
|
|
)
|
|
).resolves.toMatchObject({ threadId: 'thread-1', model: 'gpt-live' })
|
|
|
|
expect(request).toHaveBeenCalledWith(
|
|
'thread/resume',
|
|
{
|
|
threadId: 'thread-1',
|
|
cwd: '/workspace',
|
|
path: '/history/thread-1.jsonl',
|
|
excludeTurns: true
|
|
},
|
|
{ timeoutMs: 2_000 }
|
|
)
|
|
})
|
|
|
|
it('does not retry without excludeTurns after an unsupported-field refusal', async () => {
|
|
const refusal = new CodexAppServerRequestError(
|
|
'thread/resume',
|
|
-32602,
|
|
'unknown field excludeTurns'
|
|
)
|
|
const request = vi.fn(async () => {
|
|
throw refusal
|
|
})
|
|
await expect(
|
|
openCodexThread(
|
|
connectionFor(request),
|
|
{ cwd: '/workspace', resumeThreadId: 'thread-1' },
|
|
2_000
|
|
)
|
|
).rejects.toBe(refusal)
|
|
expect(request).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('does not retry ambiguous invalid params or oversized history responses', async () => {
|
|
const invalid = new CodexAppServerRequestError(
|
|
'thread/resume',
|
|
-32602,
|
|
'codex app-server thread/resume failed: invalid params'
|
|
)
|
|
const invalidRequest = vi.fn(async () => {
|
|
throw invalid
|
|
})
|
|
await expect(
|
|
openCodexThread(
|
|
connectionFor(invalidRequest),
|
|
{ cwd: '/workspace', resumeThreadId: 'thread-1' },
|
|
2_000
|
|
)
|
|
).rejects.toBe(invalid)
|
|
expect(invalidRequest).toHaveBeenCalledOnce()
|
|
|
|
const oversized = new CodexAppServerFrameSizeError('thread/resume', 16_777_217, 16_777_216)
|
|
const oversizedRequest = vi.fn(async () => {
|
|
throw oversized
|
|
})
|
|
await expect(
|
|
openCodexThread(
|
|
connectionFor(oversizedRequest),
|
|
{ cwd: '/workspace', resumeThreadId: 'thread-1' },
|
|
2_000
|
|
)
|
|
).rejects.toBe(oversized)
|
|
expect(oversizedRequest).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
describe('a thread Codex never saved', () => {
|
|
const noRollout = (threadId: string, code = -32600) =>
|
|
new CodexAppServerRequestError(
|
|
'thread/resume',
|
|
code,
|
|
`codex app-server thread/resume failed: no rollout found for thread id ${threadId}`
|
|
)
|
|
function codexWithoutRollout(error: Error = noRollout('thread-unsaved')) {
|
|
return vi.fn(async (method: string, _params?: Record<string, unknown>) => {
|
|
if (method === 'thread/resume') {
|
|
throw error
|
|
}
|
|
return { thread: { id: 'thread-new' }, model: 'gpt-live' }
|
|
})
|
|
}
|
|
|
|
it('starts a new thread in its place when Codex proves it holds no rollout', async () => {
|
|
const request = codexWithoutRollout()
|
|
|
|
await expect(
|
|
openCodexThread(
|
|
connectionFor(request),
|
|
{ cwd: '/workspace', resumeThreadId: 'thread-unsaved', supersedeIfUnsaved: true },
|
|
2_000
|
|
)
|
|
).resolves.toMatchObject({
|
|
threadId: 'thread-new',
|
|
supersededThreadId: 'thread-unsaved',
|
|
model: 'gpt-live'
|
|
})
|
|
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
|
'thread/resume',
|
|
'thread/start'
|
|
])
|
|
expect(request).toHaveBeenLastCalledWith(
|
|
'thread/start',
|
|
{ cwd: '/workspace' },
|
|
{
|
|
timeoutMs: 2_000
|
|
}
|
|
)
|
|
})
|
|
|
|
it('opens the replacement thread on the chosen model', async () => {
|
|
const request = codexWithoutRollout()
|
|
|
|
await openCodexThread(
|
|
connectionFor(request),
|
|
{
|
|
cwd: '/workspace',
|
|
resumeThreadId: 'thread-unsaved',
|
|
supersedeIfUnsaved: true,
|
|
model: 'gpt-chosen'
|
|
},
|
|
2_000
|
|
)
|
|
|
|
// A resume keeps the thread's own saved model, provider and effort, which naming one skips.
|
|
expect(request.mock.calls[0]?.[1]).not.toHaveProperty('model')
|
|
expect(request).toHaveBeenLastCalledWith(
|
|
'thread/start',
|
|
{ cwd: '/workspace', model: 'gpt-chosen' },
|
|
{ timeoutMs: 2_000 }
|
|
)
|
|
})
|
|
|
|
it('keeps the resume failure for a thread a resume already proved', async () => {
|
|
const request = codexWithoutRollout()
|
|
|
|
await expect(
|
|
openCodexThread(
|
|
connectionFor(request),
|
|
{ cwd: '/workspace', resumeThreadId: 'thread-unsaved' },
|
|
2_000
|
|
)
|
|
).rejects.toThrow('no rollout found for thread id thread-unsaved')
|
|
expect(request).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('treats no other resume failure as proof that nothing was saved', async () => {
|
|
const failures = [
|
|
noRollout('thread-other'),
|
|
noRollout('thread-unsaved', -32603),
|
|
new CodexAppServerRequestError(
|
|
'thread/resume',
|
|
-32600,
|
|
'codex app-server thread/resume failed: thread not found'
|
|
),
|
|
new Error('codex app-server exited')
|
|
]
|
|
for (const failure of failures) {
|
|
const request = codexWithoutRollout(failure)
|
|
await expect(
|
|
openCodexThread(
|
|
connectionFor(request),
|
|
{ cwd: '/workspace', resumeThreadId: 'thread-unsaved', supersedeIfUnsaved: true },
|
|
2_000
|
|
)
|
|
).rejects.toBe(failure)
|
|
expect(request).toHaveBeenCalledOnce()
|
|
}
|
|
})
|
|
|
|
// Every other test here builds the error itself; this one sends Codex's raw JSON-RPC frame
|
|
// through the real connection, so a change to Orca's own error wording cannot hide the proof.
|
|
it('recognizes the raw frame Codex sends, through the real connection', async () => {
|
|
const fakeAppServer = String.raw`
|
|
const readline = require('node:readline')
|
|
const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n')
|
|
readline.createInterface({ input: process.stdin }).on('line', (line) => {
|
|
const message = JSON.parse(line)
|
|
if (message.method === 'initialize') return send({ id: message.id, result: {} })
|
|
if (message.method === 'thread/resume') {
|
|
const threadId = message.params.threadId
|
|
return send({
|
|
id: message.id,
|
|
error: { code: -32600, message: 'no rollout found for thread id ' + threadId }
|
|
})
|
|
}
|
|
if (message.method === 'thread/start') {
|
|
return send({ id: message.id, result: { thread: { id: 'thread-new' } } })
|
|
}
|
|
})
|
|
`
|
|
const connection = await openCodexAppServerConnection({
|
|
command: process.execPath,
|
|
args: ['-e', fakeAppServer]
|
|
})
|
|
try {
|
|
await expect(
|
|
openCodexThread(
|
|
connection,
|
|
{ cwd: '/workspace', resumeThreadId: 'thread-unsaved', supersedeIfUnsaved: true },
|
|
5_000
|
|
)
|
|
).resolves.toMatchObject({ threadId: 'thread-new', supersededThreadId: 'thread-unsaved' })
|
|
} finally {
|
|
await connection.close()
|
|
}
|
|
})
|
|
})
|
|
})
|