@xingular/harness 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/NOTICE.md +38 -0
- package/README.md +118 -26
- package/dist/cli.js +6106 -912
- package/dist/terminal-pi.js +462 -11
- package/docs/README.md +12 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
- package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/verification.json +130 -0
- package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +95 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +27 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +18 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +95 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +60 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +24 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +57 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +58 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +320 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +30 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +133 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +20 -0
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +207 -0
- package/docs/terminal-tui.md +254 -0
- package/package.json +8 -2
- package/src/accounts/accounts.test.ts +110 -0
- package/src/accounts/accounts.ts +146 -10
- package/src/cli.ts +7 -3
- package/src/harness.ts +16 -3
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +6 -1
- package/src/memory/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +163 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/maintenance.ts +199 -11
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +9 -2
- package/src/server/methods.ts +37 -2
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +27 -0
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +109 -22
- package/src/sessions/runner.ts +36 -1
- package/src/sessions/sessions.test.ts +95 -1
- package/src/sessions/sessions.ts +53 -9
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +68 -4
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +50 -1
- package/src/terminal/socket.ts +13 -2
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +16 -7
- package/src/terminal/terminal.test.ts +70 -1
- package/src/terminal/terminals.ts +122 -12
- package/src/terminal/tui/application.test.ts +114 -0
- package/src/terminal/tui/application.ts +491 -0
- package/src/terminal/tui/commands.test.ts +218 -0
- package/src/terminal/tui/commands.ts +361 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +84 -0
- package/src/terminal/tui/editor.ts +79 -0
- package/src/terminal/tui/keybindings.test.ts +22 -0
- package/src/terminal/tui/keybindings.ts +50 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +104 -0
- package/src/terminal/tui/runtime.test.ts +215 -0
- package/src/terminal/tui/runtime.ts +388 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -0
- package/src/terminal/tui/status.test.ts +70 -0
- package/src/terminal/tui/status.ts +109 -0
- package/src/terminal/tui/theme.ts +174 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/transcript.test.ts +25 -0
- package/src/terminal/tui/transcript.ts +124 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/maintenance.ts +24 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +92 -0
package/src/sessions/runner.ts
CHANGED
|
@@ -84,6 +84,10 @@ export interface PiSessionLike {
|
|
|
84
84
|
getAllTools?(): Array<{ name: string; description?: string; parameters?: unknown }>
|
|
85
85
|
subscribe(listener: (event: PiEvent) => void): () => void
|
|
86
86
|
prompt(text: string, options?: { images?: unknown[] }): Promise<void>
|
|
87
|
+
/** Queues a message for the model after the current step. Resolves `handled` when Pi takes it without queueing. */
|
|
88
|
+
steer?(text: string): Promise<'handled' | 'queued'>
|
|
89
|
+
/** Drops queued messages Pi has not delivered. */
|
|
90
|
+
clearQueue?(): unknown
|
|
87
91
|
sendCustomMessage?(message: { customType: string; content: string; display: boolean; details: unknown }, options: { triggerTurn: boolean }): Promise<void>
|
|
88
92
|
abort(): Promise<void>
|
|
89
93
|
dispose(): void
|
|
@@ -255,11 +259,35 @@ export async function runTurn(run: ActiveRun, session: PiSessionLike, params: Tu
|
|
|
255
259
|
await session.abort()
|
|
256
260
|
})
|
|
257
261
|
|
|
262
|
+
// Steers handed to Pi and not yet delivered, oldest first. Pi delivers them in that order, after
|
|
263
|
+
// the prompt's own user message, which is the first user message it starts.
|
|
264
|
+
const pendingSteers: string[] = []
|
|
265
|
+
let accepting = false
|
|
266
|
+
let promptStarted = false
|
|
267
|
+
const forgetSteer = (text: string): void => {
|
|
268
|
+
const index = pendingSteers.lastIndexOf(text)
|
|
269
|
+
if (index >= 0) pendingSteers.splice(index, 1)
|
|
270
|
+
}
|
|
271
|
+
run.onSteer(async (text) => {
|
|
272
|
+
if (!accepting || !session.steer) throw new HarnessError('run_not_active', 'This reply is not accepting messages.')
|
|
273
|
+
pendingSteers.push(text)
|
|
274
|
+
try {
|
|
275
|
+
if ((await session.steer(text)) === 'handled') forgetSteer(text)
|
|
276
|
+
} catch (error) {
|
|
277
|
+
forgetSteer(text)
|
|
278
|
+
throw error
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
|
|
258
282
|
const unsubscribe = session.subscribe((event) => {
|
|
259
283
|
switch (event.type) {
|
|
260
284
|
case 'message_start': {
|
|
261
285
|
const { message } = event as Extract<PiEvent, { type: 'message_start' }>
|
|
262
286
|
if (message.role === 'assistant') currentMessageId = `${run.runId}:m${messageIndex++}`
|
|
287
|
+
else if (message.role === 'user' && promptStarted) {
|
|
288
|
+
const text = pendingSteers.shift()
|
|
289
|
+
if (text !== undefined) run.emit('message.steered', { text })
|
|
290
|
+
} else if (message.role === 'user') promptStarted = true
|
|
263
291
|
break
|
|
264
292
|
}
|
|
265
293
|
case 'turn_start': {
|
|
@@ -424,7 +452,14 @@ export async function runTurn(run: ActiveRun, session: PiSessionLike, params: Tu
|
|
|
424
452
|
}
|
|
425
453
|
const prompt = params.preamble && !extensionCommand ? `${params.preamble}\n\n${params.text}` : params.text
|
|
426
454
|
const images = (params.images ?? []).map((image) => ({ type: 'image', mimeType: image.mediaType, data: image.data }))
|
|
427
|
-
|
|
455
|
+
accepting = true
|
|
456
|
+
try {
|
|
457
|
+
await session.prompt(prompt, images.length ? { images } : undefined)
|
|
458
|
+
} finally {
|
|
459
|
+
// A message Pi has not delivered by now never will be; the caller sends it again as a new reply.
|
|
460
|
+
accepting = false
|
|
461
|
+
session.clearQueue?.()
|
|
462
|
+
}
|
|
428
463
|
if (aborted) run.finish('interrupted')
|
|
429
464
|
else if (lastError) run.finish('failed', { code: 'provider_error', message: lastError })
|
|
430
465
|
else run.finish('completed')
|
|
@@ -326,6 +326,36 @@ describe('session turns on Pi', () => {
|
|
|
326
326
|
expect(events.at(-1)?.type).toBe('completed')
|
|
327
327
|
})
|
|
328
328
|
|
|
329
|
+
it('asks a person for a humanOnly call even when the tool was remembered', async () => {
|
|
330
|
+
let blockReason: string | null | undefined
|
|
331
|
+
const policy: ToolPolicy = { scope: async () => ({ allowed: false, requiresApproval: true, humanOnly: true, reason: 'Run this command?' }) }
|
|
332
|
+
const { events, run, harness } = setup(async ({ gate }) => fakeSession(async (_emit, check) => void (blockReason = await check()), gate), policy)
|
|
333
|
+
harness.approvals.remember('agent-1', 'bash')
|
|
334
|
+
harness.approvals.setAuto('chat', true)
|
|
335
|
+
await run('run-human', { text: 'npm install' })
|
|
336
|
+
await tick()
|
|
337
|
+
const requested = events.find((event) => event.type === 'approval.requested')!.payload as { approvalId: string }
|
|
338
|
+
expect(requested).toMatchObject({ target: 'bash', rememberable: false })
|
|
339
|
+
expect(requested).not.toHaveProperty('automatic')
|
|
340
|
+
expect(events.some((event) => event.type === 'status.changed' && (event.payload as { status: string }).status === 'waiting_for_approval')).toBe(true)
|
|
341
|
+
harness.runs.resolveApproval('run-human', requested.approvalId, 'approved')
|
|
342
|
+
await tick()
|
|
343
|
+
expect(blockReason).toBeNull()
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('lets the Agent chat switch approve humanOnly calls without remembering the tool', async () => {
|
|
347
|
+
let blockReason: string | null | undefined
|
|
348
|
+
const policy: ToolPolicy = { scope: async () => ({ allowed: false, requiresApproval: true, humanOnly: true, allowGlobalApproval: true, reason: 'Run this command?' }) }
|
|
349
|
+
const { events, run, harness } = setup(async ({ gate }) => fakeSession(async (_emit, check) => void (blockReason = await check()), gate), policy)
|
|
350
|
+
harness.approvals.setAuto('chat', true)
|
|
351
|
+
await run('run-human-auto', { text: 'run command' })
|
|
352
|
+
await tick()
|
|
353
|
+
expect(blockReason).toBeNull()
|
|
354
|
+
expect(events.find((event) => event.type === 'approval.requested')?.payload).toMatchObject({ target: 'bash', rememberable: false, automatic: 'always' })
|
|
355
|
+
expect(events.find((event) => event.type === 'approval.resolved')?.payload).toMatchObject({ decision: 'approved', automatic: 'always' })
|
|
356
|
+
expect(events.some((event) => event.type === 'status.changed' && (event.payload as { status: string }).status === 'waiting_for_approval')).toBe(false)
|
|
357
|
+
})
|
|
358
|
+
|
|
329
359
|
it('still asks when the application cannot say what is allowed', async () => {
|
|
330
360
|
const { events, run } = setup(async ({ gate }) => fakeSession(async (_emit, check) => void (await check()), gate), { approval: async () => { throw new Error('down') } })
|
|
331
361
|
await run('run-ask', { text: 'list files' })
|
|
@@ -344,14 +374,32 @@ describe('session turns on Pi', () => {
|
|
|
344
374
|
if (authorized > 1) throw new Error('Tool unavailable in the app settings.')
|
|
345
375
|
}
|
|
346
376
|
}
|
|
347
|
-
const { run } = setup(async ({ gate }) => fakeSession(async () => {
|
|
377
|
+
const { run, harness } = setup(async ({ gate }) => fakeSession(async () => {
|
|
348
378
|
reasons.push(await gate.check('bash', {}), await gate.check('read', {}), await gate.check('read', {}))
|
|
349
379
|
}, gate), policy)
|
|
380
|
+
harness.approvals.setAuto('chat', true)
|
|
350
381
|
await run('run-scope')
|
|
351
382
|
await tick()
|
|
352
383
|
expect(reasons).toEqual(['Scheduled workers may use scoped file tools only.', null, 'Tool unavailable in the app settings.'])
|
|
353
384
|
})
|
|
354
385
|
|
|
386
|
+
it('forwards opaque caller policy context to the application tool policy', async () => {
|
|
387
|
+
const contexts: unknown[] = []
|
|
388
|
+
const policy: ToolPolicy = {
|
|
389
|
+
scope: async ({ caller }) => {
|
|
390
|
+
contexts.push(caller.policyContext)
|
|
391
|
+
return { allowed: true }
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const { run } = setup(async ({ gate }) => fakeSession(async (_emit, check) => void (await check()), gate), policy)
|
|
395
|
+
const policyContext = { delegatesTo: ['worker-1'], snapshot: 'run-start' }
|
|
396
|
+
await run('run-policy-context', {
|
|
397
|
+
caller: { agentId: 'agent-1', agentName: 'Helper', actor: 'Chat', policyContext }
|
|
398
|
+
})
|
|
399
|
+
await tick()
|
|
400
|
+
expect(contexts).toEqual([policyContext])
|
|
401
|
+
})
|
|
402
|
+
|
|
355
403
|
it('aborts the Pi session on interrupt', async () => {
|
|
356
404
|
let session: (PiSessionLike & { aborted: boolean }) | null = null
|
|
357
405
|
const { events, run, harness } = setup(async ({ gate }) => (session = fakeSession(() => new Promise((resolve) => setTimeout(resolve, 50)), gate)))
|
|
@@ -438,6 +486,52 @@ describe('history selection', () => {
|
|
|
438
486
|
})
|
|
439
487
|
})
|
|
440
488
|
|
|
489
|
+
describe('subagents', () => {
|
|
490
|
+
it('starts a child run of the same caller with no more tools than its parent, and returns its result', async () => {
|
|
491
|
+
const seen = new Map<string, { allow: string[]; tools: string[]; depth: string | null }>()
|
|
492
|
+
let parentResult: unknown = null
|
|
493
|
+
const { harness, run } = setup(async ({ gate, params, allow, tools }) => {
|
|
494
|
+
seen.set(params.runId, { allow: [...allow], tools: tools.map((tool) => tool.name), depth: null })
|
|
495
|
+
return fakeSession(async (emit) => {
|
|
496
|
+
if (params.runId === 'parent') {
|
|
497
|
+
const spawn = tools.find((tool) => tool.name === 'spawn_subagent')!
|
|
498
|
+
expect(await spawn.execute({ brief: 'Find the bug', tools: ['bash', 'web'] })).toMatch(/cannot have tools you do not have: web/)
|
|
499
|
+
parentResult = await spawn.execute({ brief: 'Find the bug', tools: ['read'] })
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
emit({ type: 'message_start', message: { role: 'assistant' } })
|
|
503
|
+
emit({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'Found it in parser.ts' }] } })
|
|
504
|
+
}, gate)
|
|
505
|
+
})
|
|
506
|
+
await run('parent', { subagents: { maxDepth: 1 } })
|
|
507
|
+
for (let attempt = 0; attempt < 50 && !parentResult; attempt++) await tick()
|
|
508
|
+
expect(parentResult).toMatch(/Subagent run .* · completed\nResult:\nFound it in parser.ts/)
|
|
509
|
+
expect(seen.get('parent')!.allow).toContain('spawn_subagent')
|
|
510
|
+
const [childId] = [...seen.keys()].filter((id) => id !== 'parent')
|
|
511
|
+
const child = seen.get(childId)!
|
|
512
|
+
expect(child.allow).toContain('read')
|
|
513
|
+
expect(child.allow).not.toContain('bash')
|
|
514
|
+
// At the depth limit the child cannot start subagents of its own.
|
|
515
|
+
expect(child.allow).not.toContain('spawn_subagent')
|
|
516
|
+
expect(loadRun(harness.env.root, childId)).toMatchObject({ parentRunId: 'parent', relation: 'subagent', agentId: 'agent-1', status: 'completed' })
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
it('offers no subagent tool unless the caller asks for it, and can run without the application\'s tool providers', async () => {
|
|
520
|
+
const offered: string[][] = []
|
|
521
|
+
const { harness, run } = setup(async ({ gate, tools }) => {
|
|
522
|
+
offered.push(tools.map((tool) => tool.name))
|
|
523
|
+
return fakeSession(async () => undefined, gate)
|
|
524
|
+
})
|
|
525
|
+
harness.sessions.provide(() => [{ name: 'app_tool', label: 'App', description: 'An app tool', parameters: (Type) => Type.Object({}), execute: async () => 'ok' }])
|
|
526
|
+
await run('plain', { tools: { allow: ['app_tool'] } })
|
|
527
|
+
await run('solo', { tools: { allow: ['app_tool'] }, providers: false })
|
|
528
|
+
await tick()
|
|
529
|
+
expect(offered[0]).toContain('app_tool')
|
|
530
|
+
expect(offered[0]).not.toContain('spawn_subagent')
|
|
531
|
+
expect(offered[1]).not.toContain('app_tool')
|
|
532
|
+
})
|
|
533
|
+
})
|
|
534
|
+
|
|
441
535
|
describe('standing approvals', () => {
|
|
442
536
|
const policy = { auto: { pi: true }, remembered: { 'pane-1': ['shell'] } }
|
|
443
537
|
const facts = (unattended: boolean, readOnly: boolean): ApprovalFacts => ({ unattended, readOnly })
|
package/src/sessions/sessions.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { FIRST_PARTY_PLUGINS, firstPartyExtensions, type InlineExtension } from
|
|
|
21
21
|
import * as registry from '../skills/registry'
|
|
22
22
|
import { ASK_USER, askUserTool } from '../tools/askUser'
|
|
23
23
|
import { PLUGIN_PROPOSE, pluginProposeTool } from '../tools/pluginPropose'
|
|
24
|
+
import { SPAWN_SUBAGENT, SUBAGENT_RELATION, spawnSubagentTool, subagentDepth, type SubagentOptions } from '../tools/spawnSubagent'
|
|
24
25
|
import { WIKI_TOOL_NAMES } from '../tools/definitions'
|
|
25
26
|
import { memoryTools } from '../tools/memoryTools'
|
|
26
27
|
import type { ToolProvider, ToolSession, ToolSpec } from '../tools/types'
|
|
@@ -66,6 +67,10 @@ export interface SessionRunInput {
|
|
|
66
67
|
capabilities?: CapabilitySet
|
|
67
68
|
/** Hops from the user to this session (0 when the user started it). */
|
|
68
69
|
depth?: number
|
|
70
|
+
/** Offer `spawn_subagent`: the model may start child runs of this caller, up to this depth. */
|
|
71
|
+
subagents?: SubagentOptions | null
|
|
72
|
+
/** Ask the application's tool providers for tools (the default). False runs with the harness's own tools only. */
|
|
73
|
+
providers?: boolean
|
|
69
74
|
}
|
|
70
75
|
|
|
71
76
|
/** The connected-apps broker, as a session sees it. */
|
|
@@ -126,9 +131,15 @@ export class Sessions {
|
|
|
126
131
|
} catch (error) {
|
|
127
132
|
return error instanceof Error ? error.message : 'Tool unavailable in the app settings.'
|
|
128
133
|
}
|
|
129
|
-
if (scope.allowed && (scope.automatic || !gated.has(tool))) return null
|
|
130
|
-
// A call outside the scope is approved one at a time; a gated tool may be remembered.
|
|
131
|
-
const decision = await run.requestApproval({
|
|
134
|
+
if (scope.allowed && !scope.humanOnly && (scope.automatic || !gated.has(tool))) return null
|
|
135
|
+
// A call outside the scope is approved one at a time; a gated tool may be remembered, unless only a person may decide it.
|
|
136
|
+
const decision = await run.requestApproval({
|
|
137
|
+
target: tool,
|
|
138
|
+
action: summarize(args),
|
|
139
|
+
operation: { tool, args: (args && typeof args === 'object' ? args : {}) as Record<string, unknown> },
|
|
140
|
+
rememberable: scope.allowed && !scope.humanOnly,
|
|
141
|
+
standing: scope.humanOnly ? (scope.allowGlobalApproval ? 'switch' : 'none') : undefined
|
|
142
|
+
})
|
|
132
143
|
if (decision !== 'approved') return 'The user denied this action. Ask how they would like to proceed.'
|
|
133
144
|
try {
|
|
134
145
|
await policy.authorize?.(call)
|
|
@@ -140,6 +151,35 @@ export class Sessions {
|
|
|
140
151
|
}
|
|
141
152
|
}
|
|
142
153
|
|
|
154
|
+
/** `spawn_subagent` for a run: each child is this caller again, one hop deeper, with no more tools than the parent. */
|
|
155
|
+
private subagentTool(input: SessionRunInput, runId: string, accountId: string, parent: CapabilitySet): ToolSpec {
|
|
156
|
+
const parentTools = parent.tools.filter((name) => name !== SPAWN_SUBAGENT)
|
|
157
|
+
return spawnSubagentTool({
|
|
158
|
+
root: this.env.root,
|
|
159
|
+
parentRunId: runId,
|
|
160
|
+
parentSessionId: input.run.sessionId,
|
|
161
|
+
parentTools,
|
|
162
|
+
sessionDir: input.sessionDir,
|
|
163
|
+
start: (child) => this.run({
|
|
164
|
+
...input,
|
|
165
|
+
run: { ...input.run, id: child.runId, sessionId: child.sessionId, parentRunId: runId, relation: SUBAGENT_RELATION, goal: takeChars(child.brief, 4000), resumedFromRunId: null, handoffId: null },
|
|
166
|
+
accountId,
|
|
167
|
+
poolId: null,
|
|
168
|
+
sessionDir: child.sessionDir,
|
|
169
|
+
resume: null,
|
|
170
|
+
text: child.brief,
|
|
171
|
+
images: undefined,
|
|
172
|
+
requiresImageInput: false,
|
|
173
|
+
preamble: null,
|
|
174
|
+
caller: { ...input.caller, runId: undefined, sessionId: child.sessionId },
|
|
175
|
+
tools: { allow: [...child.tools, SPAWN_SUBAGENT], gated: (input.tools.gated ?? []).filter((name) => child.tools.includes(name)) },
|
|
176
|
+
// Never wider than the parent's frozen set; the session intersects it with what is available now.
|
|
177
|
+
capabilities: { ...parent, tools: [...parent.tools.filter((name) => child.tools.includes(name)), SPAWN_SUBAGENT] },
|
|
178
|
+
depth: (input.depth ?? 0) + 1
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
143
183
|
/**
|
|
144
184
|
* Starts one turn. Returns once the Pi session is open; the reply arrives as run events. A
|
|
145
185
|
* session that cannot be opened ends its run as failed and returns no native session id.
|
|
@@ -151,14 +191,17 @@ export class Sessions {
|
|
|
151
191
|
const account = this.options.accounts.resolve(input.accountId, input.poolId)
|
|
152
192
|
const context = this.options.accounts.context(account)
|
|
153
193
|
const runId = input.run.id
|
|
154
|
-
const caller: CallerContext = { ...input.caller, runId, sessionId: input.caller.sessionId ?? input.run.sessionId }
|
|
194
|
+
const caller: CallerContext = { ...input.caller, maintenanceJobIds: input.caller.maintenanceJobIds?.slice(), runId, sessionId: input.caller.sessionId ?? input.run.sessionId }
|
|
155
195
|
const broker = input.connections ? this.options.broker : null
|
|
196
|
+
const depth = input.depth ?? 0
|
|
197
|
+
const canSpawn = Boolean(input.subagents) && depth < subagentDepth(input.subagents ?? undefined)
|
|
156
198
|
const requestedTools = [...new Set([
|
|
157
|
-
...input.tools.allow,
|
|
199
|
+
...input.tools.allow.filter((name) => name !== SPAWN_SUBAGENT),
|
|
158
200
|
ASK_USER,
|
|
159
201
|
...(input.automatic ? [] : [PLUGIN_PROPOSE]),
|
|
160
202
|
...WIKI_TOOL_NAMES,
|
|
161
|
-
...(broker?.toolNames ?? [])
|
|
203
|
+
...(broker?.toolNames ?? []),
|
|
204
|
+
...(canSpawn ? [SPAWN_SUBAGENT] : [])
|
|
162
205
|
])]
|
|
163
206
|
const available = this.options.resolveCapabilities
|
|
164
207
|
? await this.options.resolveCapabilities(caller, requestedTools, input.cwd)
|
|
@@ -232,7 +275,7 @@ export class Sessions {
|
|
|
232
275
|
if (run.finished) return { runId, nativeSessionId: null }
|
|
233
276
|
// What every session has, after the tools the caller switched on and in a fixed order.
|
|
234
277
|
const allow = capabilities.tools
|
|
235
|
-
const session: ToolSession = { run, caller, allow, depth
|
|
278
|
+
const session: ToolSession = { run, caller, allow, depth, cwd: input.cwd, capabilities }
|
|
236
279
|
const { plugins } = this.options
|
|
237
280
|
|
|
238
281
|
let pi: PiSessionLike
|
|
@@ -241,9 +284,10 @@ export class Sessions {
|
|
|
241
284
|
...(broker ? broker.tools(session, { readOnly: typeof input.connections === 'object' && input.connections.readOnly === true }) : []),
|
|
242
285
|
...memoryTools(this.options.env, caller, capabilities.contributions),
|
|
243
286
|
askUserTool(run),
|
|
244
|
-
pluginProposeTool(this.options.env, caller)
|
|
287
|
+
pluginProposeTool(this.options.env, caller),
|
|
288
|
+
...(allow.includes(SPAWN_SUBAGENT) ? [this.subagentTool(input, runId, account.id, capabilities)] : [])
|
|
245
289
|
]
|
|
246
|
-
for (const provider of this.providers) tools.push(...(await provider(session)))
|
|
290
|
+
if (input.providers !== false) for (const provider of this.providers) tools.push(...(await provider(session)))
|
|
247
291
|
const params: TurnParams = {
|
|
248
292
|
runId,
|
|
249
293
|
cwd: input.cwd,
|
package/src/skills/registry.ts
CHANGED
|
@@ -291,11 +291,16 @@ export function create(root: string, kind: string, rawName: string): RegistrySta
|
|
|
291
291
|
|
|
292
292
|
/** Imports complete user-owned skill folders without replacing existing or built-in items. */
|
|
293
293
|
export function importSkills(root: string, bundles: SkillBundleInput[]): RegistryState {
|
|
294
|
+
return importItems(root, 'skills', bundles)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Imports inert registry resources. Prompts and rules are single UTF-8 Markdown documents. */
|
|
298
|
+
export function importItems(root: string, kind: RegistryKind, bundles: SkillBundleInput[]): RegistryState {
|
|
294
299
|
if (!Array.isArray(bundles) || bundles.length === 0 || bundles.length > MAX_SKILL_BUNDLES) {
|
|
295
300
|
throw invalid(`Import between 1 and ${MAX_SKILL_BUNDLES} skills at a time.`)
|
|
296
301
|
}
|
|
297
302
|
|
|
298
|
-
const directory = kindDir(root,
|
|
303
|
+
const directory = kindDir(root, kind)
|
|
299
304
|
mkdirSync(directory, { recursive: true })
|
|
300
305
|
const existing = new Set(list(root).map((item) => item.id))
|
|
301
306
|
const seen = new Set<string>()
|
|
@@ -308,10 +313,10 @@ export function importSkills(root: string, bundles: SkillBundleInput[]): Registr
|
|
|
308
313
|
if (name.length > 64) throw invalid('Use a skill name with at most 64 letters or numbers.')
|
|
309
314
|
const slug = Array.from(name.toLowerCase(), (character) => (/^[a-z0-9]$/.test(character) ? character : '-')).join('').split('-').filter(Boolean).join('-')
|
|
310
315
|
if (!slug || slug.length > 64) throw invalid('Use a skill name with at most 64 letters or numbers.')
|
|
311
|
-
const id =
|
|
316
|
+
const id = `${kind}/${slug}`
|
|
312
317
|
if (existing.has(id) || seen.has(id)) throw invalid(`The skill “${name}” already exists.`)
|
|
313
318
|
try {
|
|
314
|
-
lstatSync(join(directory, slug))
|
|
319
|
+
lstatSync(join(directory, kind === 'skills' ? slug : `${slug}.md`))
|
|
315
320
|
throw invalid(`The skill “${name}” already exists.`)
|
|
316
321
|
} catch (error) {
|
|
317
322
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
@@ -350,7 +355,8 @@ export function importSkills(root: string, bundles: SkillBundleInput[]): Registr
|
|
|
350
355
|
if (file.encoding === 'base64' && bytes.toString('base64') !== file.content) throw invalid(`The file “${relative}” is not valid base64.`)
|
|
351
356
|
files.set(relative, bytes)
|
|
352
357
|
}
|
|
353
|
-
if (!files.has('SKILL.md')) throw invalid(`The skill “${name}” must include SKILL.md at its root.`)
|
|
358
|
+
if (kind === 'skills' && !files.has('SKILL.md')) throw invalid(`The skill “${name}” must include SKILL.md at its root.`)
|
|
359
|
+
if (kind !== 'skills' && (bundle.files.length !== 1 || bundle.files[0].encoding !== 'utf8' || bundle.files[0].path !== 'CONTENT.md')) throw invalid('A prompt or rule must contain one UTF-8 CONTENT.md file.')
|
|
354
360
|
return { name, slug, files }
|
|
355
361
|
})
|
|
356
362
|
|
|
@@ -366,8 +372,8 @@ export function importSkills(root: string, bundles: SkillBundleInput[]): Registr
|
|
|
366
372
|
}
|
|
367
373
|
}
|
|
368
374
|
for (const bundle of prepared) {
|
|
369
|
-
const target = join(directory, bundle.slug)
|
|
370
|
-
renameSync(join(staging, bundle.slug), target)
|
|
375
|
+
const target = join(directory, kind === 'skills' ? bundle.slug : `${bundle.slug}.md`)
|
|
376
|
+
renameSync(kind === 'skills' ? join(staging, bundle.slug) : join(staging, bundle.slug, 'CONTENT.md'), target)
|
|
371
377
|
installed.push(target)
|
|
372
378
|
}
|
|
373
379
|
} catch (error) {
|
|
@@ -383,7 +389,7 @@ export function importSkills(root: string, bundles: SkillBundleInput[]): Registr
|
|
|
383
389
|
* Writes a skill, prompt, or rule (an accepted proposal), keeping an existing item's location.
|
|
384
390
|
* The previous content and the new content are recorded as Wiki revisions of the item.
|
|
385
391
|
*/
|
|
386
|
-
export function writeItem(root: string, kind: string, name: string, description: string, body: string, reason: string): string {
|
|
392
|
+
export function writeItem(root: string, kind: string, name: string, description: string, body: string, reason: string, actor = 'user'): string {
|
|
387
393
|
const plural = kind === 'skill' ? 'skills' : kind === 'prompt' ? 'prompts' : kind === 'rule' ? 'rules' : null
|
|
388
394
|
if (!plural) throw invalid('Only skills, prompts, and rules can be written.')
|
|
389
395
|
const stem = store.slug(name)
|
|
@@ -395,13 +401,13 @@ export function writeItem(root: string, kind: string, name: string, description:
|
|
|
395
401
|
const wiki = store.wikiDir(root)
|
|
396
402
|
if (existing) {
|
|
397
403
|
try {
|
|
398
|
-
store.snapshot(wiki, id, readFileSync(existing.path, 'utf8'),
|
|
404
|
+
store.snapshot(wiki, id, readFileSync(existing.path, 'utf8'), actor, 'Before accepted proposal')
|
|
399
405
|
} catch {
|
|
400
406
|
// Nothing readable to keep.
|
|
401
407
|
}
|
|
402
408
|
}
|
|
403
409
|
atomicWrite(path, text)
|
|
404
|
-
store.snapshot(wiki, id, text,
|
|
405
|
-
store.log(wiki, `- ${now()} ·
|
|
410
|
+
store.snapshot(wiki, id, text, actor, reason)
|
|
411
|
+
store.log(wiki, `- ${now()} · ${actor} · ${existing ? 'updated' : 'created'} \`${id}\` · ${reason}`)
|
|
406
412
|
return id
|
|
407
413
|
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { mkdtempSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import type { AccountsOverview, AccountView } from '../accounts/store'
|
|
7
|
+
import { memorySecretStore } from '../accounts/secrets'
|
|
8
|
+
import { createHarness, type Harness } from '../harness'
|
|
9
|
+
import { rootWithProject, writeFile } from '../../tests/helpers'
|
|
10
|
+
import { SOCKET_ENV, TOKEN_ENV, socketCall, type SocketConnection } from './client'
|
|
11
|
+
import { accountDetails, accountRows, resetIn, runAccountsMenu, type AccountsCall, type AccountsUi, type SessionAccount } from './accountsMenu'
|
|
12
|
+
|
|
13
|
+
const open: Harness[] = []
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
for (const harness of open.splice(0)) await harness.close()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A person at the terminal. Each answer picks the first option that contains it (select), or
|
|
20
|
+
* is the typed text (input), or is the yes/no (confirm). A select with no matching option fails
|
|
21
|
+
* the test, so a changed menu label shows up here.
|
|
22
|
+
*/
|
|
23
|
+
function person(answers: (string | boolean | undefined)[]) {
|
|
24
|
+
const shown: string[] = []
|
|
25
|
+
const said: { message: string; level?: string }[] = []
|
|
26
|
+
const next = () => answers.shift()
|
|
27
|
+
const ui: AccountsUi = {
|
|
28
|
+
select: async (title, options) => {
|
|
29
|
+
shown.push(title)
|
|
30
|
+
const answer = next()
|
|
31
|
+
if (answer === undefined) return undefined
|
|
32
|
+
const found = options.find((option) => option.includes(String(answer)))
|
|
33
|
+
if (!found) throw new Error(`No option containing "${answer}" in: ${options.join(' | ')}`)
|
|
34
|
+
return found
|
|
35
|
+
},
|
|
36
|
+
confirm: async (title) => {
|
|
37
|
+
shown.push(title)
|
|
38
|
+
return next() === true
|
|
39
|
+
},
|
|
40
|
+
input: async (title) => {
|
|
41
|
+
shown.push(title)
|
|
42
|
+
const answer = next()
|
|
43
|
+
return typeof answer === 'string' ? answer : undefined
|
|
44
|
+
},
|
|
45
|
+
notify: (message, level) => void said.push({ message, level })
|
|
46
|
+
}
|
|
47
|
+
return { ui, shown, said, remaining: () => answers.length }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function view(id: string, label: string, overrides: Partial<AccountView> = {}): AccountView {
|
|
51
|
+
return {
|
|
52
|
+
id,
|
|
53
|
+
provider: 'pi',
|
|
54
|
+
label,
|
|
55
|
+
authKind: 'subscription',
|
|
56
|
+
createdAt: '2026-10-01T00:00:00.000Z',
|
|
57
|
+
isDefault: false,
|
|
58
|
+
state: { health: 'unknown', signedIn: null, authMethod: null, plan: null, identity: null, limits: [], lastError: null, lastCheckedAt: null, cliVersion: null, capabilities: [], piProviders: [] },
|
|
59
|
+
...overrides
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('the accounts menu, without a terminal', () => {
|
|
64
|
+
it('numbers each account, marks the default and this session, and shows its health', () => {
|
|
65
|
+
const overview: AccountsOverview = {
|
|
66
|
+
accounts: [view('a1', 'Main', { isDefault: true, state: { ...view('a1', 'Main').state, health: 'limited' } }), view('a2', 'Spare')],
|
|
67
|
+
pools: [],
|
|
68
|
+
defaults: { pi: 'a1' }
|
|
69
|
+
}
|
|
70
|
+
const rows = accountRows(overview, { accountId: 'a2', label: 'Spare' } satisfies SessionAccount)
|
|
71
|
+
expect(rows.map((row) => row.label)).toEqual(['1. ● Main · Limit reached', '2. ○ Spare · Not checked · this session'])
|
|
72
|
+
expect(rows[1].account.id).toBe('a2')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('says what the account can do and that providers do not report usage when none is reported', () => {
|
|
76
|
+
const text = accountDetails(view('a1', 'Main', { state: { ...view('a1', 'Main').state, health: 'auth_expired', piProviders: ['anthropic'], lastError: { category: 'rate_limited', message: 'Slow down.', at: '2026-10-01T00:00:00.000Z', resetsAt: null } } }))
|
|
77
|
+
expect(text).toContain('Health: Sign in again')
|
|
78
|
+
expect(text).toContain('Providers: anthropic')
|
|
79
|
+
expect(text).toContain('Last error: Slow down.')
|
|
80
|
+
expect(text).toContain('Pi providers do not report them.')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('formats a reset time as a short countdown', () => {
|
|
84
|
+
const now = Date.parse('2026-10-01T00:00:00.000Z')
|
|
85
|
+
expect(resetIn('2026-10-01T00:05:00.000Z', now)).toBe('in 5m')
|
|
86
|
+
expect(resetIn('2026-10-01T02:30:00.000Z', now)).toBe('in 2h 30m')
|
|
87
|
+
expect(resetIn('2026-10-03T00:00:00.000Z', now)).toBe('in 2d')
|
|
88
|
+
expect(resetIn('2026-09-30T00:00:00.000Z', now)).toBe('now')
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
async function session() {
|
|
93
|
+
const root = rootWithProject()
|
|
94
|
+
const base = mkdtempSync(join(tmpdir(), 'accounts-menu-'))
|
|
95
|
+
writeFile(join(root, 'registry/rules/tests.md'), '---\nname: Run tests\ndescription: Always test.\n---\nAlways run the tests.\n')
|
|
96
|
+
const harness = createHarness({ dataRoot: root, stateDir: join(base, 'state'), secrets: memorySecretStore(), piProgram: () => '/opt/pi/bin/pi', assets: { node: '/opt/node', cli: '/opt/harness/cli.js', piExtension: '/opt/harness/terminal-pi.js' } })
|
|
97
|
+
open.push(harness)
|
|
98
|
+
const main = harness.accounts.create('Main', 'apiKey').accounts[0]
|
|
99
|
+
const spare = harness.accounts.create('Spare', 'apiKey').accounts.find((account) => account.label === 'Spare')!
|
|
100
|
+
const launch = await harness.terminals.prepare({ sessionId: 'term-1', accountId: main.id, cwd: base, sessionFolder: join(root, 'sessions', 'term-1') })
|
|
101
|
+
const connection: SocketConnection = { socket: String(launch.env[SOCKET_ENV]), token: String(launch.env[TOKEN_ENV]) }
|
|
102
|
+
// The extension claims the key before the model runs; the menu uses it for every change.
|
|
103
|
+
const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
|
|
104
|
+
const call: AccountsCall = (method, params) => socketCall(connection, method, params ?? {}, 5_000, key)
|
|
105
|
+
return { harness, main, spare, connection, key, call }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe('the accounts menu, over the session socket', () => {
|
|
109
|
+
it('refuses account calls from the shell token, and serves them to the extension with its key', async () => {
|
|
110
|
+
const { connection, key, main, harness } = await session()
|
|
111
|
+
await expect(socketCall(connection, 'accounts.list')).rejects.toMatchObject({ code: 'unauthorized' })
|
|
112
|
+
await expect(socketCall(connection, 'accounts.list', {}, 5_000, 'guessed')).rejects.toMatchObject({ code: 'unauthorized' })
|
|
113
|
+
await expect(socketCall(connection, 'accounts.deviceId')).rejects.toMatchObject({ code: 'unauthorized' })
|
|
114
|
+
const listed = await socketCall<AccountsOverview>(connection, 'accounts.list', {}, 5_000, key)
|
|
115
|
+
expect(listed.accounts.map((account) => account.id)).toContain(main.id)
|
|
116
|
+
expect(await socketCall<{ accountId: string; label: string }>(connection, 'accounts.session', {}, 5_000, key)).toEqual({ accountId: main.id, label: 'Main' })
|
|
117
|
+
expect(await socketCall(connection, 'accounts.deviceId', {}, 5_000, key)).toBe(await harness.accounts.installationDeviceId())
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('will not remove an account a running terminal is using, this session included', async () => {
|
|
121
|
+
const { call, main, spare, harness } = await session()
|
|
122
|
+
await expect(call('accounts.remove', { accountId: main.id })).rejects.toMatchObject({ code: 'account_in_use' })
|
|
123
|
+
expect(harness.accounts.find(main.id).id).toBe(main.id)
|
|
124
|
+
// An account with no terminal on it is removed as usual.
|
|
125
|
+
await call('accounts.remove', { accountId: spare.id })
|
|
126
|
+
expect(() => harness.accounts.find(spare.id)).toThrow()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('makes an account the default from the menu', async () => {
|
|
130
|
+
const { call, harness, spare, main } = await session()
|
|
131
|
+
const { ui, said } = person(['Spare', 'Make default', 'Back', 'Done'])
|
|
132
|
+
expect(await runAccountsMenu(ui, call, { accountId: main.id, label: 'Main' })).toBeUndefined()
|
|
133
|
+
expect(harness.accounts.overview().defaults.pi).toBe(spare.id)
|
|
134
|
+
expect(said.map((entry) => entry.message)).toContain('Spare is the default for new sessions.')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('switches this session to another account by ending Pi, and leaves the choice for the launcher', async () => {
|
|
138
|
+
const { call, harness, spare, main } = await session()
|
|
139
|
+
const { ui } = person(['Spare', 'Use for this session', true])
|
|
140
|
+
const outcome = await runAccountsMenu(ui, call, { accountId: main.id, label: 'Main' })
|
|
141
|
+
expect(outcome).toBe('relaunch')
|
|
142
|
+
expect(harness.terminals.takeRelaunch('term-1')).toEqual({ accountId: spare.id, nativeSessionId: null })
|
|
143
|
+
// Taken once: a second read finds nothing.
|
|
144
|
+
expect(harness.terminals.takeRelaunch('term-1')).toBeNull()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('does not switch when the person declines the confirmation', async () => {
|
|
148
|
+
const { call, harness, spare, main } = await session()
|
|
149
|
+
const { ui } = person(['Spare', 'Use for this session', false, 'Back', 'Done'])
|
|
150
|
+
expect(await runAccountsMenu(ui, call, { accountId: main.id, label: 'Main' })).toBeUndefined()
|
|
151
|
+
expect(harness.terminals.takeRelaunch('term-1')).toBeNull()
|
|
152
|
+
expect(harness.accounts.find(spare.id).id).toBe(spare.id)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('signing in from the accounts menu', () => {
|
|
157
|
+
it('shows structured OAuth choices and returns the selected provider choice ID', async () => {
|
|
158
|
+
const overview: AccountsOverview = { accounts: [view('a1', 'Main')], pools: [], defaults: { pi: 'a1' } }
|
|
159
|
+
const answered: Record<string, unknown>[] = []
|
|
160
|
+
const statuses = [
|
|
161
|
+
{ loginId: 'L2', revision: 1, status: 'running', events: [], prompt: { type: 'prompt', promptId: 'P2', prompt: { type: 'select', message: 'Select OpenAI Codex login method:', options: [{ id: 'device_code', label: 'Device code login' }, { id: 'browser', label: 'Browser login' }] } }, success: null, error: null, accountId: 'a1', provider: 'openai-codex', method: 'oauth' },
|
|
162
|
+
{ loginId: 'L2', revision: 2, status: 'completed', events: [], prompt: null, success: true, error: null, accountId: 'a1', provider: 'openai-codex', method: 'oauth' }
|
|
163
|
+
]
|
|
164
|
+
const call = (async (method: string, params?: Record<string, unknown>) => {
|
|
165
|
+
switch (method) {
|
|
166
|
+
case 'accounts.list':
|
|
167
|
+
return overview
|
|
168
|
+
case 'accounts.providers':
|
|
169
|
+
return [{ id: 'openai-codex', name: 'OpenAI Codex', oauth: true, oauthLabel: 'Sign in with ChatGPT', apiKey: false }]
|
|
170
|
+
case 'accounts.loginStart':
|
|
171
|
+
return { loginId: 'L2' }
|
|
172
|
+
case 'accounts.loginStatus':
|
|
173
|
+
return statuses.shift()
|
|
174
|
+
case 'accounts.loginRespond':
|
|
175
|
+
answered.push(params!)
|
|
176
|
+
return {}
|
|
177
|
+
default:
|
|
178
|
+
throw new Error(`unexpected ${method}`)
|
|
179
|
+
}
|
|
180
|
+
}) as unknown as AccountsCall
|
|
181
|
+
const script = person(['Main', 'Sign in', 'OpenAI Codex', 'Device code login', 'Back', 'Done'])
|
|
182
|
+
|
|
183
|
+
expect(await runAccountsMenu(script.ui, call, { accountId: 'a1', label: 'Main' })).toBeUndefined()
|
|
184
|
+
|
|
185
|
+
expect(answered).toEqual([{ loginId: 'L2', promptId: 'P2', value: 'device_code' }])
|
|
186
|
+
expect(script.shown).toContain('Select OpenAI Codex login method:')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('follows a browser sign-in, answers its prompt, and reports the result', async () => {
|
|
190
|
+
const overview: AccountsOverview = { accounts: [view('a1', 'Main')], pools: [], defaults: { pi: 'a1' } }
|
|
191
|
+
const answered: Record<string, unknown>[] = []
|
|
192
|
+
const statuses = [
|
|
193
|
+
{ loginId: 'L1', revision: 1, status: 'running', events: [{ type: 'auth_url', url: 'https://auth.example/start' }], prompt: null, success: null, error: null, accountId: 'a1', provider: 'anthropic', method: 'oauth' },
|
|
194
|
+
{ loginId: 'L1', revision: 2, status: 'running', events: [], prompt: { type: 'prompt', promptId: 'P1', prompt: { type: 'secret', message: 'Paste the code', placeholder: null, options: null } }, success: null, error: null, accountId: 'a1', provider: 'anthropic', method: 'oauth' },
|
|
195
|
+
{ loginId: 'L1', revision: 3, status: 'completed', events: [], prompt: null, success: true, error: null, accountId: 'a1', provider: 'anthropic', method: 'oauth' }
|
|
196
|
+
]
|
|
197
|
+
const call = (async (method: string, params?: Record<string, unknown>) => {
|
|
198
|
+
switch (method) {
|
|
199
|
+
case 'accounts.list':
|
|
200
|
+
return overview
|
|
201
|
+
case 'accounts.session':
|
|
202
|
+
return { accountId: 'a1', label: 'Main' }
|
|
203
|
+
case 'accounts.providers':
|
|
204
|
+
return [{ id: 'anthropic', name: 'Anthropic', oauth: true, oauthLabel: 'Claude', apiKey: false }]
|
|
205
|
+
case 'accounts.loginStart':
|
|
206
|
+
expect(params).toEqual({ accountId: 'a1', provider: 'anthropic' })
|
|
207
|
+
return { loginId: 'L1' }
|
|
208
|
+
case 'accounts.loginStatus':
|
|
209
|
+
return statuses.shift()
|
|
210
|
+
case 'accounts.loginRespond':
|
|
211
|
+
answered.push(params!)
|
|
212
|
+
return {}
|
|
213
|
+
default:
|
|
214
|
+
throw new Error(`unexpected ${method}`)
|
|
215
|
+
}
|
|
216
|
+
}) as unknown as AccountsCall
|
|
217
|
+
// Account, then Sign in, then the provider; one sign-in method means no second pick. The
|
|
218
|
+
// browser step is answered with "Check status", and the prompt is typed as '123'.
|
|
219
|
+
const script = person(['Main', 'Sign in', 'Anthropic', 'Check status', '123', 'Back', 'Done'])
|
|
220
|
+
expect(await runAccountsMenu(script.ui, call, { accountId: 'a1', label: 'Main' })).toBeUndefined()
|
|
221
|
+
expect(answered).toEqual([{ loginId: 'L1', promptId: 'P1', value: '123' }])
|
|
222
|
+
expect(script.said.map((entry) => entry.message)).toContain('Signed in.')
|
|
223
|
+
expect(script.remaining()).toBe(0)
|
|
224
|
+
})
|
|
225
|
+
})
|