Skip to content

Commit c8bad6b

Browse files
authored
fix(web): keep report-only Design runs off the ARTIFACT_NOT_FOUND path (#5724)
A successful Design turn with zero produced files is only a missing deliverable when the run actually attempted to mutate project files. Image-analysis and report-only audit turns answer in prose without ever calling a write tool; classify them as report_only and let them finish as normal text results instead of appending ARTIFACT_NOT_FOUND.
1 parent b6fbde0 commit c8bad6b

5 files changed

Lines changed: 201 additions & 5 deletions

File tree

apps/web/src/runtime/design-delivery.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import type { ChatSessionMode } from '@open-design/contracts';
22
import type { AgentEvent, ChatMessage } from '../types';
3+
import { hasFileMutationToolUse } from './file-ops';
34
import { unfinishedTodosFromEvents } from './todos';
45

56
export type DesignDeliveryOutcome =
67
| 'not_required'
78
| 'awaiting_input'
89
| 'delivered'
10+
| 'report_only'
911
| 'no_result'
1012
| 'delivery_failed';
1113

@@ -60,6 +62,14 @@ function hasLiveArtifactDelivery(events: AgentEvent[] | undefined): boolean {
6062
* Design mode is artifact-first, but clarification and explicitly unfinished
6163
* turns are valid intermediate outcomes. Chat and Plan remain text-first and
6264
* must never be failed merely because they did not write a project file.
65+
*
66+
* A zero-file success is only a missing deliverable when the turn attempted
67+
* to mutate project files (or an artifact save failed). A turn that never
68+
* tried to write and answered with substantive text is a report-only result —
69+
* image analysis and report-only audits end exactly this way — and must not
70+
* be downgraded to ARTIFACT_NOT_FOUND. The known cost: an agent that merely
71+
* claims completion without ever calling a write tool now passes as text; the
72+
* text itself makes that visible to the user.
6373
*/
6474
export function resolveDesignDeliveryOutcome(
6575
input: DesignDeliveryInput,
@@ -78,7 +88,11 @@ export function resolveDesignDeliveryOutcome(
7888
) {
7989
return 'delivered';
8090
}
81-
return input.persistenceFailed ? 'delivery_failed' : 'no_result';
91+
if (input.persistenceFailed) return 'delivery_failed';
92+
if (!hasFileMutationToolUse(input.events) && input.content.trim().length > 0) {
93+
return 'report_only';
94+
}
95+
return 'no_result';
8296
}
8397

8498
/**

apps/web/src/runtime/file-ops.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,25 @@ export function deriveFileOps(events: AgentEvent[] | undefined): FileOpEntry[] {
129129
return Array.from(byPath.values());
130130
}
131131

132+
/**
133+
* True when the run attempted any file mutation (write/edit/delete tool call,
134+
* or a simple Bash rm/unlink), regardless of whether the attempt succeeded.
135+
* Tool names must stay aligned with the daemon's cross-runtime
136+
* `WRITE_OR_EDIT_TOOL_NAMES` set in `apps/daemon/src/runtimes/run-artifacts.ts`.
137+
*/
138+
export function hasFileMutationToolUse(events: AgentEvent[] | undefined): boolean {
139+
for (const ev of events ?? []) {
140+
if (ev.kind !== 'tool_use') continue;
141+
if (ev.name === 'Bash') {
142+
if (extractSimpleBashDeletes(ev.input).length > 0) return true;
143+
continue;
144+
}
145+
const kind = classify(ev.name);
146+
if (kind === 'write' || kind === 'edit' || kind === 'delete') return true;
147+
}
148+
return false;
149+
}
150+
132151
export type FileOpCounts = Record<FileOpKind, number>;
133152

134153
/** Total tool_use count per op family across `entries`. */

apps/web/tests/components/ProjectView.api-empty-response.test.tsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,10 @@ describe('ProjectView API empty response handling', () => {
403403
});
404404
});
405405

406-
it('records no_result without downgrading a successful Design run', async () => {
406+
it('keeps a text-only successful Design run as a report-only success', async () => {
407+
// Report-only turns (image analysis, audits) legitimately end with prose
408+
// and zero produced files (#5714, #5718). They must not be downgraded to
409+
// ARTIFACT_NOT_FOUND.
407410
mockedStreamViaDaemon.mockImplementation(async (options: DaemonStreamOptions) => {
408411
const { handlers } = options;
409412
handlers.onDelta('hello');
@@ -413,6 +416,39 @@ describe('ProjectView API empty response handling', () => {
413416

414417
await sendTestPrompt();
415418

419+
await waitFor(() => expect(screen.getAllByText('hello').length).toBeGreaterThan(0));
420+
await waitFor(() => {
421+
expect(
422+
hasSavedAssistantMessage(
423+
(message) =>
424+
message.runStatus === 'succeeded' &&
425+
message.resultDeliveryState === undefined &&
426+
message.producedFiles !== undefined &&
427+
message.events?.some(
428+
(event) => event.kind === 'status' && event.code === 'ARTIFACT_NOT_FOUND',
429+
) !== true,
430+
),
431+
).toBe(true);
432+
});
433+
expect(screen.queryByText(/without producing a deliverable project file/i)).toBeNull();
434+
});
435+
436+
it('records no_result when a Design run attempted file writes that never landed', async () => {
437+
mockedStreamViaDaemon.mockImplementation(async (options: DaemonStreamOptions) => {
438+
const { handlers } = options;
439+
handlers.onAgentEvent({
440+
kind: 'tool_use',
441+
id: 'write-1',
442+
name: 'Write',
443+
input: { file_path: 'index.html', content: '<!doctype html>' },
444+
});
445+
handlers.onDelta('hello');
446+
handlers.onDone('hello');
447+
});
448+
renderProjectView();
449+
450+
await sendTestPrompt();
451+
416452
await waitFor(() => expect(screen.getAllByText('hello').length).toBeGreaterThan(0));
417453
await waitFor(() => {
418454
expect(
@@ -548,6 +584,12 @@ describe('ProjectView API empty response handling', () => {
548584
it('waits for delivery verification before playing the failure sound for a missing result', async () => {
549585
mockedStreamViaDaemon.mockImplementation(async (options: DaemonStreamOptions) => {
550586
const { handlers } = options;
587+
handlers.onAgentEvent({
588+
kind: 'tool_use',
589+
id: 'write-1',
590+
name: 'Write',
591+
input: { file_path: 'index.html', content: '<!doctype html>' },
592+
});
551593
handlers.onDelta('hello');
552594
handlers.onDone('hello');
553595
});

apps/web/tests/components/ProjectView.run-isolation.test.tsx

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -932,13 +932,21 @@ describe('ProjectView conversation run isolation', () => {
932932
expect(showCompletionNotification).not.toHaveBeenCalled();
933933
});
934934

935-
it('downgrades a reloaded terminal Design run with prose but no delivered file', async () => {
935+
it('downgrades a reloaded terminal Design run whose file writes never landed', async () => {
936936
conversationAMessages = [
937937
{
938938
...succeededAssistant,
939939
content: '',
940940
sessionMode: 'design',
941-
events: [{ kind: 'text', text: 'I finished the design.' }],
941+
events: [
942+
{ kind: 'text', text: 'I finished the design.' },
943+
{
944+
kind: 'tool_use',
945+
id: 'write-1',
946+
name: 'Write',
947+
input: { file_path: 'index.html', content: '<!doctype html>' },
948+
},
949+
],
942950
preTurnFileNames: [],
943951
producedFiles: undefined,
944952
traceObjectFiles: undefined,
@@ -981,6 +989,54 @@ describe('ProjectView conversation run isolation', () => {
981989
expect(reattachDaemonRun).not.toHaveBeenCalled();
982990
});
983991

992+
it('keeps a reloaded report-only Design run without file writes on the success path', async () => {
993+
// Prose-only turns (image analysis, audits) are legitimate zero-file
994+
// Design results (#5714, #5718); reload must not downgrade them.
995+
conversationAMessages = [
996+
{
997+
...succeededAssistant,
998+
content: '',
999+
sessionMode: 'design',
1000+
events: [{ kind: 'text', text: 'The hero image contrast is too low.' }],
1001+
preTurnFileNames: [],
1002+
producedFiles: undefined,
1003+
traceObjectFiles: undefined,
1004+
},
1005+
];
1006+
fetchChatRunStatus.mockResolvedValue({
1007+
id: 'run-a',
1008+
status: 'succeeded',
1009+
createdAt: 1,
1010+
updatedAt: 2,
1011+
exitCode: 0,
1012+
signal: null,
1013+
});
1014+
1015+
renderProjectView();
1016+
1017+
await waitFor(() => {
1018+
const recoveredMessage = saveMessage.mock.calls
1019+
.map((call) => call[2] as ChatMessage)
1020+
.find(
1021+
(message) =>
1022+
message.id === succeededAssistant.id && message.producedFiles !== undefined,
1023+
);
1024+
expect(recoveredMessage).toMatchObject({
1025+
runStatus: 'succeeded',
1026+
producedFiles: [],
1027+
traceObjectFiles: [],
1028+
});
1029+
expect(recoveredMessage?.resultDeliveryState).toBeUndefined();
1030+
expect(recoveredMessage?.events).not.toEqual(
1031+
expect.arrayContaining([
1032+
expect.objectContaining({ code: 'ARTIFACT_NOT_FOUND' }),
1033+
]),
1034+
);
1035+
});
1036+
expect(screen.getByTestId('chat-error').textContent).toBe('');
1037+
expect(reattachDaemonRun).not.toHaveBeenCalled();
1038+
});
1039+
9841040
it('does not reload or reattach when selecting the active streaming conversation', async () => {
9851041
renderProjectView();
9861042

apps/web/tests/runtime/design-delivery.test.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,24 @@ import {
55
} from '../../src/runtime/design-delivery';
66

77
describe('resolveDesignDeliveryOutcome', () => {
8-
it('requires file delivery for a successfully completed Design-mode turn', () => {
8+
it('treats a text answer without any file-write attempt as a report-only result', () => {
9+
// Image analysis / report-only audits legitimately end with prose and no
10+
// new project file (#5714, #5718). Only fail delivery when the agent
11+
// actually attempted to write files and nothing landed.
12+
expect(
13+
resolveDesignDeliveryOutcome({
14+
sessionMode: 'design',
15+
runStatus: 'succeeded',
16+
content: 'The hero image uses low contrast; increase it for readability.',
17+
events: [
18+
{ kind: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'hero.png' } },
19+
],
20+
producedFileCount: 0,
21+
traceObjectFileCount: 0,
22+
}),
23+
).toBe('report_only');
24+
// BYOK API runs have no tool events at all; a substantive text answer is
25+
// still a report-only result, not a missing artifact.
926
expect(
1027
resolveDesignDeliveryOutcome({
1128
sessionMode: 'design',
@@ -15,6 +32,38 @@ describe('resolveDesignDeliveryOutcome', () => {
1532
producedFileCount: 0,
1633
traceObjectFileCount: 0,
1734
}),
35+
).toBe('report_only');
36+
});
37+
38+
it('requires file delivery once the turn attempted to write project files', () => {
39+
for (const attempt of [
40+
{ kind: 'tool_use' as const, id: 'w-1', name: 'Write', input: { file_path: 'index.html' } },
41+
{ kind: 'tool_use' as const, id: 'e-1', name: 'Edit', input: { file_path: 'index.html' } },
42+
{ kind: 'tool_use' as const, id: 'b-1', name: 'Bash', input: { command: 'rm stale.html' } },
43+
]) {
44+
expect(
45+
resolveDesignDeliveryOutcome({
46+
sessionMode: 'design',
47+
runStatus: 'succeeded',
48+
content: 'I finished the design.',
49+
events: [attempt],
50+
producedFileCount: 0,
51+
traceObjectFileCount: 0,
52+
}),
53+
).toBe('no_result');
54+
}
55+
});
56+
57+
it('does not accept an empty answer as a report-only result', () => {
58+
expect(
59+
resolveDesignDeliveryOutcome({
60+
sessionMode: 'design',
61+
runStatus: 'succeeded',
62+
content: ' ',
63+
events: [],
64+
producedFileCount: 0,
65+
traceObjectFileCount: 0,
66+
}),
1867
).toBe('no_result');
1968
});
2069

@@ -84,6 +133,22 @@ describe('resolveDesignDeliveryOutcome', () => {
84133
).toBe('delivery_failed');
85134
});
86135

136+
it('keeps a failed artifact save a failure even without file-write tool calls', () => {
137+
// A BYOK <artifact> block that failed to persist is a delivery failure;
138+
// the report-only escape must never mask it.
139+
expect(
140+
resolveDesignDeliveryOutcome({
141+
sessionMode: 'design',
142+
runStatus: 'succeeded',
143+
content: 'Here is the landing page you asked for.',
144+
events: [],
145+
producedFileCount: 0,
146+
traceObjectFileCount: 0,
147+
persistenceFailed: true,
148+
}),
149+
).toBe('delivery_failed');
150+
});
151+
87152
it('does not fail clarification turns or turns with explicitly unfinished work', () => {
88153
expect(
89154
resolveDesignDeliveryOutcome({

0 commit comments

Comments
 (0)