@xingular/harness 0.1.6 → 2.0.0
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 +25 -0
- package/NOTICE.md +38 -0
- package/README.md +139 -28
- package/dist/cli.js +9598 -2201
- package/dist/terminal-pi.js +808 -16
- package/docs/README.md +15 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
- package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
- package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
- package/docs/terminal-tui.md +344 -0
- package/package.json +11 -4
- package/src/accounts/accounts.test.ts +197 -1
- package/src/accounts/accounts.ts +266 -28
- package/src/cli.ts +7 -3
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +27 -4
- 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 +12 -2
- package/src/mcp/connections.ts +39 -12
- package/src/mcp/service.test.ts +49 -3
- package/src/mcp/service.ts +14 -3
- package/src/mcp/types.ts +10 -0
- 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 +206 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +367 -18
- 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 +11 -3
- package/src/memory/trajectory.test.ts +178 -0
- package/src/memory/trajectory.ts +220 -0
- package/src/memory/wiki.ts +2 -1
- package/src/memory/wikiIndex.ts +3 -2
- package/src/plugins/builtin.ts +4 -2
- package/src/plugins/catalogue.test.ts +1 -1
- package/src/server/methods.ts +43 -2
- package/src/server/server.test.ts +20 -1
- package/src/sessions/approvals.test.ts +49 -0
- package/src/sessions/approvals.ts +26 -4
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +83 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +145 -27
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +45 -4
- package/src/sessions/sessions.test.ts +137 -4
- package/src/sessions/sessions.ts +51 -15
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- 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 +134 -7
- 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 +91 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +55 -20
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +28 -7
- package/src/terminal/terminal.test.ts +220 -16
- package/src/terminal/terminals.ts +234 -16
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +290 -0
- package/src/terminal/tui/application.ts +653 -0
- package/src/terminal/tui/commands.test.ts +278 -0
- package/src/terminal/tui/commands.ts +443 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +113 -0
- package/src/terminal/tui/editor.ts +109 -0
- package/src/terminal/tui/keybindings.test.ts +30 -0
- package/src/terminal/tui/keybindings.ts +59 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -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 +178 -0
- package/src/terminal/tui/runtime.test.ts +225 -0
- package/src/terminal/tui/runtime.ts +403 -0
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -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/skillInvocation.test.ts +71 -0
- package/src/terminal/tui/skillInvocation.ts +51 -0
- package/src/terminal/tui/status.test.ts +48 -0
- package/src/terminal/tui/status.ts +82 -0
- package/src/terminal/tui/theme.ts +185 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +39 -0
- package/src/terminal/tui/transcript.ts +135 -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/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +29 -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 +47 -0
- package/src/tools/types.ts +2 -0
|
@@ -59,6 +59,23 @@ function host(hostCalls: Record<string, (params: any) => unknown> = {}) {
|
|
|
59
59
|
const idle = (): PiSessionLike => ({ sessionId: 'native-1', subscribe: () => () => undefined, prompt: async () => undefined, sendCustomMessage: async () => undefined, abort: async () => undefined, dispose: () => undefined })
|
|
60
60
|
|
|
61
61
|
describe('the harness served to a host', () => {
|
|
62
|
+
it('starts native subagents over RPC using the live parent launch', async () => {
|
|
63
|
+
const { transport, request } = host()
|
|
64
|
+
const harness = createHarness({
|
|
65
|
+
dataRoot: rootWithProject(), stateDir: mkdtempSync(join(tmpdir(), 'hs-')), secrets: memorySecretStore(),
|
|
66
|
+
sessionFactory: async ({ params }) => ({ ...idle(), ...(params.runId === 'rpc-parent' ? { prompt: async () => new Promise<void>(() => undefined) } : {}) })
|
|
67
|
+
})
|
|
68
|
+
open.push(harness)
|
|
69
|
+
serve(harness, transport)
|
|
70
|
+
harness.accounts.create('Main', 'apiKey')
|
|
71
|
+
await request('sessions.run', { run: { id: 'rpc-parent', sessionId: 'opaque-session' }, cwd: harness.env.root, sessionDir: join(harness.env.root, 'sessions'), text: 'Parent', caller: { agentId: 'caller' }, tools: { allow: ['read'] }, subagents: {} })
|
|
72
|
+
const child = await request('subagents.spawn', { parentRunId: 'rpc-parent', brief: 'Review', tools: ['read'] })
|
|
73
|
+
expect(await request('subagents.wait', { parentRunId: 'rpc-parent', runIds: [child.runId] })).toMatchObject([{ runId: child.runId, status: 'completed' }])
|
|
74
|
+
const { loadRun } = await import('../sessions/runStore')
|
|
75
|
+
expect(loadRun(harness.env.root, child.runId)).toMatchObject({ parentRunId: 'rpc-parent', relation: 'subagent', agentId: 'caller', status: 'completed' })
|
|
76
|
+
await request('runs.interrupt', { runId: 'rpc-parent' })
|
|
77
|
+
})
|
|
78
|
+
|
|
62
79
|
it('answers by method name, reports failures with their code, and forwards events and notices', async () => {
|
|
63
80
|
const { transport, request, events, notices } = host()
|
|
64
81
|
const harness = createHarness({ dataRoot: rootWithProject(), stateDir: mkdtempSync(join(tmpdir(), 'hs-')), secrets: memorySecretStore(), sessionFactory: async () => idle() })
|
|
@@ -67,7 +84,9 @@ describe('the harness served to a host', () => {
|
|
|
67
84
|
|
|
68
85
|
const status = await request('harness.status')
|
|
69
86
|
expect(status).toMatchObject({ dataRoot: harness.env.root, productName: 'Xingular Harness', activeRuns: [] })
|
|
70
|
-
expect(await request<string[]>('harness.methods')).toEqual(expect.arrayContaining(['accounts.list', 'sessions.run', 'wiki.pages', 'terminal.prepare', 'skills.list', 'skills.import', 'connections.list', 'harness.shutdown']))
|
|
87
|
+
expect(await request<string[]>('harness.methods')).toEqual(expect.arrayContaining(['accounts.list', 'sessions.run', 'sessions.spawnSubagent', 'subagents.spawn', 'subagents.wait', 'subagents.results', 'wiki.pages', 'terminal.prepare', 'skills.list', 'skills.import', 'connections.list', 'harness.shutdown']))
|
|
88
|
+
|
|
89
|
+
await expect(request('sessions.spawnSubagent', { parentRunId: 'missing', brief: 'Review' })).rejects.toMatchObject({ code: 'run_not_active' })
|
|
71
90
|
|
|
72
91
|
const overview = await request('accounts.create', { label: 'Main', authKind: 'apiKey' })
|
|
73
92
|
expect(overview.accounts).toHaveLength(1)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } 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 { Bus } from '../core/bus'
|
|
7
|
+
import { Approvals, grant } from './approvals'
|
|
8
|
+
|
|
9
|
+
const folders: string[] = []
|
|
10
|
+
afterEach(() => { for (const folder of folders.splice(0)) rmSync(folder, { recursive: true, force: true }) })
|
|
11
|
+
|
|
12
|
+
function state() {
|
|
13
|
+
const folder = mkdtempSync(join(tmpdir(), 'harness-approvals-'))
|
|
14
|
+
folders.push(folder)
|
|
15
|
+
const bus = new Bus()
|
|
16
|
+
return { folder, approvals: new Approvals(folder, bus) }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('standing approval policy', () => {
|
|
20
|
+
it('loads old files with no global tool grants', () => {
|
|
21
|
+
const { folder, approvals } = state()
|
|
22
|
+
writeFileSync(join(folder, 'approvals.json'), JSON.stringify({ auto: { pi: true }, remembered: { old: ['write'] } }))
|
|
23
|
+
|
|
24
|
+
expect(approvals.read()).toEqual({ auto: { pi: true }, globalTools: {}, remembered: { old: ['write'] } })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('persists a profile-wide tool grant, while read-only and other profiles stay isolated', () => {
|
|
28
|
+
const { folder, approvals } = state()
|
|
29
|
+
approvals.allowTool('pi', 'bash')
|
|
30
|
+
const policy = approvals.read()
|
|
31
|
+
|
|
32
|
+
expect(policy.globalTools).toEqual({ pi: ['bash'] })
|
|
33
|
+
expect(grant(policy, 'pi', 'terminal-one', 'bash')).toBe('remembered')
|
|
34
|
+
expect(grant(policy, 'pi', 'terminal-two', 'bash')).toBe('remembered')
|
|
35
|
+
expect(grant(policy, 'chat', 'terminal-two', 'bash')).toBeNull()
|
|
36
|
+
expect(grant(policy, 'pi', 'terminal-two', 'bash', { readOnly: true })).toBeNull()
|
|
37
|
+
expect(JSON.parse(readFileSync(join(folder, 'approvals.json'), 'utf8'))).toMatchObject({ globalTools: { pi: ['bash'] } })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('revokes a global tool grant without changing the full switch or session grants', () => {
|
|
41
|
+
const { approvals } = state()
|
|
42
|
+
approvals.setAuto('pi', true)
|
|
43
|
+
approvals.remember('one-session', 'write')
|
|
44
|
+
approvals.allowTool('pi', 'bash')
|
|
45
|
+
|
|
46
|
+
expect(approvals.revokeTool('pi', 'bash')).toEqual({ auto: { pi: true }, globalTools: {}, remembered: { 'one-session': ['write'] } })
|
|
47
|
+
expect(approvals.read().globalTools).toEqual({})
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// What the user has chosen to stop being asked about: an "always approve" switch
|
|
2
|
-
//
|
|
1
|
+
// What the user has chosen to stop being asked about: an "always approve" switch, tools allowed
|
|
2
|
+
// across a harness state directory, and tools remembered for one session.
|
|
3
3
|
import { chmodSync } from 'node:fs'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
|
|
@@ -14,6 +14,8 @@ export const APPROVAL_KEYS = ['chat', 'pi'] as const
|
|
|
14
14
|
export interface ApprovalPolicy {
|
|
15
15
|
/** Switch key → every approval is granted without asking. */
|
|
16
16
|
auto: Record<string, boolean>
|
|
17
|
+
/** Approval key → tools allowed across sessions that share this local state directory. */
|
|
18
|
+
globalTools: Record<string, string[]>
|
|
17
19
|
/** Session id → approval targets always allowed there. */
|
|
18
20
|
remembered: Record<string, string[]>
|
|
19
21
|
}
|
|
@@ -34,13 +36,14 @@ export interface ApprovalFacts {
|
|
|
34
36
|
/** Why the standing choices grant this request, if they do. */
|
|
35
37
|
export function decide(policy: ApprovalPolicy, key: string, sessionId: string, target: string): AutomaticReason | null {
|
|
36
38
|
if (policy.auto[key]) return 'always'
|
|
39
|
+
if (policy.globalTools[key]?.includes(target)) return 'remembered'
|
|
37
40
|
return policy.remembered[sessionId]?.includes(target) ? 'remembered' : null
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export function grant(policy: ApprovalPolicy, key: string, sessionId: string, target: string, facts: ApprovalFacts = {}): AutomaticReason | null {
|
|
41
44
|
if (facts.unattended) return null
|
|
42
45
|
// "Always approve" must not let a read-only session approve its way out of read-only mode.
|
|
43
|
-
return decide(facts.readOnly ? { auto: {}, remembered: policy.remembered } : policy, key, sessionId, target)
|
|
46
|
+
return decide(facts.readOnly ? { auto: {}, globalTools: {}, remembered: policy.remembered } : policy, key, sessionId, target)
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/** A conversation's runs share one switch whatever model they use; other runs follow their own. */
|
|
@@ -55,7 +58,7 @@ export class Approvals {
|
|
|
55
58
|
|
|
56
59
|
read(): ApprovalPolicy {
|
|
57
60
|
const stored = readJson<Partial<ApprovalPolicy>>(this.path) ?? {}
|
|
58
|
-
return { auto: stored.auto ?? {}, remembered: stored.remembered ?? {} }
|
|
61
|
+
return { auto: stored.auto ?? {}, globalTools: stored.globalTools ?? {}, remembered: stored.remembered ?? {} }
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
private change(update: (policy: ApprovalPolicy) => void): ApprovalPolicy {
|
|
@@ -90,6 +93,25 @@ export class Approvals {
|
|
|
90
93
|
return this.change((policy) => void (policy.auto[key] = on))
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
/** Always allow `target` for every session in this approval profile and state directory. */
|
|
97
|
+
allowTool(key: string, target: string): ApprovalPolicy {
|
|
98
|
+
if (!(APPROVAL_KEYS as readonly string[]).includes(key)) throw invalid('Unknown harness.')
|
|
99
|
+
if (!target.trim()) throw invalid('A tool is required.')
|
|
100
|
+
return this.change((policy) => {
|
|
101
|
+
const targets = (policy.globalTools[key] ??= [])
|
|
102
|
+
if (!targets.includes(target)) targets.push(target)
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
revokeTool(key: string, target: string): ApprovalPolicy {
|
|
107
|
+
if (!(APPROVAL_KEYS as readonly string[]).includes(key)) throw invalid('Unknown harness.')
|
|
108
|
+
return this.change((policy) => {
|
|
109
|
+
const targets = policy.globalTools[key]?.filter((item) => item !== target) ?? []
|
|
110
|
+
if (targets.length) policy.globalTools[key] = targets
|
|
111
|
+
else delete policy.globalTools[key]
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
93
115
|
/** Always allow `target` in this session. */
|
|
94
116
|
remember(sessionId: string, target: string): ApprovalPolicy {
|
|
95
117
|
if (!target.trim()) throw invalid('A tool is required.')
|
|
@@ -10,6 +10,7 @@ export const runEventTypeSchema = z.enum([
|
|
|
10
10
|
'context.snapshot',
|
|
11
11
|
'message.delta',
|
|
12
12
|
'message.completed',
|
|
13
|
+
'message.steered',
|
|
13
14
|
'tool.started',
|
|
14
15
|
'tool.completed',
|
|
15
16
|
'retry.started',
|
|
@@ -35,6 +36,8 @@ export type RunEventType = z.infer<typeof runEventTypeSchema>
|
|
|
35
36
|
|
|
36
37
|
const messageDeltaPayload = z.object({ messageId: z.string(), text: z.string() })
|
|
37
38
|
const messageCompletedPayload = z.object({ messageId: z.string(), role: z.enum(['user', 'assistant']), text: z.string() })
|
|
39
|
+
/** A message the person sent into the run while it was going; the model reads it from its next request on. */
|
|
40
|
+
const messageSteeredPayload = z.object({ text: z.string() })
|
|
38
41
|
const contextSnapshotPayload = z.object({
|
|
39
42
|
systemPrompt: z.string().max(100_000),
|
|
40
43
|
contextUpdate: z.string().max(100_000).optional(),
|
|
@@ -96,15 +99,37 @@ const approvalRequestedPayload = z.object({
|
|
|
96
99
|
const approvalResolvedPayload = z.object({ approvalId: z.string(), decision: z.enum(['approved', 'denied', 'native']), automatic: approvalAutomaticSchema.optional() })
|
|
97
100
|
export const questionOptionSchema = z.object({ label: z.string().min(1), description: z.string().optional() })
|
|
98
101
|
export type QuestionOption = z.infer<typeof questionOptionSchema>
|
|
102
|
+
const questionBatchFields = {
|
|
103
|
+
batchId: z.string().min(1).optional(),
|
|
104
|
+
batchIndex: z.number().int().min(1).max(5).optional(),
|
|
105
|
+
batchCount: z.number().int().min(1).max(5).optional()
|
|
106
|
+
}
|
|
107
|
+
const questionBatchRefinement = <T extends { batchId?: string; batchIndex?: number; batchCount?: number }>(value: T, context: z.RefinementCtx): void => {
|
|
108
|
+
const fields = [value.batchId, value.batchIndex, value.batchCount]
|
|
109
|
+
if (fields.some((field) => field !== undefined) && fields.some((field) => field === undefined)) {
|
|
110
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: 'Question batch fields must be supplied together.' })
|
|
111
|
+
}
|
|
112
|
+
if (value.batchIndex !== undefined && value.batchCount !== undefined && value.batchIndex > value.batchCount) {
|
|
113
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: 'Question batch index cannot exceed its count.' })
|
|
114
|
+
}
|
|
115
|
+
}
|
|
99
116
|
const questionRequestedPayload = z.object({
|
|
100
117
|
questionId: z.string(),
|
|
101
118
|
question: z.string().min(1),
|
|
119
|
+
summary: z.string().min(1).max(100).optional(),
|
|
102
120
|
options: z.array(questionOptionSchema),
|
|
103
121
|
multiSelect: z.boolean(),
|
|
104
122
|
allowText: z.boolean(),
|
|
105
|
-
userOnly: z.boolean().optional()
|
|
106
|
-
|
|
107
|
-
|
|
123
|
+
userOnly: z.boolean().optional(),
|
|
124
|
+
...questionBatchFields
|
|
125
|
+
}).superRefine(questionBatchRefinement)
|
|
126
|
+
const questionAnsweredPayload = z.object({
|
|
127
|
+
questionId: z.string(),
|
|
128
|
+
selected: z.array(z.string()),
|
|
129
|
+
text: z.string().optional(),
|
|
130
|
+
notes: z.record(z.string(), z.string().max(10_000)).optional(),
|
|
131
|
+
...questionBatchFields
|
|
132
|
+
}).superRefine(questionBatchRefinement)
|
|
108
133
|
/** A short emoji sequence, never words or numbers. */
|
|
109
134
|
const reactionAddedPayload = z.object({ emoji: z.string().min(1).refine((text) => Buffer.byteLength(text) <= 16 && !/[A-Za-z0-9\s]/.test(text)) })
|
|
110
135
|
/** A message this run's agent sent to (`to`) or received back from (`from`) another agent. */
|
|
@@ -196,6 +221,7 @@ export const runEventPayloadSchemas = {
|
|
|
196
221
|
'context.snapshot': contextSnapshotPayload,
|
|
197
222
|
'message.delta': messageDeltaPayload,
|
|
198
223
|
'message.completed': messageCompletedPayload,
|
|
224
|
+
'message.steered': messageSteeredPayload,
|
|
199
225
|
'tool.started': toolPayload,
|
|
200
226
|
'tool.completed': toolPayload,
|
|
201
227
|
'retry.started': retryStartedPayload,
|
|
@@ -56,6 +56,62 @@ async function setup() {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
describe('a whole reply, with a scripted model', () => {
|
|
59
|
+
it('joins async native children and persists one result receipt before the final reply', async () => {
|
|
60
|
+
const { harness, root, model, start, until, settled, of } = await setup()
|
|
61
|
+
const route = (context: { messages: Array<{ role: string; content: unknown }> }) => {
|
|
62
|
+
const users = context.messages.filter(message => message.role === 'user').map(message => typeof message.content === 'string' ? message.content : (message.content as Array<{ text?: string }>).map(part => part.text ?? '').join(''))
|
|
63
|
+
if (users.includes('native-child-brief')) return model.says('CHILD_EVIDENCE')
|
|
64
|
+
if (JSON.stringify(context.messages).includes('CHILD_EVIDENCE')) return model.says('Final with CHILD_EVIDENCE')
|
|
65
|
+
if (!context.messages.some(message => message.role === 'toolResult')) return model.calls(['spawn_subagent', { brief: 'native-child-brief' }])
|
|
66
|
+
return model.says('Draft before joining')
|
|
67
|
+
}
|
|
68
|
+
model.script(Array(8).fill(route))
|
|
69
|
+
const id = await start()
|
|
70
|
+
await until(() => settled(id))
|
|
71
|
+
expect(of(id).at(-1)?.type).toBe('completed')
|
|
72
|
+
expect(of(id).filter(event => event.type === 'message.completed').at(-1)?.payload).toMatchObject({ text: 'Final with CHILD_EVIDENCE' })
|
|
73
|
+
const { sessionFile } = await import('../prompt/compile')
|
|
74
|
+
const { readFileSync } = await import('node:fs')
|
|
75
|
+
const entries = readFileSync(sessionFile(join(root, 'sessions', 'a1'))!, 'utf8').trim().split('\n').map(line => JSON.parse(line))
|
|
76
|
+
expect(entries.filter(entry => entry.customType === 'xingular-subagent-results')).toHaveLength(1)
|
|
77
|
+
expect(model.requests()).toBeLessThanOrEqual(4)
|
|
78
|
+
expect(harness.runs.activeRunIds()).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('uses blocking native results without adding an unsolicited duplicate delivery', async () => {
|
|
82
|
+
const { root, model, start, until, settled, of } = await setup()
|
|
83
|
+
model.script([model.calls(['spawn_subagent', { brief: 'Find proof', wait: true }]), model.says('INLINE_CHILD_PROOF'), model.says('Final')])
|
|
84
|
+
const id = await start()
|
|
85
|
+
await until(() => settled(id))
|
|
86
|
+
expect(of(id).at(-1)?.type).toBe('completed')
|
|
87
|
+
expect(model.lastToolResults().join('')).toContain('INLINE_CHILD_PROOF')
|
|
88
|
+
const { sessionFile } = await import('../prompt/compile')
|
|
89
|
+
const { readFileSync } = await import('node:fs')
|
|
90
|
+
expect(readFileSync(sessionFile(join(root, 'sessions', 'a1'))!, 'utf8')).not.toContain('"customType":"xingular-subagent-results"')
|
|
91
|
+
expect(model.requests()).toBe(3)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('interrupts a parent waiting on a child question without deadlocking SDK abort', async () => {
|
|
95
|
+
const { harness, model, start, until, settled } = await setup()
|
|
96
|
+
const ai = await import('@earendil-works/pi-ai')
|
|
97
|
+
model.script([
|
|
98
|
+
model.calls(['spawn_subagent', { brief: 'Ask for a decision', wait: true }]),
|
|
99
|
+
ai.fauxAssistantMessage([ai.fauxToolCall('ask_user', { questions: [{ summary: 'A decision', question: 'Which approach?' }] })], { stopReason: 'toolUse' })
|
|
100
|
+
])
|
|
101
|
+
let childRunId: string | null = null
|
|
102
|
+
harness.runs.listen((event, run) => { if (event.type === 'question.requested' && run?.relation === 'subagent') childRunId = event.runId })
|
|
103
|
+
const id = await start()
|
|
104
|
+
await until(() => childRunId !== null || settled(id))
|
|
105
|
+
expect(childRunId).not.toBeNull()
|
|
106
|
+
await harness.runs.interrupt(id)
|
|
107
|
+
const { loadRun } = await import('./runStore')
|
|
108
|
+
expect(loadRun(harness.env.root, id).status).toBe('interrupted')
|
|
109
|
+
expect(loadRun(harness.env.root, childRunId!).status).toBe('interrupted')
|
|
110
|
+
const { readEvents } = await import('./runStore')
|
|
111
|
+
expect(readEvents(harness.env.root, childRunId!).some(event => event.type === 'question.answered')).toBe(true)
|
|
112
|
+
expect(harness.runs.activeRunIds()).toEqual([])
|
|
113
|
+
})
|
|
114
|
+
|
|
59
115
|
it('dispatches a registered command with notes without making a provider request', async () => {
|
|
60
116
|
const { harness, model, start, until, settled, of } = await setup()
|
|
61
117
|
const account = harness.accounts.context(harness.accounts.resolve(null, null))
|
|
@@ -147,4 +203,31 @@ describe('a whole reply, with a scripted model', () => {
|
|
|
147
203
|
await until(() => settled(id))
|
|
148
204
|
expect(model.lastToolResults().at(-1)).not.toContain('waiting in the Inbox')
|
|
149
205
|
})
|
|
206
|
+
|
|
207
|
+
it('delivers a message sent during a tool call after that call, before the next request', async () => {
|
|
208
|
+
const { harness, model, call, start, until, settled, of } = await setup()
|
|
209
|
+
model.script([model.calls(['bash', { command: 'ls' }]), model.says('Noted.')])
|
|
210
|
+
const id = await start({ tools: { allow: ['read', 'bash'], gated: ['bash'] } })
|
|
211
|
+
await until(() => of(id).some((event) => event.type === 'approval.requested'))
|
|
212
|
+
const approval = of(id).find((event) => event.type === 'approval.requested')!.payload as { approvalId: string }
|
|
213
|
+
await call('runs.steer', { runId: id, text: 'Also check the logs.' })
|
|
214
|
+
harness.runs.resolveApproval(id, approval.approvalId, 'denied')
|
|
215
|
+
await until(() => settled(id))
|
|
216
|
+
const types = of(id).map((event) => event.type)
|
|
217
|
+
expect(of(id).find((event) => event.type === 'message.steered')?.payload).toEqual({ text: 'Also check the logs.' })
|
|
218
|
+
expect(types.indexOf('message.steered')).toBeGreaterThan(types.indexOf('approval.resolved'))
|
|
219
|
+
expect(model.requests()).toBe(2)
|
|
220
|
+
// The context update reaches the model as a user-role message too; the prompt and the steer come last.
|
|
221
|
+
expect(model.lastUserTexts().slice(-2)).toEqual(['Go.', 'Also check the logs.'])
|
|
222
|
+
expect(of(id).at(-1)?.type).toBe('completed')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('refuses a message once the reply has ended, so the app sends it as a new reply', async () => {
|
|
226
|
+
const { call, model, start, until, settled, of } = await setup()
|
|
227
|
+
model.script([model.says('Done.')])
|
|
228
|
+
const id = await start()
|
|
229
|
+
await until(() => settled(id))
|
|
230
|
+
await expect(call('runs.steer', { runId: id, text: 'Too late.' })).rejects.toMatchObject({ code: 'run_not_active' })
|
|
231
|
+
expect(of(id).some((event) => event.type === 'message.steered')).toBe(false)
|
|
232
|
+
})
|
|
150
233
|
})
|
|
@@ -16,7 +16,7 @@ function directChildren(root: string, runId: string): RunSummary[] {
|
|
|
16
16
|
try {
|
|
17
17
|
return runFamily(root, runId).runs
|
|
18
18
|
.map(({ run }) => run)
|
|
19
|
-
.filter((run) => run.parentRunId === runId)
|
|
19
|
+
.filter((run) => run.parentRunId === runId && run.relation !== 'subagent')
|
|
20
20
|
.map((run) => ({ id: run.id, status: run.status, goal: run.goal, terminalReason: run.terminalReason }))
|
|
21
21
|
} catch {
|
|
22
22
|
return []
|
package/src/sessions/policy.ts
CHANGED
|
@@ -17,6 +17,10 @@ export interface ScopeDecision {
|
|
|
17
17
|
allowed: boolean
|
|
18
18
|
/** Not allowed as it stands, but a person may approve this one call. */
|
|
19
19
|
requiresApproval?: boolean
|
|
20
|
+
/** Per-tool remembered approvals never answer this call. */
|
|
21
|
+
humanOnly?: boolean
|
|
22
|
+
/** Whether the global always-approve switch may answer a human-only call. Defaults to false. */
|
|
23
|
+
allowGlobalApproval?: boolean
|
|
20
24
|
reason?: string | null
|
|
21
25
|
}
|
|
22
26
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { mkdtempSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, expect, test } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { closeIndex } from '../core/index'
|
|
7
|
+
import { runEventPayloadSchemas, runRecordSchema, type RunEvent } from './contracts'
|
|
8
|
+
import { RunRegistry } from './registry'
|
|
9
|
+
import { readEvents, saveRun } from './runStore'
|
|
10
|
+
|
|
11
|
+
const roots: string[] = []
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
for (const root of roots.splice(0)) closeIndex(root)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
function fixture() {
|
|
17
|
+
const root = mkdtempSync(join(tmpdir(), 'harness-question-batch-'))
|
|
18
|
+
roots.push(root)
|
|
19
|
+
saveRun(root, runRecordSchema.parse({
|
|
20
|
+
id: 'run-1',
|
|
21
|
+
sessionId: 'session-1',
|
|
22
|
+
projectId: null,
|
|
23
|
+
mode: 'chat',
|
|
24
|
+
harness: 'pi',
|
|
25
|
+
sessionType: 'structured',
|
|
26
|
+
nativeSessionId: null,
|
|
27
|
+
workingDirectory: null,
|
|
28
|
+
worktreeId: null,
|
|
29
|
+
goal: null,
|
|
30
|
+
status: 'running',
|
|
31
|
+
startedAt: 'start',
|
|
32
|
+
endedAt: null
|
|
33
|
+
}))
|
|
34
|
+
const registry = new RunRegistry({ root: () => root })
|
|
35
|
+
const run = registry.start('session-1', 'run-1')
|
|
36
|
+
return { root, registry, run }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('question batches validate all answers before resolving and retain choice notes', async () => {
|
|
40
|
+
const { root, registry, run } = fixture()
|
|
41
|
+
expect(() => run.requestAnswers([])).toThrow(/between one and five/)
|
|
42
|
+
expect(() => run.requestAnswers(Array.from({ length: 6 }, () => ({ question: 'Q', options: [], multiSelect: false, allowText: true })))).toThrow(/between one and five/)
|
|
43
|
+
|
|
44
|
+
const result = run.requestAnswers([
|
|
45
|
+
{ summary: 'Project scope', question: 'What should change?', options: [{ label: 'UI' }, { label: 'API' }], multiSelect: true, allowText: true },
|
|
46
|
+
{ summary: 'Target date', question: 'When should it ship?', options: [{ label: 'Soon' }, { label: 'Later' }], multiSelect: false, allowText: false }
|
|
47
|
+
])
|
|
48
|
+
let settled = false
|
|
49
|
+
void result.then(() => { settled = true })
|
|
50
|
+
|
|
51
|
+
const requested = readEvents(root, 'run-1').filter((event) => event.type === 'question.requested')
|
|
52
|
+
expect(requested).toHaveLength(2)
|
|
53
|
+
const [first, second] = requested.map((event) => event.payload as { questionId: string; summary: string; batchId: string; batchIndex: number; batchCount: number })
|
|
54
|
+
expect(first).toMatchObject({ summary: 'Project scope', batchIndex: 1, batchCount: 2 })
|
|
55
|
+
expect(second).toMatchObject({ summary: 'Target date', batchId: first.batchId, batchIndex: 2, batchCount: 2 })
|
|
56
|
+
expect(() => registry.answerQuestion('run-1', first.questionId, { selected: ['UI'] })).toThrow(/together/)
|
|
57
|
+
|
|
58
|
+
expect(() => registry.answerQuestions('run-1', first.batchId, [
|
|
59
|
+
{ questionId: first.questionId, answer: { selected: ['UI'] } },
|
|
60
|
+
{ questionId: second.questionId, answer: { selected: ['Later'], notes: { Soon: 'wrong choice' } } }
|
|
61
|
+
])).toThrow(/selected choices/)
|
|
62
|
+
await Promise.resolve()
|
|
63
|
+
expect(settled).toBe(false)
|
|
64
|
+
|
|
65
|
+
registry.answerQuestions('run-1', first.batchId, [
|
|
66
|
+
{ questionId: second.questionId, answer: { selected: ['Soon'] } },
|
|
67
|
+
{ questionId: first.questionId, answer: { selected: ['UI'], notes: { UI: 'Keep keyboard navigation.' } } }
|
|
68
|
+
])
|
|
69
|
+
await expect(result).resolves.toEqual([
|
|
70
|
+
{ selected: ['UI'], notes: { UI: 'Keep keyboard navigation.' } },
|
|
71
|
+
{ selected: ['Soon'] }
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
const answered = readEvents(root, 'run-1').filter((event) => event.type === 'question.answered')
|
|
75
|
+
expect(answered).toHaveLength(2)
|
|
76
|
+
expect(answered.find((event) => (event.payload as { questionId: string }).questionId === first.questionId)?.payload).toMatchObject({
|
|
77
|
+
batchId: first.batchId,
|
|
78
|
+
batchIndex: 1,
|
|
79
|
+
batchCount: 2,
|
|
80
|
+
notes: { UI: 'Keep keyboard navigation.' }
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('legacy single questions remain valid alongside batch event metadata', () => {
|
|
85
|
+
const { root, registry, run } = fixture()
|
|
86
|
+
const answer = run.requestAnswer({ question: 'Continue?', options: [{ label: 'Yes' }], multiSelect: false, allowText: false })
|
|
87
|
+
const request = readEvents(root, 'run-1').find((event) => event.type === 'question.requested')!
|
|
88
|
+
expect(runEventPayloadSchemas['question.requested'].safeParse(request.payload).success).toBe(true)
|
|
89
|
+
expect(runEventPayloadSchemas['question.requested'].safeParse({ ...(request.payload as object), batchId: 'partial' }).success).toBe(false)
|
|
90
|
+
registry.answerQuestion('run-1', (request.payload as { questionId: string }).questionId, { selected: ['Yes'] })
|
|
91
|
+
return expect(answer).resolves.toEqual({ selected: ['Yes'] })
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('answer batch method rejects duplicate and foreign question ids without settling the batch', async () => {
|
|
95
|
+
const { root, registry, run } = fixture()
|
|
96
|
+
const result = run.requestAnswers([
|
|
97
|
+
{ summary: 'First', question: 'First?', options: [], multiSelect: false, allowText: true },
|
|
98
|
+
{ summary: 'Second', question: 'Second?', options: [], multiSelect: false, allowText: true }
|
|
99
|
+
])
|
|
100
|
+
const requested = readEvents(root, 'run-1').filter((event) => event.type === 'question.requested')
|
|
101
|
+
const [first, second] = requested.map((event) => event.payload as { questionId: string; batchId: string })
|
|
102
|
+
const answers = [{ questionId: first.questionId, answer: { selected: [], text: 'a' } }, { questionId: second.questionId, answer: { selected: [], text: 'b' } }]
|
|
103
|
+
expect(() => registry.answerQuestions('run-1', 'other-batch', answers)).toThrow(/batch/)
|
|
104
|
+
expect(() => registry.answerQuestions('run-1', first.batchId, [answers[0], answers[0]])).toThrow(/batch/)
|
|
105
|
+
expect(() => registry.answerQuestions('another-run', first.batchId, answers)).toThrow(/batch/)
|
|
106
|
+
const events: RunEvent[] = readEvents(root, 'run-1')
|
|
107
|
+
expect(events.filter((event) => event.type === 'question.answered')).toHaveLength(0)
|
|
108
|
+
registry.answerQuestions('run-1', first.batchId, answers)
|
|
109
|
+
await expect(result).resolves.toHaveLength(2)
|
|
110
|
+
})
|