Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/compose-workspace-tool-conflicts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@nestm/storage': minor
---

Add a typed `mapCreateConflict` hook to the AI SDK workspace adapter so
applications can represent atomic create collisions as domain results without
mutating generated tools. Keep replace/ETag conflicts fail-closed and sanitize
mapper failures at the tool boundary.

Mark workspace tools with optional inputs or a combined create/replace union as
non-strict for provider schema generation while retaining strict Zod runtime
validation.
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,24 @@ workspace capability remains the authorization boundary even when approval is
disabled. The module's `AiSdkService.files()` API is the model provider's file
upload facility and is unrelated to storage workspaces.

Atomic create collisions remain sanitized tool errors by default. Applications
that model an existing destination as a normal tool result can map that one
case while preserving replace/ETag conflicts as failures:

```ts
const tools = createAiSdkWorkspaceTools({
workspace,
mapCreateConflict: ({ path }) => ({
kind: 'artifact-conflict' as const,
path,
status: 'already-exists' as const,
}),
});
```

The mapper receives only the logical workspace path; provider errors, object
keys, and mount coordinates are never exposed.

This logical confinement is sufficient for a `ToolLoopAgent` whose only file
capabilities are these tools. It cannot constrain a coding harness that already
has shell, `node:fs`, or subprocess access. For Codex/Claude-style harnesses,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@
"@types/node": "26.1.2",
"@types/supertest": "7.2.1",
"@vitest/coverage-v8": "4.1.10",
"ai": "7.0.52",
"ai": "7.0.64",
"fastify": "5.11.2",
"oxlint": "1.77.0",
"prettier": "3.9.6",
Expand Down
46 changes: 23 additions & 23 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

111 changes: 111 additions & 0 deletions src/ai-sdk/ai-sdk-workspace-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ describe('createAiSdkWorkspaceTools', () => {
'workspace_stat',
'workspace_write_file',
]);
expect(viewTool(tools, 'workspace_write_file').strict).toBe(false);
});

it('omits compound mutations until all enforcing permissions are present', () => {
Expand Down Expand Up @@ -314,6 +315,7 @@ describe('createAiSdkWorkspaceTools', () => {
false,
);
expect(viewTool(tools, 'workspace_stat').strict).toBe(true);
expect(viewTool(tools, 'workspace_search').strict).toBe(false);
});

it('describes cursor continuation with the bound query options', () => {
Expand All @@ -331,6 +333,7 @@ describe('createAiSdkWorkspaceTools', () => {
viewTool(tools, 'workspace_list').inputSchema.safeParse({ cursor })
.success,
).toBe(true);
expect(viewTool(tools, 'workspace_list').strict).toBe(false);
expect(
viewTool(tools, 'workspace_list').inputSchema.safeParse({
cursor: '',
Expand Down Expand Up @@ -375,6 +378,7 @@ describe('createAiSdkWorkspaceTools', () => {
createTools,
'workspace_write_file',
).inputSchema;
expect(viewTool(createTools, 'workspace_write_file').strict).toBe(true);
expect(
createSchema.safeParse({
path: 'new.txt',
Expand Down Expand Up @@ -406,6 +410,7 @@ describe('createAiSdkWorkspaceTools', () => {
replaceTools,
'workspace_write_file',
).inputSchema;
expect(viewTool(replaceTools, 'workspace_write_file').strict).toBe(true);
expect(
replaceSchema.safeParse({
path: 'old.txt',
Expand Down Expand Up @@ -686,6 +691,112 @@ describe('createAiSdkWorkspaceTools', () => {
});
});

it('maps only atomic create conflicts through the typed application hook', async () => {
const fixture = createWorkspaceDouble(['create', 'replace']);
const conflict = new StorageWorkspaceError('private provider detail', {
code: StorageErrorCode.CONFLICT,
operation: 'writeFile',
path: 'existing.txt',
permanent: true,
});
fixture.writeFile.mockRejectedValue(conflict);
const mappedPaths: string[] = [];
const tools = createAiSdkWorkspaceTools({
workspace: fixture.workspace,
mapCreateConflict: ({ path }) => {
mappedPaths.push(path);
return { kind: 'already-exists' as const, path };
},
});

await expect(
executeTool(tools, 'workspace_write_file', {
path: 'existing.txt',
content: 'new',
mode: 'create',
}),
).resolves.toEqual({ kind: 'already-exists', path: 'existing.txt' });
expect(mappedPaths).toEqual(['existing.txt']);

await expect(
executeTool(tools, 'workspace_write_file', {
path: 'existing.txt',
content: 'new',
mode: 'replace',
etag: 'stale-etag',
}),
).rejects.toMatchObject({ code: StorageErrorCode.CONFLICT });
expect(mappedPaths).toEqual(['existing.txt']);

fixture.writeFile.mockRejectedValueOnce(
new StorageWorkspaceError('private provider detail', {
code: StorageErrorCode.PROVIDER,
operation: 'writeFile',
path: 'new.txt',
}),
);
await expect(
executeTool(tools, 'workspace_write_file', {
path: 'new.txt',
content: 'new',
mode: 'create',
}),
).rejects.toMatchObject({ code: StorageErrorCode.PROVIDER });
expect(mappedPaths).toEqual(['existing.txt']);
});

it('keeps create conflicts as errors when no mapper is configured', async () => {
const fixture = createWorkspaceDouble(['create']);
fixture.writeFile.mockRejectedValue(
new StorageWorkspaceError('private provider detail', {
code: StorageErrorCode.CONFLICT,
operation: 'writeFile',
path: 'existing.txt',
}),
);
const tools = createAiSdkWorkspaceTools({ workspace: fixture.workspace });

await expect(
executeTool(tools, 'workspace_write_file', {
path: 'existing.txt',
content: 'new',
mode: 'create',
}),
).rejects.toMatchObject({
code: StorageErrorCode.CONFLICT,
message:
'The operation conflicts with current workspace state. Refresh metadata and retry with the current ETag or a new destination.',
});
});

it('sanitizes failures thrown by a create-conflict mapper', async () => {
const fixture = createWorkspaceDouble(['create']);
fixture.writeFile.mockRejectedValue(
new StorageWorkspaceError('private provider detail', {
code: StorageErrorCode.CONFLICT,
operation: 'writeFile',
path: 'existing.txt',
}),
);
const tools = createAiSdkWorkspaceTools({
workspace: fixture.workspace,
mapCreateConflict: () => {
throw new Error('private application detail');
},
});

await expect(
executeTool(tools, 'workspace_write_file', {
path: 'existing.txt',
content: 'new',
mode: 'create',
}),
).rejects.toMatchObject({
code: StorageErrorCode.PROVIDER,
message: 'The workspace operation failed.',
});
});

it('sanitizes workspace and unknown failures without retaining their cause', async () => {
const fixture = createWorkspaceDouble(['read']);
fixture.stat.mockRejectedValueOnce(
Expand Down
Loading