The preset's persona row copied the deployment's suffix, "Your working directory
is {{cwd}}". The harness substitutes only registered prompt variables and throws
on an unregistered one during prompt assembly — so when a harness release stopped
registering `cwd` (only `provider` and `model` remain), every session on this
preset failed its first turn with
unknown prompt variable "{{cwd}}" in section "deployment:persona-suffix"
and chat was dead rather than degraded. The row now carries the prefix only.
The folder is stated where it can follow the session instead: the facet binding
already names the facet, and now names the session folder as well when the two
differ — which is every conversation that started higher up and was narrowed, and
exactly the case where relative tool paths need saying out loud.
`test/presets.test.js` makes this class of failure impossible to reintroduce
silently: it scans every shipped preset for a variable reference and requires the
name to be one the harness registers, and holds this bundle's own rule and
binding text to no references at all. Restoring the old suffix fails it.
145 lines
6.7 KiB
JavaScript
145 lines
6.7 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { test } from 'node:test'
|
|
|
|
import {
|
|
displayPath,
|
|
facetContextText,
|
|
facetRuleText,
|
|
isWideningCall,
|
|
outsideReason,
|
|
pathArgumentsFor,
|
|
widenReason,
|
|
} from '../lib/scope.js'
|
|
|
|
test('pathArgumentsFor reads the path argument of every guarded tool', () => {
|
|
assert.deepEqual(pathArgumentsFor('read', { file_path: 'src/a.ts' }), ['src/a.ts'])
|
|
assert.deepEqual(pathArgumentsFor('write', { file_path: 'src/a.ts', content: 'x' }), ['src/a.ts'])
|
|
assert.deepEqual(pathArgumentsFor('edit', { file_path: 'src/a.ts' }), ['src/a.ts'])
|
|
assert.deepEqual(pathArgumentsFor('read_image', { file_path: 'a.png' }), ['a.png'])
|
|
assert.deepEqual(pathArgumentsFor('grep', { pattern: 'x', path: 'src' }), ['src'])
|
|
assert.deepEqual(pathArgumentsFor('glob', { pattern: '*.ts', path: 'src' }), ['src'])
|
|
assert.deepEqual(pathArgumentsFor('str_replace_editor', { path: 'src/a.ts' }), ['src/a.ts'])
|
|
assert.deepEqual(pathArgumentsFor('recall', { path: 'src', focus: 'src/controllers' }), [
|
|
'src',
|
|
'src/controllers',
|
|
])
|
|
})
|
|
|
|
test('pathArgumentsFor has no opinion where it cannot have one', () => {
|
|
// The deliberate hole: a shell command cannot be judged from argv.
|
|
assert.deepEqual(pathArgumentsFor('bash', { command: 'cat /etc/passwd' }), [])
|
|
// Unknown tools are not guessed at.
|
|
assert.deepEqual(pathArgumentsFor('mcp_something', { path: '/etc' }), [])
|
|
// An omitted path means the session directory, which is already in scope.
|
|
assert.deepEqual(pathArgumentsFor('grep', { pattern: 'x' }), [])
|
|
// Unvalidated JSON must never make the guard throw.
|
|
assert.deepEqual(pathArgumentsFor('read', null), [])
|
|
assert.deepEqual(pathArgumentsFor('read', 'nonsense'), [])
|
|
assert.deepEqual(pathArgumentsFor('read', { file_path: 42 }), [])
|
|
assert.deepEqual(pathArgumentsFor('read', { file_path: ' ' }), [])
|
|
assert.deepEqual(pathArgumentsFor('grep', { path: { nested: '/etc' } }), [])
|
|
})
|
|
|
|
test('isWideningCall is true only for the one act that needs the user', () => {
|
|
assert.equal(isWideningCall('facet', { action: 'add', paths: ['x'] }), true)
|
|
// Narrowing and reading cannot show the agent anything new.
|
|
assert.equal(isWideningCall('facet', { action: 'remove', paths: ['x'] }), false)
|
|
assert.equal(isWideningCall('facet', { action: 'reset' }), false)
|
|
assert.equal(isWideningCall('facet', { action: 'status' }), false)
|
|
assert.equal(isWideningCall('read', { action: 'add' }), false)
|
|
assert.equal(isWideningCall('facet', null), false)
|
|
})
|
|
|
|
test('facetRuleText states the rule and never the binding', () => {
|
|
const rule = facetRuleText()
|
|
|
|
// Mode-neutral by construction. A prompt section is registered once as a plain
|
|
// string, and a `volatile()` edit updates the config reference without
|
|
// re-applying the plugin — so a `mode` interpolated here would freeze at load
|
|
// while the guard went on enforcing the new one. The mode-specific sentence
|
|
// lives in the binding, which is a callback.
|
|
assert.match(rule, /Work inside the facet\./)
|
|
assert.doesNotMatch(rule, /refused|put to the user as a question/)
|
|
assert.match(rule, /Never widen the scope on your own initiative/)
|
|
|
|
// The rule must not name a path: a changing system prompt invalidates the
|
|
// prompt cache for the whole conversation.
|
|
assert.doesNotMatch(rule, /\/|src\//)
|
|
|
|
assert.equal(facetContextText({ cwd: '/a', root: '/a/b', extras: [], mode: 'off' }), '')
|
|
})
|
|
|
|
test('facetContextText carries the binding that does change', () => {
|
|
const ask = facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' })
|
|
assert.equal(
|
|
ask,
|
|
'facet: /w/src/room\n' +
|
|
'session folder: /w — relative paths in tool calls resolve here\n' +
|
|
'outside the facet: put to the user as a question — say in one line what you need and why, then record it with `facet action=add` so the rest of the session does not have to ask again',
|
|
)
|
|
|
|
const deny = facetContextText({ cwd: '/w', root: '/w/src/room', extras: ['/w/src/shared'], mode: 'deny' })
|
|
assert.equal(
|
|
deny,
|
|
'facet: /w/src/room\n' +
|
|
'session folder: /w — relative paths in tool calls resolve here\n' +
|
|
'granted outside it: src/shared\n' +
|
|
'outside the facet: refused — ask the user to widen the facet from its context panel, or with `facet action=add` where that can be granted',
|
|
)
|
|
|
|
// The binding is the only half that can follow a live edit, so the two modes
|
|
// have to read differently here — they no longer differ in the rule.
|
|
assert.notEqual(ask, deny)
|
|
|
|
// Unscoped sessions contribute nothing rather than an empty heading.
|
|
assert.equal(facetContextText({ cwd: '/w', root: undefined, extras: [], mode: 'ask' }), '')
|
|
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 granted either. `canAsk: false` is the one
|
|
// input that collapses them, and it has to name the mechanism that still
|
|
// works — the context panel, which the user owns.
|
|
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 — this session has no approval channel/)
|
|
assert.match(text, /`facet action=add` cannot be granted/)
|
|
assert.match(text, /context panel/)
|
|
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'), '.')
|
|
assert.equal(displayPath('/w', '/other/a.ts'), '/other/a.ts')
|
|
assert.equal(displayPath('', '/w/a.ts'), '/w/a.ts')
|
|
})
|
|
|
|
test('outsideReason names the path, the boundary and the way forward', () => {
|
|
const reason = outsideReason({
|
|
cwd: '/w/src/controllers/room',
|
|
path: '/w/src/shared/types.ts',
|
|
root: '/w/src/controllers/room',
|
|
mode: 'ask',
|
|
})
|
|
assert.match(reason, /src\/shared\/types\.ts/)
|
|
assert.match(reason, /src\/controllers\/room/)
|
|
// A denial that does not say how to proceed is one the agent routes around.
|
|
assert.match(reason, /facet action=add/)
|
|
})
|
|
|
|
test('widenReason survives an add with no usable paths', () => {
|
|
assert.match(widenReason(['src/shared']), /src\/shared/)
|
|
assert.match(widenReason([]), /nothing/)
|
|
})
|