Skip to content

Commit 13f776f

Browse files
fix artifact delivery reconciliation (#5750)
1 parent 36d2ea3 commit 13f776f

6 files changed

Lines changed: 86 additions & 0 deletions

File tree

apps/web/src/components/ProjectView.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5473,6 +5473,7 @@ export function ProjectView({
54735473
events: message.events,
54745474
producedFileCount: produced.length,
54755475
traceObjectFileCount: traceObjectFiles.length,
5476+
artifactCount: status.artifactCount,
54765477
persistenceSucceeded: artifactPersistenceSucceeded,
54775478
persistenceFailed: artifactPersistenceError !== undefined,
54785479
});
@@ -5564,6 +5565,7 @@ export function ProjectView({
55645565
let liveHtml = '';
55655566
let replayedContent = needsFullReplay ? '' : message.content;
55665567
let replayedEvents: AgentEvent[] = needsFullReplay ? [] : [...(message.events ?? [])];
5568+
let daemonArtifactCount = status.artifactCount;
55675569
let latestReattachRunStatus: ChatMessage['runStatus'] = status.status;
55685570
let authoritativeReattachArtifactPaths = status.artifactPaths;
55695571
const applyContentDelta = (delta: string) => {
@@ -5666,6 +5668,9 @@ export function ProjectView({
56665668
replayedEvents = appendCoalescedAgentEvent(replayedEvents, ev);
56675669
textBuffer.appendEvent(ev);
56685670
},
5671+
onArtifactCount: (count) => {
5672+
daemonArtifactCount = count;
5673+
},
56695674
onDone: async () => {
56705675
// A reattached run interrupted by a "send now" still receives a
56715676
// late onDone from the daemon. Decide ownership first, then bail
@@ -5821,6 +5826,7 @@ export function ProjectView({
58215826
events: deliveryEvents,
58225827
producedFileCount: produced.length,
58235828
traceObjectFileCount: traceObjectFiles.length,
5829+
artifactCount: daemonArtifactCount,
58245830
persistenceSucceeded: artifactPersistenceSucceeded,
58255831
persistenceFailed: artifactPersistenceError !== undefined,
58265832
});
@@ -6907,6 +6913,7 @@ export function ProjectView({
69076913
// that just failed in the current session (the daemon status fetch is only
69086914
// needed on reload, not for runs that are already known to have failed).
69096915
let currentRunId: string | undefined = undefined;
6916+
let daemonArtifactCount: number | undefined;
69106917
const updateConversationLatestRun = (
69116918
status: NonNullable<ChatMessage['runStatus']>,
69126919
endedAt?: number,
@@ -7351,6 +7358,9 @@ export function ProjectView({
73517358
else if (ev.kind === 'thinking') textBuffer.appendEvent(ev);
73527359
else pushEvent(ev);
73537360
},
7361+
onArtifactCount: (count: number) => {
7362+
daemonArtifactCount = count;
7363+
},
73547364
onToolInputDelta: (id: string, name: string, delta: string) => {
73557365
setLiveToolInput((prev) => ({
73567366
...prev,
@@ -7593,6 +7603,7 @@ export function ProjectView({
75937603
events: deliveryCandidate.events,
75947604
producedFileCount: produced.length,
75957605
traceObjectFileCount: traceObjectFiles.length,
7606+
artifactCount: daemonArtifactCount,
75967607
persistenceSucceeded: artifactPersistenceSucceeded,
75977608
persistenceFailed: artifactPersistenceError !== undefined,
75987609
});

apps/web/src/providers/daemon.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,8 @@ export function buildDaemonTranscript(history: ChatMessage[], targetAgentId?: st
272272

273273
export interface DaemonStreamHandlers extends StreamHandlers {
274274
onAgentEvent: (ev: AgentEvent) => void;
275+
/** Authoritative artifact count from the daemon's terminal run record. */
276+
onArtifactCount?: (count: number) => void;
275277
/**
276278
* Live-only incremental tool-input fragment (Claude `input_json_delta`).
277279
* Kept off `AgentEvent`/`PersistedAgentEvent` because it is ephemeral and
@@ -1249,6 +1251,7 @@ async function consumeDaemonRun({
12491251
const reportArtifactCount = (value: unknown) => {
12501252
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return;
12511253
resolvedArtifactCount = value;
1254+
handlers.onArtifactCount?.(value);
12521255
};
12531256
const reportArtifactPaths = (value: unknown) => {
12541257
if (!Array.isArray(value)) return;

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export interface DesignDeliveryInput {
1818
events: AgentEvent[] | undefined;
1919
producedFileCount: number;
2020
traceObjectFileCount: number;
21+
/** Authoritative artifact count reported by the daemon at run finalization. */
22+
artifactCount?: number;
2123
persistenceSucceeded?: boolean;
2224
persistenceFailed?: boolean;
2325
}
@@ -83,6 +85,7 @@ export function resolveDesignDeliveryOutcome(
8385
if (
8486
input.producedFileCount > 0 ||
8587
input.traceObjectFileCount > 0 ||
88+
(input.artifactCount ?? 0) > 0 ||
8689
input.persistenceSucceeded ||
8790
hasLiveArtifactDelivery(input.events)
8891
) {

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1880,6 +1880,62 @@ describe('ProjectView conversation run isolation', () => {
18801880
expect(reattachDaemonRun).not.toHaveBeenCalled();
18811881
});
18821882

1883+
it('trusts the daemon artifact count when browser file reconciliation misses delivered output', async () => {
1884+
conversationAMessages = [
1885+
{
1886+
...succeededAssistant,
1887+
content: '',
1888+
sessionMode: 'design',
1889+
events: [
1890+
{ kind: 'text', text: 'I finished the design.' },
1891+
{
1892+
kind: 'tool_use',
1893+
id: 'write-1',
1894+
name: 'Write',
1895+
input: { file_path: 'index.html', content: '<!doctype html>' },
1896+
},
1897+
],
1898+
preTurnFileNames: [],
1899+
producedFiles: undefined,
1900+
traceObjectFiles: undefined,
1901+
},
1902+
];
1903+
fetchChatRunStatus.mockResolvedValue({
1904+
id: 'run-a',
1905+
status: 'succeeded',
1906+
createdAt: 1,
1907+
updatedAt: 2,
1908+
exitCode: 0,
1909+
signal: null,
1910+
artifactCount: 1,
1911+
});
1912+
1913+
renderProjectView();
1914+
1915+
await waitFor(() => {
1916+
const recoveredMessage = saveMessage.mock.calls
1917+
.map((call) => call[2] as ChatMessage)
1918+
.find(
1919+
(message) =>
1920+
message.id === succeededAssistant.id
1921+
&& message.resultDeliveryState === 'delivered',
1922+
);
1923+
expect(recoveredMessage).toMatchObject({
1924+
runStatus: 'succeeded',
1925+
resultDeliveryState: 'delivered',
1926+
producedFiles: [],
1927+
traceObjectFiles: [],
1928+
});
1929+
expect(recoveredMessage?.events).not.toEqual(
1930+
expect.arrayContaining([
1931+
expect.objectContaining({ code: 'ARTIFACT_NOT_FOUND' }),
1932+
]),
1933+
);
1934+
});
1935+
expect(screen.getByTestId('chat-error').textContent).toBe('');
1936+
expect(reattachDaemonRun).not.toHaveBeenCalled();
1937+
});
1938+
18831939
it('keeps a reloaded report-only Design run without file writes on the success path', async () => {
18841940
// Prose-only turns (image analysis, audits) are legitimate zero-file
18851941
// Design results (#5714, #5718); reload must not downgrade them.

apps/web/tests/providers/sse.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ describe('streamViaDaemon', () => {
142142
onArtifactPaths: (paths) => artifactPaths.push(paths),
143143
});
144144

145+
expect(handlers.onArtifactCount).toHaveBeenCalledWith(2);
145146
expect(published).toEqual([{
146147
agentId: 'amr',
147148
runId: 'run-artifact-success',
@@ -2391,6 +2392,7 @@ function createDaemonHandlers() {
23912392
return {
23922393
...createStreamHandlers(),
23932394
onAgentEvent: vi.fn(),
2395+
onArtifactCount: vi.fn(),
23942396
};
23952397
}
23962398

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,17 @@ describe('resolveDesignDeliveryOutcome', () => {
8989
traceObjectFileCount: 1,
9090
}),
9191
).toBe('delivered');
92+
expect(
93+
resolveDesignDeliveryOutcome({
94+
sessionMode: 'design',
95+
runStatus: 'succeeded',
96+
content: '',
97+
events: [],
98+
producedFileCount: 0,
99+
traceObjectFileCount: 0,
100+
artifactCount: 1,
101+
}),
102+
).toBe('delivered');
92103
expect(
93104
resolveDesignDeliveryOutcome({
94105
sessionMode: 'design',

0 commit comments

Comments
 (0)