feat: add canonical typed tool outputs
This commit is contained in:
@@ -67,7 +67,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'original')
|
||||
const result = await call('write', { file_path: 'a.txt', content: 'clobber' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original')
|
||||
})
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'changed-externally') // out-of-band change
|
||||
const result = await call('write', { file_path: 'a.txt', content: 'replaced' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01, 0x02]))
|
||||
const result = await call('read', { file_path: 'bin' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_TEXT' } })
|
||||
})
|
||||
|
||||
it('paginates a multi-line file with offset/limit', async () => {
|
||||
@@ -127,7 +127,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world')
|
||||
})
|
||||
|
||||
@@ -151,7 +151,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'goodbye') // out-of-band change removes 'world'
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
|
||||
})
|
||||
|
||||
it('rejects an ambiguous match without replace_all', async () => {
|
||||
@@ -159,7 +159,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' })
|
||||
expect(result.error).toMatchObject({ info: { code: 'FS_AMBIGUOUS_EDIT' } })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a a a')
|
||||
})
|
||||
|
||||
@@ -187,7 +187,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
|
||||
// The model-facing edit still rejects: the read did not emit fs/observed.
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -260,14 +260,14 @@ describe('bare provider (no dsh-fs-policy)', () => {
|
||||
it('edit of a MISSING target reports FS_STALE_VERSION even on the unguarded path', async () => {
|
||||
const result = await call('edit', { file_path: 'missing.txt', old_string: 'a', new_string: 'b' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
|
||||
})
|
||||
|
||||
it('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'absent', new_string: 'x' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
|
||||
expect(result.error).toMatchObject({ info: { code: 'FS_EDIT_NOT_FOUND' } })
|
||||
})
|
||||
|
||||
it('neither write nor edit stats in the tool on the bare path', async () => {
|
||||
@@ -350,11 +350,11 @@ describe('signal, concurrency, and the fs/observed contract', () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello')
|
||||
const read = await callSig(AbortSignal.abort(), 'read', { file_path: 'a.txt' })
|
||||
expect(read.isError).toBe(true)
|
||||
expect(read.error).toMatchObject({ code: 'FS_ABORTED' })
|
||||
expect(read.error).toMatchObject({ info: { code: 'FS_ABORTED' } })
|
||||
|
||||
const write = await callSig(AbortSignal.abort(), 'write', { file_path: 'new.txt', content: 'x' })
|
||||
expect(write.isError).toBe(true)
|
||||
expect(write.error).toMatchObject({ code: 'FS_ABORTED' })
|
||||
expect(write.error).toMatchObject({ info: { code: 'FS_ABORTED' } })
|
||||
await expect(readFile(join(dir, 'new.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
|
||||
// Read first (un-aborted, SAME session owner) so the edit clears the
|
||||
@@ -363,7 +363,7 @@ describe('signal, concurrency, and the fs/observed contract', () => {
|
||||
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
|
||||
const edit = await callSig(AbortSignal.abort(), 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' })
|
||||
expect(edit.isError).toBe(true)
|
||||
expect(edit.error).toMatchObject({ code: 'FS_ABORTED' })
|
||||
expect(edit.error).toMatchObject({ info: { code: 'FS_ABORTED' } })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello') // unchanged
|
||||
})
|
||||
|
||||
@@ -378,7 +378,7 @@ describe('signal, concurrency, and the fs/observed contract', () => {
|
||||
])
|
||||
const errors = [one, two].filter(r => r.isError)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]?.error).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
expect(errors[0]?.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
|
||||
// The world is consistent: exactly one edit landed.
|
||||
const onDisk = await readFile(join(dir, 'a.txt'), 'utf8')
|
||||
expect(onDisk === 'ONE value here' || onDisk === 'base TWO here').toBe(true)
|
||||
@@ -407,7 +407,7 @@ describe('signal, concurrency, and the fs/observed contract', () => {
|
||||
new_string: 'edited',
|
||||
})
|
||||
expect(edit.isError).toBe(true)
|
||||
expect(edit.error).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
expect(edit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('newer current content\n')
|
||||
})
|
||||
|
||||
|
||||
@@ -161,6 +161,13 @@ describe('read tool', () => {
|
||||
fs.files.set('key:a.txt', 'hello\nworld')
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected read success')
|
||||
expect(result.value).toEqual({
|
||||
path: '/abs/a.txt',
|
||||
offset: 1,
|
||||
lines: [{ number: 1, text: 'hello' }, { number: 2, text: 'world' }],
|
||||
totalLines: 2,
|
||||
})
|
||||
expect(text(result)).toBe(`<path>/abs/a.txt</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
@@ -171,6 +178,15 @@ describe('read tool', () => {
|
||||
</content>`)
|
||||
})
|
||||
|
||||
it('returns an explicit empty canonical line window for an empty file', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:empty.txt', '')
|
||||
const result = await call(ctx, 'read', { file_path: 'empty.txt' })
|
||||
if (result.isError) throw new Error('expected empty read success')
|
||||
expect(result.value).toEqual({ path: '/abs/empty.txt', offset: 1, lines: [], totalLines: 0 })
|
||||
expect(text(result)).toContain('(End of file - total 0 lines)')
|
||||
})
|
||||
|
||||
it('rejects a non-positive offset via arg validation', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt', offset: 0 })
|
||||
@@ -226,7 +242,7 @@ describe('read tool', () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: 'missing.txt' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
|
||||
})
|
||||
|
||||
it('rejects a non-regular target', async () => {
|
||||
@@ -235,7 +251,7 @@ describe('read tool', () => {
|
||||
fs.stat = async () => ({ version: FsVersion('v1'), type: 'directory' })
|
||||
const result = await call(ctx, 'read', { file_path: 'd' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } })
|
||||
})
|
||||
|
||||
it('streams a large file (size at/above the cap) instead of reading whole', async () => {
|
||||
@@ -301,6 +317,8 @@ describe('write tool', () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }, { session: { header: {} } })
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected write success')
|
||||
expect(result.value).toEqual({ path: '/abs/a.txt', operation: 'create', before: null, after: 'hi' })
|
||||
expect(text(result)).toContain('Created file')
|
||||
expect(fs.writeIntents).toEqual([{ kind: 'createIfAbsent' }])
|
||||
})
|
||||
@@ -317,7 +335,7 @@ describe('write tool', () => {
|
||||
fs.rejectWith = new FsError('blocked', 'FS_STALE_VERSION')
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ name: 'FsError', code: 'FS_STALE_VERSION' })
|
||||
expect(result.error).toMatchObject({ info: { name: 'FsError', code: 'FS_STALE_VERSION' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -328,6 +346,8 @@ describe('edit tool', () => {
|
||||
fs.files.set('key:a.txt', 'a')
|
||||
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session })
|
||||
if (result.isError) throw new Error('expected edit success')
|
||||
expect(result.value).toEqual({ path: '/abs/a.txt', before: 'a', after: 'b' })
|
||||
expect(text(result)).toBe('The file /abs/a.txt has been updated successfully.')
|
||||
})
|
||||
|
||||
@@ -366,7 +386,7 @@ describe('edit tool', () => {
|
||||
fs.files.set('key:a.txt', 'hello')
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session: { header: {} } })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -464,27 +484,27 @@ describe('result-time contextual diff (meta + presentResult)', () => {
|
||||
expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] })
|
||||
})
|
||||
|
||||
it('write CREATE: no before-version → no meta, but presentResult still renders a whole-file diff card', async () => {
|
||||
// A create has no prior content (no `meta`), yet the completed card must be a `diff` — an
|
||||
it('write CREATE: an empty applied-diff projection still falls back to the whole-file diff card', async () => {
|
||||
// A create has no prior content, yet the completed card must be a `diff` — an
|
||||
// ACP tool_call_update.content REPLACES the call's content, so a non-diff result would
|
||||
// clobber the pending new-file diff.
|
||||
const { ctx } = await setup()
|
||||
const session = { header: {} }
|
||||
const result = await call(ctx, 'write', { file_path: 'new.txt', content: 'fresh\n' }, { session })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.meta).toBeUndefined()
|
||||
expect(result.meta).toEqual({ diffs: [] })
|
||||
const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'new.txt', content: 'fresh\n' }, result)
|
||||
expect(view).toEqual({ card: 'diff', title: 'Write new.txt', diffs: [{ path: 'new.txt', oldText: null, newText: 'fresh\n' }] })
|
||||
})
|
||||
|
||||
it('write OVERWRITE with identical content: a before exists but yields no hunk → no meta, presentResult falls back to a whole-file diff', async () => {
|
||||
it('write OVERWRITE with identical content: an empty applied-diff projection falls back to a whole-file diff', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const session = { header: {} }
|
||||
fs.files.set('key:a.txt', 'same\n')
|
||||
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'same\n' }, { session })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.meta).toBeUndefined()
|
||||
expect(result.meta).toEqual({ diffs: [] })
|
||||
const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'same\n' }, result)
|
||||
expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'same\n' }] })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user