@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
package/src/sessions/registry.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface ApprovalInput {
|
|
|
27
27
|
export interface QuestionInput {
|
|
28
28
|
requestId?: string
|
|
29
29
|
userOnly?: boolean
|
|
30
|
+
summary?: string
|
|
30
31
|
question: string
|
|
31
32
|
options: QuestionOption[]
|
|
32
33
|
multiSelect: boolean
|
|
@@ -36,6 +37,21 @@ export interface QuestionInput {
|
|
|
36
37
|
export interface QuestionAnswer {
|
|
37
38
|
selected: string[]
|
|
38
39
|
text?: string
|
|
40
|
+
/** Optional notes keyed by the selected choice label. */
|
|
41
|
+
notes?: Record<string, string>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface QuestionBatchMetadata {
|
|
45
|
+
batchId: string
|
|
46
|
+
batchIndex: number
|
|
47
|
+
batchCount: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface PendingQuestion {
|
|
51
|
+
runId: string
|
|
52
|
+
input: QuestionInput
|
|
53
|
+
batch?: QuestionBatchMetadata
|
|
54
|
+
resolve: (answer: QuestionAnswer) => void
|
|
39
55
|
}
|
|
40
56
|
|
|
41
57
|
export interface ActiveRun {
|
|
@@ -43,10 +59,14 @@ export interface ActiveRun {
|
|
|
43
59
|
sessionId: string
|
|
44
60
|
emit(type: RunEventType, payload: unknown, native?: unknown): void
|
|
45
61
|
onInterrupt(handler: () => Promise<void> | void): void
|
|
62
|
+
/** Hands a message to the model after its current step; the session registers how. */
|
|
63
|
+
onSteer(handler: (text: string) => Promise<void> | void): void
|
|
46
64
|
/** Resolves when the user decides; only the approval with this exact id can be resolved by it. */
|
|
47
65
|
requestApproval(input: ApprovalInput): Promise<ApprovalDecision>
|
|
48
66
|
/** Asks the user a question and resolves with their answer. */
|
|
49
67
|
requestAnswer(input: QuestionInput): Promise<QuestionAnswer>
|
|
68
|
+
/** Asks one to five related questions and resolves only after the whole batch is answered. */
|
|
69
|
+
requestAnswers(inputs: QuestionInput[]): Promise<QuestionAnswer[]>
|
|
50
70
|
finish(outcome: 'completed' | 'failed' | 'interrupted', failure?: { code: string; message: string }): void
|
|
51
71
|
readonly finished: boolean
|
|
52
72
|
}
|
|
@@ -64,13 +84,14 @@ export interface RegistryOptions {
|
|
|
64
84
|
redact?: (event: RunEvent) => void
|
|
65
85
|
}
|
|
66
86
|
|
|
67
|
-
type LiveRun = ActiveRun & { interrupt: () => Promise<void> }
|
|
87
|
+
type LiveRun = ActiveRun & { interrupt: () => Promise<void>; steer: (text: string) => Promise<void> }
|
|
68
88
|
|
|
69
89
|
export class RunRegistry {
|
|
70
90
|
private readonly runs = new Map<string, LiveRun>()
|
|
71
91
|
private readonly approvals = new Map<string, { runId: string; resolve: (decision: ApprovalDecision) => void }>()
|
|
72
|
-
private readonly questions = new Map<string,
|
|
92
|
+
private readonly questions = new Map<string, PendingQuestion>()
|
|
73
93
|
private readonly nativeCleared = new Map<string, string>()
|
|
94
|
+
private readonly waiters = new Map<string, Array<() => void>>()
|
|
74
95
|
private readonly stopping = new Set<string>()
|
|
75
96
|
private readonly listeners: RunListener[] = []
|
|
76
97
|
|
|
@@ -110,10 +131,34 @@ export class RunRegistry {
|
|
|
110
131
|
sequence = 0
|
|
111
132
|
}
|
|
112
133
|
let finished = false
|
|
134
|
+
let finishing = false
|
|
113
135
|
const interruptHandlers: Array<() => Promise<void> | void> = []
|
|
136
|
+
let steerHandler: ((text: string) => Promise<void> | void) | null = null
|
|
114
137
|
const waitingStatus = (): string =>
|
|
115
138
|
[...this.approvals.values()].some((request) => request.runId === runId) ? 'waiting_for_approval' : [...this.questions.values()].some((request) => request.runId === runId) ? 'waiting_for_input' : 'running'
|
|
116
139
|
|
|
140
|
+
const requestQuestion = (input: QuestionInput, batch?: QuestionBatchMetadata, statusEvent = true): Promise<QuestionAnswer> => {
|
|
141
|
+
const { requestId, ...payload } = input
|
|
142
|
+
const questionId = requestId ?? randomUUID()
|
|
143
|
+
if (finished || finishing || this.nativeCleared.delete(questionId)) return Promise.resolve({ selected: [] })
|
|
144
|
+
if (this.questions.has(questionId)) throw new HarnessError('invalid_request', 'This question id is already pending.')
|
|
145
|
+
return new Promise<QuestionAnswer>((resolve) => {
|
|
146
|
+
this.questions.set(questionId, {
|
|
147
|
+
runId,
|
|
148
|
+
input,
|
|
149
|
+
...(batch ? { batch } : {}),
|
|
150
|
+
resolve: (answer) => {
|
|
151
|
+
this.questions.delete(questionId)
|
|
152
|
+
emit('question.answered', { questionId, ...answer, ...(batch ?? {}) })
|
|
153
|
+
emit('status.changed', { status: waitingStatus() })
|
|
154
|
+
resolve(answer)
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
emit('question.requested', { questionId, ...payload, ...(batch ?? {}) })
|
|
158
|
+
if (statusEvent) emit('status.changed', { status: 'waiting_for_input' })
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
117
162
|
const emit = (type: RunEventType, payload: unknown, native?: unknown): void => {
|
|
118
163
|
if (finished) return
|
|
119
164
|
const parsed = runEventSchema.safeParse({ id: randomUUID(), runId, sessionId, sequence, timestamp: new Date().toISOString(), type, payload, ...(native === undefined ? {} : { native }) })
|
|
@@ -130,13 +175,20 @@ export class RunRegistry {
|
|
|
130
175
|
sessionId,
|
|
131
176
|
emit,
|
|
132
177
|
onInterrupt: (handler) => void interruptHandlers.push(handler),
|
|
178
|
+
onSteer: (handler) => {
|
|
179
|
+
steerHandler = handler
|
|
180
|
+
},
|
|
181
|
+
steer: async (text) => {
|
|
182
|
+
if (finished || !steerHandler) throw new HarnessError('run_not_active', 'This run is not accepting messages.')
|
|
183
|
+
await steerHandler(text)
|
|
184
|
+
},
|
|
133
185
|
requestApproval: async (input) => {
|
|
134
186
|
const { requestId, standing = 'full', timeoutMs, ...payload } = input
|
|
135
187
|
const approvalId = requestId ?? randomUUID()
|
|
136
188
|
// What the user already allowed is known here; if it cannot be read, ask as usual.
|
|
137
189
|
const record = findRun(this.options.root(), runId)
|
|
138
190
|
const automatic = record && this.options.automatic && standing !== 'none' ? await this.options.automatic(record, input.target, input.operation, standing).catch(() => null) : null
|
|
139
|
-
if (finished) return 'denied'
|
|
191
|
+
if (finished || finishing) return 'denied'
|
|
140
192
|
if (this.nativeCleared.delete(approvalId)) return 'native'
|
|
141
193
|
if (automatic) {
|
|
142
194
|
// Marked on the request too, so nothing treats it as waiting on someone.
|
|
@@ -160,35 +212,35 @@ export class RunRegistry {
|
|
|
160
212
|
})
|
|
161
213
|
})
|
|
162
214
|
},
|
|
163
|
-
requestAnswer: (input) =>
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
215
|
+
requestAnswer: (input) => requestQuestion(input),
|
|
216
|
+
requestAnswers: (inputs) => {
|
|
217
|
+
if (!Array.isArray(inputs) || inputs.length < 1 || inputs.length > 5) {
|
|
218
|
+
throw new HarnessError('invalid_request', 'A question batch must contain between one and five questions.')
|
|
219
|
+
}
|
|
220
|
+
const requestedIds = inputs.flatMap((input) => input.requestId ? [input.requestId] : [])
|
|
221
|
+
if (new Set(requestedIds).size !== requestedIds.length || requestedIds.some((id) => this.questions.has(id))) {
|
|
222
|
+
throw new HarnessError('invalid_request', 'Question ids in a batch must be unique and not already pending.')
|
|
223
|
+
}
|
|
224
|
+
if (finished || finishing) return Promise.resolve(inputs.map(() => ({ selected: [] })))
|
|
225
|
+
const batchId = randomUUID()
|
|
226
|
+
const answers = inputs.map((input, index) => requestQuestion(input, { batchId, batchIndex: index + 1, batchCount: inputs.length }, false))
|
|
168
227
|
emit('status.changed', { status: 'waiting_for_input' })
|
|
169
|
-
return
|
|
170
|
-
this.questions.set(questionId, {
|
|
171
|
-
runId,
|
|
172
|
-
input,
|
|
173
|
-
resolve: (answer) => {
|
|
174
|
-
this.questions.delete(questionId)
|
|
175
|
-
emit('question.answered', { questionId, ...answer })
|
|
176
|
-
emit('status.changed', { status: waitingStatus() })
|
|
177
|
-
resolve(answer)
|
|
178
|
-
}
|
|
179
|
-
})
|
|
180
|
-
})
|
|
228
|
+
return Promise.all(answers)
|
|
181
229
|
},
|
|
182
230
|
finish: (outcome, failure) => {
|
|
183
|
-
if (finished) return
|
|
231
|
+
if (finished || finishing) return
|
|
232
|
+
finishing = true
|
|
233
|
+
// Persist cancellation receipts while emit is still open, so every interface clears them.
|
|
234
|
+
for (const [, approval] of [...this.approvals]) if (approval.runId === runId) approval.resolve('denied')
|
|
235
|
+
for (const [, question] of [...this.questions]) if (question.runId === runId) question.resolve({ selected: [] })
|
|
184
236
|
if (outcome === 'failed') emit('failed', failure ?? { code: 'failed', message: 'The run failed.' })
|
|
185
237
|
else if (outcome === 'interrupted') emit('status.changed', { status: 'interrupted' })
|
|
186
238
|
else emit('completed', {})
|
|
187
239
|
finished = true
|
|
188
240
|
this.runs.delete(runId)
|
|
241
|
+
for (const resolve of [...(this.waiters.get(runId) ?? [])]) resolve()
|
|
242
|
+
this.waiters.delete(runId)
|
|
189
243
|
for (const [id, owner] of this.nativeCleared) if (owner === runId) this.nativeCleared.delete(id)
|
|
190
|
-
for (const [, approval] of [...this.approvals]) if (approval.runId === runId) approval.resolve('denied')
|
|
191
|
-
for (const [, question] of [...this.questions]) if (question.runId === runId) question.resolve({ selected: [] })
|
|
192
244
|
try {
|
|
193
245
|
this.options.ended?.(runId, sessionId)
|
|
194
246
|
} catch (error) {
|
|
@@ -199,6 +251,8 @@ export class RunRegistry {
|
|
|
199
251
|
return finished
|
|
200
252
|
},
|
|
201
253
|
interrupt: async () => {
|
|
254
|
+
// Release approvals/questions before SDK abort waits for their blocked tools.
|
|
255
|
+
run.finish('interrupted')
|
|
202
256
|
for (const handler of interruptHandlers) {
|
|
203
257
|
try {
|
|
204
258
|
await handler()
|
|
@@ -206,7 +260,6 @@ export class RunRegistry {
|
|
|
206
260
|
log(`an interrupt handler failed for run ${runId}`, error instanceof Error ? error.message : String(error))
|
|
207
261
|
}
|
|
208
262
|
}
|
|
209
|
-
run.finish('interrupted')
|
|
210
263
|
}
|
|
211
264
|
}
|
|
212
265
|
|
|
@@ -232,6 +285,30 @@ export class RunRegistry {
|
|
|
232
285
|
return false
|
|
233
286
|
}
|
|
234
287
|
|
|
288
|
+
/** Wait for a recorded run to settle without polling storage or retaining an event listener. */
|
|
289
|
+
wait(runId: string, signal?: AbortSignal): Promise<void> {
|
|
290
|
+
const record = findRun(this.options.root(), runId)
|
|
291
|
+
if (!record) throw new HarnessError('not_found', 'Run was not found.')
|
|
292
|
+
if (!isActiveStatus(record.status)) return Promise.resolve()
|
|
293
|
+
if (!this.runs.has(runId)) throw new HarnessError('run_not_active', 'This run is not active in this process.')
|
|
294
|
+
if (signal?.aborted) return Promise.reject(new HarnessError('run_not_active', 'The wait was interrupted.'))
|
|
295
|
+
return new Promise((resolve, reject) => {
|
|
296
|
+
const cleanup = () => {
|
|
297
|
+
signal?.removeEventListener('abort', abort)
|
|
298
|
+
const pending = this.waiters.get(runId)
|
|
299
|
+
const index = pending?.indexOf(done) ?? -1
|
|
300
|
+
if (index >= 0) pending!.splice(index, 1)
|
|
301
|
+
if (!pending?.length) this.waiters.delete(runId)
|
|
302
|
+
}
|
|
303
|
+
const done = () => { cleanup(); resolve() }
|
|
304
|
+
const abort = () => { cleanup(); reject(new HarnessError('run_not_active', 'The wait was interrupted.')) }
|
|
305
|
+
const pending = this.waiters.get(runId) ?? []
|
|
306
|
+
pending.push(done)
|
|
307
|
+
this.waiters.set(runId, pending)
|
|
308
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
235
312
|
/** The live run with this id, for a tool or broker that reports into it. */
|
|
236
313
|
active(runId: string): ActiveRun | null {
|
|
237
314
|
return this.runs.get(runId) ?? null
|
|
@@ -243,6 +320,13 @@ export class RunRegistry {
|
|
|
243
320
|
return null
|
|
244
321
|
}
|
|
245
322
|
|
|
323
|
+
/** Hands a message to a live run's model after its current step. Throws `run_not_active` when the run cannot take it. */
|
|
324
|
+
async steer(runId: string, text: string): Promise<void> {
|
|
325
|
+
const run = this.runs.get(runId)
|
|
326
|
+
if (!run) throw new HarnessError('run_not_active', 'This run is not active.')
|
|
327
|
+
await run.steer(text)
|
|
328
|
+
}
|
|
329
|
+
|
|
246
330
|
async interrupt(runId: string): Promise<void> {
|
|
247
331
|
const run = this.runs.get(runId)
|
|
248
332
|
if (!run) throw new HarnessError('run_not_active', 'This run is not active.')
|
|
@@ -272,10 +356,44 @@ export class RunRegistry {
|
|
|
272
356
|
answerQuestion(runId: string, questionId: string, answer: QuestionAnswer): void {
|
|
273
357
|
const question = this.questions.get(questionId)
|
|
274
358
|
if (!question || question.runId !== runId) throw new HarnessError('question_not_pending', 'This question is not waiting for an answer.')
|
|
275
|
-
if (
|
|
276
|
-
|
|
359
|
+
if (question.batch) throw new HarnessError('question_batch_required', 'Answer every question in this batch together.')
|
|
360
|
+
question.resolve(this.validateAnswer(question.input, answer))
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
answerQuestions(runId: string, batchId: string, answers: Array<{ questionId: string; answer: QuestionAnswer }>): void {
|
|
364
|
+
const questions = [...this.questions.entries()].filter(([, pending]) => pending.runId === runId && pending.batch?.batchId === batchId)
|
|
365
|
+
if (!questions.length || answers.length !== questions.length || new Set(answers.map(({ questionId }) => questionId)).size !== answers.length) {
|
|
366
|
+
throw new HarnessError('question_not_pending', 'This question batch is not waiting for an answer.')
|
|
367
|
+
}
|
|
368
|
+
const pendingById = new Map(questions)
|
|
369
|
+
const validated = answers.map(({ questionId, answer }) => {
|
|
370
|
+
const pending = pendingById.get(questionId)
|
|
371
|
+
if (!pending) throw new HarnessError('question_not_pending', 'Every question in the batch must be answered together.')
|
|
372
|
+
return { pending, answer: this.validateAnswer(pending.input, answer) }
|
|
373
|
+
})
|
|
374
|
+
for (const { pending, answer } of validated) pending.resolve(answer)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private validateAnswer(input: QuestionInput, answer: QuestionAnswer): QuestionAnswer {
|
|
378
|
+
const selected = answer.selected
|
|
379
|
+
const selectedLabels = new Set(selected)
|
|
380
|
+
const notes: Record<string, string> = {}
|
|
381
|
+
for (const [label, note] of Object.entries(answer.notes ?? {})) {
|
|
382
|
+
const normalized = typeof note === 'string' ? note.trim() : ''
|
|
383
|
+
if (normalized) notes[label] = normalized
|
|
384
|
+
}
|
|
385
|
+
const text = answer.text?.trim()
|
|
386
|
+
if (
|
|
387
|
+
(!input.multiSelect && selected.length > 1) ||
|
|
388
|
+
selectedLabels.size !== selected.length ||
|
|
389
|
+
selected.some((label) => !input.options.some((option) => option.label === label)) ||
|
|
390
|
+
(text && !input.allowText) ||
|
|
391
|
+
Object.keys(notes).some((label) => !selectedLabels.has(label)) ||
|
|
392
|
+
Object.values(notes).some((note) => note.length > 10_000)
|
|
393
|
+
) {
|
|
394
|
+
throw new HarnessError('invalid_answer', 'Choose the offered options or an allowed text answer, and attach notes only to selected choices.')
|
|
277
395
|
}
|
|
278
|
-
|
|
396
|
+
return { selected, ...(text ? { text } : {}), ...(Object.keys(notes).length ? { notes } : {}) }
|
|
279
397
|
}
|
|
280
398
|
|
|
281
399
|
activeRunIds(): string[] {
|
package/src/sessions/runStore.ts
CHANGED
|
@@ -74,6 +74,20 @@ export function findRun(root: string, runId: string): RunRecord | null {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** A parent's reserved child IDs survive a crash before the spawn tool returns. */
|
|
78
|
+
export function recordSubagent(root: string, parentRunId: string, childRunId: string): void {
|
|
79
|
+
loadRun(root, parentRunId)
|
|
80
|
+
const ids = recordedSubagents(root, parentRunId)
|
|
81
|
+
if (!ids.includes(childRunId)) writeJson(join(runDir(root, parentRunId), 'subagents.json'), [...ids, childRunId])
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function recordedSubagents(root: string, parentRunId: string): string[] {
|
|
85
|
+
const value = readJson(join(runDir(root, parentRunId), 'subagents.json'))
|
|
86
|
+
if (value === null) return []
|
|
87
|
+
if (!Array.isArray(value) || value.some(id => typeof id !== 'string')) throw new Error('Recorded subagent ownership is unreadable.')
|
|
88
|
+
return value as string[]
|
|
89
|
+
}
|
|
90
|
+
|
|
77
91
|
/** Changes a run's record. A settled run keeps its outcome whatever the change says. */
|
|
78
92
|
export function updateRun(root: string, runId: string, update: (record: RunRecord) => void): void {
|
|
79
93
|
const record = readRecord(recordPath(root, runId))
|
package/src/sessions/runner.ts
CHANGED
|
@@ -23,6 +23,9 @@ export type Effort = (typeof EFFORTS)[number]
|
|
|
23
23
|
/** What one turn needs once its prompt is compiled and its tools are chosen. */
|
|
24
24
|
export interface TurnParams {
|
|
25
25
|
runId: string
|
|
26
|
+
/** Child runs may not gain tools registered by extensions outside their snapshot. */
|
|
27
|
+
restrictTools?: boolean
|
|
28
|
+
settle?: () => Promise<void>
|
|
26
29
|
cwd: string
|
|
27
30
|
sessionDir: string
|
|
28
31
|
/** The native history to open, or `null` to start a fresh one. */
|
|
@@ -77,13 +80,19 @@ export type PiEvent =
|
|
|
77
80
|
|
|
78
81
|
export interface PiSessionLike {
|
|
79
82
|
sessionId: string
|
|
83
|
+
sessionManager?: { getBranch(): unknown[] }
|
|
84
|
+
thinkingLevel?: Effort
|
|
80
85
|
systemPrompt?: string
|
|
81
86
|
extensionCommands?: readonly string[]
|
|
82
|
-
model?: { input: string[]; contextWindow?: number }
|
|
87
|
+
model?: { input: string[]; contextWindow?: number; id?: string; provider?: string }
|
|
83
88
|
getActiveToolNames?(): string[]
|
|
84
89
|
getAllTools?(): Array<{ name: string; description?: string; parameters?: unknown }>
|
|
85
90
|
subscribe(listener: (event: PiEvent) => void): () => void
|
|
86
91
|
prompt(text: string, options?: { images?: unknown[] }): Promise<void>
|
|
92
|
+
/** Queues a message for the model after the current step. Resolves `handled` when Pi takes it without queueing. */
|
|
93
|
+
steer?(text: string): Promise<'handled' | 'queued'>
|
|
94
|
+
/** Drops queued messages Pi has not delivered. */
|
|
95
|
+
clearQueue?(): unknown
|
|
87
96
|
sendCustomMessage?(message: { customType: string; content: string; display: boolean; details: unknown }, options: { triggerTurn: boolean }): Promise<void>
|
|
88
97
|
abort(): Promise<void>
|
|
89
98
|
dispose(): void
|
|
@@ -255,11 +264,35 @@ export async function runTurn(run: ActiveRun, session: PiSessionLike, params: Tu
|
|
|
255
264
|
await session.abort()
|
|
256
265
|
})
|
|
257
266
|
|
|
267
|
+
// Steers handed to Pi and not yet delivered, oldest first. Pi delivers them in that order, after
|
|
268
|
+
// the prompt's own user message, which is the first user message it starts.
|
|
269
|
+
const pendingSteers: string[] = []
|
|
270
|
+
let accepting = false
|
|
271
|
+
let promptStarted = false
|
|
272
|
+
const forgetSteer = (text: string): void => {
|
|
273
|
+
const index = pendingSteers.lastIndexOf(text)
|
|
274
|
+
if (index >= 0) pendingSteers.splice(index, 1)
|
|
275
|
+
}
|
|
276
|
+
run.onSteer(async (text) => {
|
|
277
|
+
if (!accepting || !session.steer) throw new HarnessError('run_not_active', 'This reply is not accepting messages.')
|
|
278
|
+
pendingSteers.push(text)
|
|
279
|
+
try {
|
|
280
|
+
if ((await session.steer(text)) === 'handled') forgetSteer(text)
|
|
281
|
+
} catch (error) {
|
|
282
|
+
forgetSteer(text)
|
|
283
|
+
throw error
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
|
|
258
287
|
const unsubscribe = session.subscribe((event) => {
|
|
259
288
|
switch (event.type) {
|
|
260
289
|
case 'message_start': {
|
|
261
290
|
const { message } = event as Extract<PiEvent, { type: 'message_start' }>
|
|
262
291
|
if (message.role === 'assistant') currentMessageId = `${run.runId}:m${messageIndex++}`
|
|
292
|
+
else if (message.role === 'user' && promptStarted) {
|
|
293
|
+
const text = pendingSteers.shift()
|
|
294
|
+
if (text !== undefined) run.emit('message.steered', { text })
|
|
295
|
+
} else if (message.role === 'user') promptStarted = true
|
|
263
296
|
break
|
|
264
297
|
}
|
|
265
298
|
case 'turn_start': {
|
|
@@ -424,7 +457,15 @@ export async function runTurn(run: ActiveRun, session: PiSessionLike, params: Tu
|
|
|
424
457
|
}
|
|
425
458
|
const prompt = params.preamble && !extensionCommand ? `${params.preamble}\n\n${params.text}` : params.text
|
|
426
459
|
const images = (params.images ?? []).map((image) => ({ type: 'image', mimeType: image.mediaType, data: image.data }))
|
|
427
|
-
|
|
460
|
+
accepting = true
|
|
461
|
+
try {
|
|
462
|
+
await session.prompt(prompt, images.length ? { images } : undefined)
|
|
463
|
+
} finally {
|
|
464
|
+
// A message Pi has not delivered by now never will be; the caller sends it again as a new reply.
|
|
465
|
+
accepting = false
|
|
466
|
+
session.clearQueue?.()
|
|
467
|
+
}
|
|
468
|
+
if (!aborted && !lastError) await params.settle?.()
|
|
428
469
|
if (aborted) run.finish('interrupted')
|
|
429
470
|
else if (lastError) run.finish('failed', { code: 'provider_error', message: lastError })
|
|
430
471
|
else run.finish('completed')
|
|
@@ -505,7 +546,7 @@ export const piSessionFactory: SessionFactory = async ({ params, allow, gate, to
|
|
|
505
546
|
const loaded = registeredBy(resourceLoader)
|
|
506
547
|
// A tool an extension brought is offered to the model, and asks before every call: the person
|
|
507
548
|
// trusted the code to load, and still decides each thing it does through a tool.
|
|
508
|
-
const extensionTools = [...new Set(loaded.flatMap((extension) => extension.tools))].filter((name) => !allow.includes(name))
|
|
549
|
+
const extensionTools = [...new Set(loaded.flatMap((extension) => extension.tools))].filter((name) => !allow.includes(name) && !params.restrictTools)
|
|
509
550
|
gate.ask?.([...new Set(loaded.flatMap((extension) => extension.tools))])
|
|
510
551
|
|
|
511
552
|
let supportsImageInput = false
|
|
@@ -523,7 +564,7 @@ export const piSessionFactory: SessionFactory = async ({ params, allow, gate, to
|
|
|
523
564
|
if (output.image && !supportsImageInput) throw new HarnessError('image_input_unsupported', 'This model cannot inspect images. Choose a model that supports image input.')
|
|
524
565
|
return {
|
|
525
566
|
content: [{ type: 'text', text: output.text }, ...(output.image ? [{ type: 'image', data: output.image.data, mimeType: output.image.mimeType }] : [])] as never,
|
|
526
|
-
details: output.attachments?.length ? { attachments: output.attachments } : {}
|
|
567
|
+
details: { ...output.details, ...(output.attachments?.length ? { attachments: output.attachments } : {}) }
|
|
527
568
|
}
|
|
528
569
|
}
|
|
529
570
|
})
|
|
@@ -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)))
|
|
@@ -375,7 +423,7 @@ describe('session turns on Pi', () => {
|
|
|
375
423
|
await tick()
|
|
376
424
|
expect(received!.allow.slice(0, 3)).toEqual(['react', 'ask_user', 'plugin_propose'])
|
|
377
425
|
expect(received!.allow).toEqual(expect.arrayContaining(['memory_append', 'history_search', 'wiki_search', 'wiki_read', 'wiki_propose']))
|
|
378
|
-
expect(received!.tools.map((tool) => tool.name)).toEqual(['memory_append', 'memory_replace', 'memory_rethink', 'history_search', 'history_read', 'wiki_search', 'wiki_read', 'wiki_list', 'skill_load', 'memory_facts', 'wiki_propose', 'ask_user', 'plugin_propose', 'react'])
|
|
426
|
+
expect(received!.tools.map((tool) => tool.name)).toEqual(['memory_append', 'memory_replace', 'memory_rethink', 'history_search', 'history_read', 'wiki_search', 'wiki_read', 'wiki_list', 'skill_load', 'memory_facts', 'wiki_propose', 'trajectory_search', 'trajectory_read', 'ask_user', 'plugin_propose', 'spawn_subagent', 'wait_subagents', 'react'])
|
|
379
427
|
expect(await received!.tools[0].execute({ block: 'notes', text: 'Remember this.' })).toContain('Updated the notes block')
|
|
380
428
|
expect(received!.params.system).toContain('# Xingular Harness context')
|
|
381
429
|
})
|
|
@@ -438,8 +486,93 @@ 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'], wait: true })
|
|
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).toMatchObject({ text: expect.stringContaining('Found it in parser.ts'), details: expect.any(Object) })
|
|
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('exposes native child runs, freezes the launch, respects nesting, and settles waiters on parent stop', async () => {
|
|
520
|
+
const seen = new Map<string, string[]>()
|
|
521
|
+
const { harness, run } = setup(async ({ gate, params, allow }) => {
|
|
522
|
+
seen.set(params.runId, [...allow])
|
|
523
|
+
return fakeSession(async () => new Promise<void>(() => undefined), gate)
|
|
524
|
+
})
|
|
525
|
+
const subagents = { maxDepth: 2 }
|
|
526
|
+
const starting = run('parent', { subagents })
|
|
527
|
+
subagents.maxDepth = 3
|
|
528
|
+
await starting
|
|
529
|
+
await expect(harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Bad tools', tools: ['outside'] })).rejects.toMatchObject({ code: 'invalid_request' })
|
|
530
|
+
const child = await harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Search' })
|
|
531
|
+
const grandchild = await harness.sessions.spawnSubagent({ parentRunId: child.runId, brief: 'Review' })
|
|
532
|
+
expect(seen.get(child.runId)).toContain('spawn_subagent')
|
|
533
|
+
expect(seen.get(grandchild.runId)).not.toContain('spawn_subagent')
|
|
534
|
+
await expect(harness.sessions.spawnSubagent({ parentRunId: grandchild.runId, brief: 'Too deep' })).rejects.toMatchObject({ code: 'invalid_request' })
|
|
535
|
+
const narrowed = await harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Read only', tools: ['read'] })
|
|
536
|
+
expect(seen.get(narrowed.runId)).toEqual(['read'])
|
|
537
|
+
const settled = Promise.all([child, grandchild, narrowed].map(({ runId }) => harness.runs.wait(runId)))
|
|
538
|
+
await harness.runs.interrupt('parent')
|
|
539
|
+
await settled
|
|
540
|
+
for (const { runId } of [child, grandchild, narrowed]) expect(loadRun(harness.env.root, runId).status).toBe('interrupted')
|
|
541
|
+
await expect(harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Too late' })).rejects.toMatchObject({ code: 'run_not_active' })
|
|
542
|
+
await harness.runs.wait(child.runId)
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
it('settles a failed child and attributes the tool to the Harness plugin', async () => {
|
|
546
|
+
const { harness, run } = setup(async ({ gate, params }) => {
|
|
547
|
+
if (params.runId !== 'parent') throw new Error('No provider')
|
|
548
|
+
return fakeSession(async () => new Promise<void>(() => undefined), gate)
|
|
549
|
+
})
|
|
550
|
+
await run('parent', { subagents: {} })
|
|
551
|
+
const child = await harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Try' })
|
|
552
|
+
await harness.runs.wait(child.runId)
|
|
553
|
+
expect(loadRun(harness.env.root, child.runId)).toMatchObject({ status: 'failed', parentRunId: 'parent', terminalReason: 'No provider' })
|
|
554
|
+
const { HARNESS_PLUGINS } = await import('../plugins/builtin')
|
|
555
|
+
expect(HARNESS_PLUGINS.find((plugin) => plugin.id === 'subagents')?.tools).toContainEqual(expect.objectContaining({ name: 'spawn_subagent' }))
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it('can explicitly disable subagents and run without the application\'s tool providers', async () => {
|
|
559
|
+
const offered: string[][] = []
|
|
560
|
+
const { harness, run } = setup(async ({ gate, tools }) => {
|
|
561
|
+
offered.push(tools.map((tool) => tool.name))
|
|
562
|
+
return fakeSession(async () => undefined, gate)
|
|
563
|
+
})
|
|
564
|
+
harness.sessions.provide(() => [{ name: 'app_tool', label: 'App', description: 'An app tool', parameters: (Type) => Type.Object({}), execute: async () => 'ok' }])
|
|
565
|
+
await run('plain', { tools: { allow: ['app_tool'] }, subagents: null })
|
|
566
|
+
await run('solo', { tools: { allow: ['app_tool'] }, providers: false })
|
|
567
|
+
await tick()
|
|
568
|
+
expect(offered[0]).toContain('app_tool')
|
|
569
|
+
expect(offered[0]).not.toContain('spawn_subagent')
|
|
570
|
+
expect(offered[1]).not.toContain('app_tool')
|
|
571
|
+
})
|
|
572
|
+
})
|
|
573
|
+
|
|
441
574
|
describe('standing approvals', () => {
|
|
442
|
-
const policy = { auto: { pi: true }, remembered: { 'pane-1': ['shell'] } }
|
|
575
|
+
const policy = { auto: { pi: true }, globalTools: {}, remembered: { 'pane-1': ['shell'] } }
|
|
443
576
|
const facts = (unattended: boolean, readOnly: boolean): ApprovalFacts => ({ unattended, readOnly })
|
|
444
577
|
|
|
445
578
|
it('the switch reaches attended sessions but not unattended or read-only ones', () => {
|
|
@@ -450,6 +583,6 @@ describe('standing approvals', () => {
|
|
|
450
583
|
expect(grant(policy, 'pi', 'pane-1', 'shell', facts(true, false))).toBeNull()
|
|
451
584
|
expect(grant(policy, 'chat', 'pane-1', 'edit files', facts(false, false))).toBeNull()
|
|
452
585
|
expect(grant(policy, 'pi', 'pane-2', 'shell', {})).toBe('always')
|
|
453
|
-
expect(grant({ auto: {}, remembered: policy.remembered }, 'pi', 'pane-2', 'shell', {})).toBeNull()
|
|
586
|
+
expect(grant({ auto: {}, globalTools: {}, remembered: policy.remembered }, 'pi', 'pane-2', 'shell', {})).toBeNull()
|
|
454
587
|
})
|
|
455
588
|
})
|