mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 18:17:36 +00:00
feat(connectors): pending prompts, answers and turn events behind experimentalConnectors (B3) (#32160)
This commit is contained in:
@@ -212,7 +212,13 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
|
||||
},
|
||||
{
|
||||
name: 'connector',
|
||||
keys: ['connector start', 'connector send'],
|
||||
keys: [
|
||||
'connector start',
|
||||
'connector send',
|
||||
'connector prompt',
|
||||
'connector answer',
|
||||
'connector watch'
|
||||
],
|
||||
load: async () => (await import('./handlers/connector.js')).CONNECTOR_HANDLERS
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { callConnector, formatConnectorStart, formatDelivery } from './connector'
|
||||
import {
|
||||
callConnector,
|
||||
formatConnectorStart,
|
||||
formatDelivery,
|
||||
formatPendingPrompt
|
||||
} from './connector'
|
||||
|
||||
function refusingClient(code: string) {
|
||||
return {
|
||||
@@ -41,4 +46,23 @@ describe('orca connector', () => {
|
||||
'refused: agent_asleep_in_window'
|
||||
)
|
||||
})
|
||||
|
||||
it('prints a prompt with its id and options, and an unknown kind as one to answer in Orca', () => {
|
||||
expect(
|
||||
formatPendingPrompt({
|
||||
kind: 'approval',
|
||||
promptId: 'p_1',
|
||||
tool: 'Bash',
|
||||
summary: 'ls',
|
||||
options: [
|
||||
{ id: 'allow_once', label: 'Allow once' },
|
||||
{ id: 'reject_once', label: 'Reject' }
|
||||
]
|
||||
})
|
||||
).toBe('approval Bash: ls\n prompt id: p_1\n allow_once: Allow once\n reject_once: Reject')
|
||||
expect(formatPendingPrompt({ kind: 'free-text', promptId: 'p_2' })).toBe(
|
||||
'unsupported (answer in Orca)\n prompt id: p_2'
|
||||
)
|
||||
expect(formatPendingPrompt(null)).toBe('no pending prompt')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { CommandHandler, HandlerContext } from '../dispatch'
|
||||
import { getOptionalStringFlag, getRequiredStringFlag } from '../flags'
|
||||
import {
|
||||
getOptionalNonNegativeIntegerFlag,
|
||||
getOptionalStringFlag,
|
||||
getRequiredStringFlag
|
||||
} from '../flags'
|
||||
import { printResult } from '../format'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { getRequiredWorktreeSelector } from '../selectors'
|
||||
@@ -26,10 +30,11 @@ export function formatConnectorStart(initial: ConnectorDelivery): string {
|
||||
export async function callConnector<TResult>(
|
||||
client: Pick<HandlerContext['client'], 'call'>,
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
params: Record<string, unknown>,
|
||||
options?: { timeoutMs: number }
|
||||
) {
|
||||
try {
|
||||
return await client.call<TResult>(method, params)
|
||||
return await client.call<TResult>(method, params, options)
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeClientError && error.code === 'method_not_found') {
|
||||
throw new RuntimeClientError(
|
||||
@@ -41,12 +46,43 @@ export async function callConnector<TResult>(
|
||||
}
|
||||
}
|
||||
|
||||
function conversationParams(flags: HandlerContext['flags']) {
|
||||
return {
|
||||
source: 'dev',
|
||||
externalId: getRequiredStringFlag(flags, 'conversation'),
|
||||
deliveryId: getRequiredStringFlag(flags, 'delivery-id')
|
||||
type ConnectorPrompt = {
|
||||
kind: string
|
||||
promptId: string
|
||||
tool?: string
|
||||
summary?: string
|
||||
question?: string
|
||||
options?: { id: string; label: string }[]
|
||||
}
|
||||
|
||||
/** A kind this CLI does not know reads as one only Orca can answer, as the contract asks. */
|
||||
export function formatPendingPrompt(prompt: ConnectorPrompt | null): string {
|
||||
if (!prompt) {
|
||||
return 'no pending prompt'
|
||||
}
|
||||
const known = prompt.kind === 'approval' || prompt.kind === 'question'
|
||||
const head =
|
||||
prompt.kind === 'approval'
|
||||
? `approval ${prompt.tool}${prompt.summary ? `: ${prompt.summary}` : ''}`
|
||||
: prompt.kind === 'question'
|
||||
? `question: ${prompt.question}`
|
||||
: 'unsupported (answer in Orca)'
|
||||
const options = known ? (prompt.options ?? []) : []
|
||||
return [
|
||||
head,
|
||||
` prompt id: ${prompt.promptId}`,
|
||||
...options.map((option) => ` ${option.id}: ${option.label}`)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const DEFAULT_WATCH_MS = 30_000
|
||||
|
||||
function conversation(flags: HandlerContext['flags']) {
|
||||
return { source: 'dev', externalId: getRequiredStringFlag(flags, 'conversation') }
|
||||
}
|
||||
|
||||
function conversationParams(flags: HandlerContext['flags']) {
|
||||
return { ...conversation(flags), deliveryId: getRequiredStringFlag(flags, 'delivery-id') }
|
||||
}
|
||||
|
||||
export const CONNECTOR_HANDLERS: Record<string, CommandHandler> = {
|
||||
@@ -82,5 +118,34 @@ export const CONNECTOR_HANDLERS: Record<string, CommandHandler> = {
|
||||
text: getRequiredStringFlag(flags, 'text')
|
||||
})
|
||||
printResult(result, json, formatDelivery)
|
||||
},
|
||||
'connector prompt': async ({ flags, client, json }) => {
|
||||
const result = await callConnector<{ prompt: ConnectorPrompt | null }>(
|
||||
client,
|
||||
'connector.pendingPrompt',
|
||||
conversation(flags)
|
||||
)
|
||||
printResult(result, json, (value) => formatPendingPrompt(value.prompt))
|
||||
},
|
||||
'connector answer': async ({ flags, client, json }) => {
|
||||
const result = await callConnector<ConnectorDelivery>(client, 'connector.answer', {
|
||||
...conversation(flags),
|
||||
promptId: getRequiredStringFlag(flags, 'prompt-id'),
|
||||
optionId: getRequiredStringFlag(flags, 'option')
|
||||
})
|
||||
printResult(result, json, formatDelivery)
|
||||
},
|
||||
'connector watch': async ({ flags, client, json }) => {
|
||||
const forMs = getOptionalNonNegativeIntegerFlag(flags, 'for-ms') ?? DEFAULT_WATCH_MS
|
||||
const result = await callConnector<{ snapshot: unknown; events: unknown[] }>(
|
||||
client,
|
||||
'connector.watch',
|
||||
{ forMs },
|
||||
// The host holds the call open for the whole window.
|
||||
{ timeoutMs: forMs + 15_000 }
|
||||
)
|
||||
printResult(result, json, (value) =>
|
||||
[value.snapshot, ...value.events].map((event) => JSON.stringify(event)).join('\n')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,5 +38,38 @@ export const CONNECTOR_COMMAND_SPECS: CommandSpec[] = [
|
||||
...CONNECTOR_NOTES,
|
||||
'Held while the agent is asking for permission, and sent once it is answered.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['connector', 'prompt'],
|
||||
hidden: true,
|
||||
summary: "Show what a dev connector conversation's agent is waiting on",
|
||||
usage: 'orca connector prompt --conversation <id> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'conversation'],
|
||||
notes: [
|
||||
CONNECTOR_NOTES[0],
|
||||
'An approval or a one-question single choice can be answered here; anything else reads unsupported, to answer in Orca.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['connector', 'answer'],
|
||||
hidden: true,
|
||||
summary: "Answer a dev connector conversation's pending prompt",
|
||||
usage: 'orca connector answer --conversation <id> --prompt-id <id> --option <id> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'conversation', 'prompt-id', 'option'],
|
||||
notes: [
|
||||
CONNECTOR_NOTES[0],
|
||||
'Refused unless --prompt-id is the prompt still pending in the same agent process, so a late answer never types into whatever came next.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['connector', 'watch'],
|
||||
hidden: true,
|
||||
summary: 'Collect connector conversation events for a while',
|
||||
usage: 'orca connector watch [--for-ms <ms>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'for-ms'],
|
||||
notes: [
|
||||
CONNECTOR_NOTES[0],
|
||||
'Prints a snapshot of every linked conversation, then each event (linked, prompt, turn-started, turn-ended) seen in the window; --for-ms defaults to 30000.'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user