fix: say so when a session has no approval channel to ask through
Release / release (push) Successful in 6s

`ask` mode promises that an out-of-facet call is put to the user, and that
promise needs a channel. A session can be composed without one: the
`danger-full-access` permission preset sets `approval: never`, and the
approval service's `decide()` then returns "rejected" before consulting any
answerer. The call is refused, `facet action=add` is refused with it — widening
is itself a question — and the scope can never be widened.

The model is told none of that. `serviceAsk` replaces a rejected ask with
`the user rejected tool \"…\"`, discarding the reason this guard wrote, so the
binding is the only channel left that can state what is true. It now reads the
session's effective approval policy and, when there is no channel, says the call
was refused rather than asked and names the mechanism that still works:
`extraRoots` is configuration applied by the profile, not a grant asked for per
conversation.

A missing approval service counts as no channel (the tool pipeline denies an ask
it cannot route); an unreadable policy counts as a channel, so a transient
failure never tells a user to go and edit their configuration.
This commit is contained in:
NotExpectedYet committed 2026-09-26 09:02:48 +01:00
1 parent 6e696f10a2
commit a1c8ecb1e8
4 files changed
+120 -6

No files matched your search

+22
View File
@@ -94,6 +94,28 @@ test('facetContextText carries the binding that does change', () => {
assert.equal(facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'off' }), '')
})
test('a session with no approval channel says so instead of promising a question', () => {
// `approval: never` refuses every ask before an answerer is consulted, so the
// mode stops mattering: `ask` and `deny` both end in a refusal, and
// `facet action=add` cannot be approved either. `canAsk: false` is the one
// input that collapses them, and it has to name the mechanism that still
// works — `extraRoots` is configuration, not a per-conversation grant.
for (const mode of ['ask', 'deny']) {
const text = facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode, canAsk: false })
assert.match(text, /outside the facet: refused — approval prompts are off in this session/)
assert.match(text, /`facet action=add` cannot be approved either/)
assert.match(text, /`extraRoots`/)
assert.doesNotMatch(text, /put to the user as a question/)
}
// The default stays a promise: a caller that knows nothing about approval
// channels must not silently change what the binding says.
assert.match(
facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' }),
/put to the user as a question/,
)
})
test('displayPath prefers the shortest unambiguous form', () => {
assert.equal(displayPath('/w', '/w/src/a.ts'), 'src/a.ts')
assert.equal(displayPath('/w', '/w'), '.')