fix: more stability for durable tasks, retry strategy, refactor dead

code
This commit is contained in:
Alex committed 2026-08-22 09:44:09 +01:00
1 parent 114585cd7d
commit e96ff8658c
27 files changed
+1060 -162

No files matched your search

@@ -65,6 +65,12 @@ const ConversationBubble = forwardRef<
index?: number,
) => void;
filesAttached?: { id: string; fileName: string }[];
/**
* Every artifact in the conversation, for resolving inline links. Refs
* are conversation-scoped, so a link may point at an earlier turn's file.
* The chip RAIL still uses this turn's artifacts only.
*/
conversationArtifacts?: ReturnType<typeof deriveArtifactChips>;
onOpenArtifact?: (artifact: { id: string; toolName: string }) => void;
onToolAction?: (
callId: string,
@@ -92,6 +98,7 @@ const ConversationBubble = forwardRef<
isStreaming,
handleUpdatedQuestionSubmission,
filesAttached,
conversationArtifacts,
onOpenArtifact,
onToolAction,
agentId,
@@ -431,7 +438,7 @@ const ConversationBubble = forwardRef<
// A research run already narrates itself above; the status line
// would be a second live indicator away from the point of action.
suppressStatusLine={Boolean(research)}
artifacts={completedArtifacts}
artifacts={conversationArtifacts ?? completedArtifacts}
onOpenArtifact={onOpenArtifact}
renderApproval={(toolCall: ToolCallsType) => (
<div className="fade-in mt-4 mr-5 ml-6">
@@ -3,6 +3,7 @@ import {
ReactNode,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -19,6 +20,7 @@ import {
MessageScrollerViewport,
} from '../components/ui/message-scroller';
import Hero from '../Hero';
import { deriveArtifactChips } from './artifactChips';
import ConversationBubble from './ConversationBubble';
import { FEEDBACK, Query, Status } from './conversationModels';
import StreamingStatusLine from './StreamingStatusLine';
@@ -73,6 +75,15 @@ export default function ConversationMessages({
}: ConversationMessagesProps) {
const { t } = useTranslation();
// Artifact refs (A1, A2, ...) are CONVERSATION-scoped on the backend — a
// ref minted on turn 1 still resolves, and edit_artifact still accepts it,
// on turn 5. Resolving inline links against only the current turn's
// artifacts made "the CSV from earlier" render as dead plain text.
const conversationArtifacts = useMemo(
() => queries.flatMap((query) => deriveArtifactChips(query.tool_calls)),
[queries],
);
const hasMessages = queries.length > 0;
const lastQuery = queries[queries.length - 1];
const lastTurnContentLength =
@@ -212,6 +223,7 @@ export default function ConversationMessages({
segments={query.segments}
workflowRunId={query.workflow_run_id}
research={query.research}
conversationArtifacts={conversationArtifacts}
onOpenArtifact={onOpenArtifact}
onToolAction={onToolAction}
feedback={query.feedback}
@@ -3,9 +3,14 @@
*
* Payloads captured verbatim from `GET /api/get_single_conversation` against a
* live run: turn 1's `run_code` wrote a CSV and a PNG (refs A1/A2), turn 2 wrote
* a JSON summary (ref A3). Refs are numbered per *conversation*, so turn 2's
* only artifact is A3 — indexing a turn's artifact list by the ref number picks
* the wrong file, or none.
* a JSON summary (ref A3). Refs are numbered per *conversation*, so indexing a
* turn's artifact list by the ref number picks the wrong file, or none.
*
* `resolveSandboxLink` matches on the ref itself, so what it can resolve is
* decided entirely by the list it is handed. Handed one turn's artifacts it
* degrades an earlier turn's ref to plain text — safe, but wrong, since the
* ref is still live conversation-wide. `ConversationMessages` therefore passes
* the whole conversation's artifacts; both cases are pinned below.
*/
import { describe, expect, it } from 'vitest';
@@ -46,13 +51,49 @@ describe('conversation-scoped refs against real turn payloads', () => {
// The bug: A1 is turn 1's CSV. Positional resolution made it index 0 of
// turn 2's list — the JSON summary — and opened the wrong file silently.
it('does not hand turn 1s ref to turn 2s file', () => {
// Never resolving beats resolving to the wrong file, so a list that lacks
// the ref must still degrade to plain text.
it('never substitutes a different file for a ref it does not hold', () => {
expect(resolveSandboxLink('artifact:A1', turn2)).toEqual({ kind: 'plain' });
expect(resolveSandboxLink('sandbox:/artifact/A2', turn2)).toEqual({
kind: 'plain',
});
});
// ...but the ref IS still live: `edit_artifact` accepts A1 on turn 2, and
// the model is told so. Given the conversation's artifacts — what
// `ConversationMessages` actually passes — the cross-turn link resolves.
it('resolves an earlier turns ref against the whole conversation', () => {
const conversation = [...turn1, ...turn2];
expect(resolveSandboxLink('artifact:A1', conversation)).toEqual({
kind: 'artifact',
artifact: turn1[0],
});
expect(resolveSandboxLink('sandbox:/artifact/A2', conversation)).toEqual({
kind: 'artifact',
artifact: turn1[1],
});
expect(resolveSandboxLink('artifact:A3', conversation)).toEqual({
kind: 'artifact',
artifact: turn2[0],
});
});
// Last-write-wins must survive widening: a filename re-written on a later
// turn resolves to the newest artifact, not the first match.
it('keeps last-write-wins across the conversation', () => {
const rewritten: SandboxArtifact = {
id: 'a1111111-2222-3333-4444-555555555555',
label: 'monthly_sales.csv',
ref: 'A4',
toolName: 'code_executor',
};
const conversation = [...turn1, ...turn2, rewritten];
expect(
resolveSandboxLink('sandbox:/mnt/data/monthly_sales.csv', conversation),
).toEqual({ kind: 'artifact', artifact: rewritten });
});
it('still resolves both of turn 1s files on turn 1', () => {
expect(resolveSandboxLink('sandbox:/artifact/A1', turn1)).toEqual({
kind: 'artifact',