Skip to content
Merged
94 changes: 94 additions & 0 deletions app/src/components/flows/WorkflowCopilotPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface MockMessage {
const hookState = vi.hoisted(() => ({
sending: false,
proposal: null as WorkflowProposal | null,
capped: false,
// Panel renders `displayMessages` (already interim-filtered upstream by
// `useWorkflowBuilderChat`) — kept separate from `messages` in these tests
// so a mismatch between the two proves the panel is reading the right field.
Expand Down Expand Up @@ -51,6 +52,7 @@ describe('WorkflowCopilotPanel', () => {
beforeEach(() => {
hookState.sending = false;
hookState.proposal = null;
hookState.capped = false;
hookState.displayMessages = [];
hookState.toolTimeline = [];
hookState.liveResponse = '';
Expand Down Expand Up @@ -335,6 +337,98 @@ describe('WorkflowCopilotPanel', () => {
expect(hookState.clearProposal).toHaveBeenCalledTimes(1);
});

it('B34: renders a "Continue building" card when the turn hit the iteration cap', () => {
hookState.capped = true;
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByTestId('workflow-copilot-capped')).toBeInTheDocument();
expect(screen.getByTestId('workflow-copilot-continue')).toBeInTheDocument();
});

it('B34: does NOT render the capped card for a normal (non-capped) turn', () => {
hookState.capped = false;
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.queryByTestId('workflow-copilot-capped')).not.toBeInTheDocument();
});

it('B34: does not render the capped card while a proposal is pending, even if capped is stale-true', () => {
// Defense-in-depth: the server already scopes `capped` to `proposal ===
// null`, but the panel re-checks `!proposal` itself too (see the JSX
// condition) in case a stale `capped=true` from a prior turn outlives a
// later turn's proposal.
hookState.capped = true;
hookState.proposal = proposalWith(['a', 'c']);
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.queryByTestId('workflow-copilot-capped')).not.toBeInTheDocument();
});

it('B34: clicking "Continue building" sends a follow-up turn', async () => {
hookState.capped = true;
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId('workflow-copilot-continue'));
await waitFor(() => expect(hookState.send).toHaveBeenCalledTimes(1));
const arg = hookState.send.mock.calls[0][0];
expect(arg.request.mode).toBe('revise');
expect(arg.request.graph).toEqual(baseGraph);
});

// Codex review on #4865: "Continue building" must resume ON the current
// draft — a `revise` turn over the EXISTING `flowId`, never a blank/`create`
// restart — since `flows_build` spins up a fresh `workflow_builder` agent
// per RPC with no server-side session/checkpoint to resume. Carrying the
// live `graph` + `flowId` is what makes "Continue" a correct, working
// continuation instead of an empty restart.
it('B34: "Continue building" carries the current flowId, not a blank restart', async () => {
hookState.capped = true;
render(
<WorkflowCopilotPanel
graph={baseGraph}
flowId="flow-123"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId('workflow-copilot-continue'));
await waitFor(() => expect(hookState.send).toHaveBeenCalledTimes(1));
const arg = hookState.send.mock.calls[0][0];
expect(arg.request.mode).toBe('revise');
expect(arg.request.flowId).toBe('flow-123');
expect(arg.request.graph).toEqual(baseGraph);
});

it('auto-sends a repair turn once when opened with a repair seed', () => {
render(
<WorkflowCopilotPanel
Expand Down
53 changes: 53 additions & 0 deletions app/src/components/flows/WorkflowCopilotPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export default function WorkflowCopilotPanel({
threadId,
sending,
proposal,
capped,
displayMessages,
toolTimeline,
liveResponse,
Expand Down Expand Up @@ -330,6 +331,31 @@ export default function WorkflowCopilotPanel({
[text, sending, send, graph, flowId, updatePendingAsk]
);

// (B34) One-click resume for a turn that hit the agent's tool-call budget
// (`capped`, see `useWorkflowBuilderChat`'s doc) with no proposal yet.
// Routes through the SAME `submit` path a typed follow-up would — the
// `pendingAskRef` mechanism (set above, since a capped turn also has
// `proposed === false`) automatically carries the original ask forward, so
// the agent picks the build back up with full context, not just "continue"
// in isolation.
//
// What this actually does (Codex review on #4865): `flows_build` spins up a
// FRESH `workflow_builder` agent per RPC — there is no server-side
// session/tool-history checkpoint to reattach to, so this is not a literal
// mid-thought resume. What DOES carry forward, because `submit` always
// sends `mode: 'revise'` over the CURRENT `graph` + `flowId` (never a blank
// `create`): (1) the live draft graph — unchanged by a capped turn, since
// `revise_workflow`/`propose_workflow` never persist without a proposal
// reaching this panel; and (2) the full accumulated instruction text via
// `pendingAskRef`. A fresh agent re-reading the same draft plus the same
// ask, now under the B31 50-iteration budget and B32's no-probing brief,
// reliably converges — that combination is what the capped card's copy
// promises ("keep building from the current draft"), not seamless
// tool-history continuity.
const continueBuilding = useCallback(() => {
void submit(t('flows.copilot.continueBuilding'));
Comment on lines +355 to +356

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve capped-turn state before resuming

When a builder turn hits the cap before producing a proposal, the canvas graph is still the old draft and flows_build creates a fresh workflow_builder agent for each RPC, so the prior tool history/checkpoint text is not included in the next BuilderTurnRequest. In that case this button sends only the generic continue text through submit() (plus the original ask via pendingAskRef), which restarts from the stale graph rather than picking up from the capped turn and can repeatedly hit the same cap. Include the checkpoint/prior transcript or a partial graph in the follow-up before advertising this as a resume action.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 216e98b. Verified: WorkflowCopilotPanel's submit already sends the Continue turn as mode: 'revise' over the CURRENT graph prop and the existing flowId (never a blank create) — so it does resume ON the built-so-far draft, not from scratch. That said, flows_build (ops.rs) spins up a fresh workflow_builder agent per RPC with no server-side tool-history/checkpoint to reattach to, so this was never a literal seamless mid-thought resume — the copy overpromised that. Reworded flows.copilot.cappedNotice (all 14 locales) and the surrounding code comments in WorkflowCopilotPanel.tsx to accurately say Continue builds forward from the current draft (graph + accumulated instruction via pendingAskRef), not that it resumes the exact in-flight reasoning. Added a regression test asserting the Continue turn carries flowId.

}, [submit, t]);

const handleInputKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Enter' && !event.shiftKey && !isComposingTextRef.current) {
Expand Down Expand Up @@ -513,6 +539,33 @@ export default function WorkflowCopilotPanel({
</div>
</div>
)}

{/* (B34) The turn hit the agent's iteration limit with no proposal
yet — distinguish this from a voluntary clarifying question (which
renders as a plain agent bubble above, no card) with an explicit
"reached its iteration limit" signal and a one-click resume that
continues building from the current draft (see `continueBuilding`
above for why this is accurate rather than a seamless resume).
Never shown alongside `sending` (a fresh turn already cleared
`capped`) or a proposal (mutually exclusive server-side — see
`ops.rs`). */}
{capped && !sending && !proposal && (
<div
data-testid="workflow-copilot-capped"
className="rounded-xl border border-amber-300 bg-surface p-3 dark:border-amber-700">
<p className="text-xs text-content-secondary">{t('flows.copilot.cappedNotice')}</p>
<div className="mt-2">
<Button
type="button"
variant="secondary"
size="sm"
data-testid="workflow-copilot-continue"
onClick={continueBuilding}>
{t('flows.copilot.continueBuilding')}
</Button>
</div>
</div>
)}
</div>

<div className="border-t border-line px-3 py-2.5">
Expand Down
57 changes: 57 additions & 0 deletions app/src/hooks/useWorkflowBuilderChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const okResult = (over: Partial<BuilderTurnResult> = {}): BuilderTurnResult => (
proposal: null,
assistantText: 'done',
error: null,
capped: false,
...over,
});

Expand Down Expand Up @@ -129,6 +130,62 @@ describe('useWorkflowBuilderChat', () => {
);
});

it('B34: surfaces `capped` when the turn hit the iteration cap with no proposal', async () => {
buildWorkflow.mockResolvedValue(okResult({ capped: true, proposal: null }));
const { result } = renderHook(() => useWorkflowBuilderChat());
expect(result.current.capped).toBe(false);
await act(async () => {
await result.current.send({
displayText: 'build me something complex',
request: { mode: 'create', instruction: 'x' },
});
});
expect(result.current.capped).toBe(true);
});

it('B34: does not surface `capped` for a normal turn (not capped)', async () => {
buildWorkflow.mockResolvedValue(okResult({ capped: false }));
const { result } = renderHook(() => useWorkflowBuilderChat());
await act(async () => {
await result.current.send({
displayText: 'hi',
request: { mode: 'create', instruction: 'x' },
});
});
expect(result.current.capped).toBe(false);
});

it('B34: resets a stale `capped=true` at the start of a new turn even if the server sends capped again with a proposal', async () => {
// First turn: capped, no proposal.
buildWorkflow.mockResolvedValueOnce(okResult({ capped: true, proposal: null }));
const { result } = renderHook(() => useWorkflowBuilderChat());
await act(async () => {
await result.current.send({
displayText: 'build me something complex',
request: { mode: 'create', instruction: 'x' },
});
});
expect(result.current.capped).toBe(true);

// Second turn resolves with a proposal — `capped` must clear even if the
// (malformed/stale) server payload still says `capped: true`, since the
// hook re-checks `!result.proposal` itself, not just at the top of send().
const proposal: WorkflowProposal = {
name: 'Digest',
graph: { nodes: [], edges: [] },
requireApproval: true,
summary: { trigger: 'schedule', steps: [] },
};
buildWorkflow.mockResolvedValueOnce(okResult({ capped: true, proposal }));
await act(async () => {
await result.current.send({
displayText: 'continue',
request: { mode: 'revise', instruction: 'continue' },
});
});
expect(result.current.capped).toBe(false);
});

it('appends the user turn locally but never the agent reply — onDone is the single authoritative path (B26)', async () => {
buildWorkflow.mockResolvedValue(okResult({ assistantText: 'Here is your workflow.' }));
const { result } = renderHook(() => useWorkflowBuilderChat());
Expand Down
22 changes: 22 additions & 0 deletions app/src/hooks/useWorkflowBuilderChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,16 @@ export interface UseWorkflowBuilderChat {
sending: boolean;
/** The latest proposal the agent returned on this thread, or `null`. */
proposal: WorkflowProposal | null;
/**
* `true` when the most recently settled turn paused because it hit the
* agent's tool-call budget with no proposal yet (B34) — the caller should
* render a "Continue building" affordance instead of treating
* `displayMessages`' latest agent bubble (the raw "Done so far / Next
* steps" checkpoint) as a normal reply or a clarifying question. Reset to
* `false` at the start of every new `send()` call, so it only ever
* reflects the most recent turn.
*/
capped: boolean;
/**
* The dedicated thread's FULL transcript (user + agent turns, including
* between-tool narration bubbles), so a caller that needs the complete
Expand Down Expand Up @@ -164,6 +174,7 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
const [threadId, setThreadId] = useState<string | null>(seedThreadId ?? null);
const [localSending, setLocalSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [capped, setCapped] = useState(false);
// Tracks a thread id this hook created itself via `send()`'s `createNewThread`
// call — as opposed to one that arrived from `seedThreadId` because a caller
// (e.g. `WorkflowCopilotPanel`) reports every `threadId` change back up via
Expand Down Expand Up @@ -295,6 +306,10 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
}
setLocalSending(true);
setError(null);
// A fresh turn supersedes any prior cap-hit signal, same as the
// proposal-clearing dispatch below — `capped` must only ever reflect
// this turn, not a stale one.
setCapped(false);
let targetThreadId = threadId;
let proposed = false;
try {
Expand Down Expand Up @@ -344,6 +359,12 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
} else if (result.error) {
setError(result.error);
}
// (B34) Surface the cap-hit signal so the panel can render a
// "Continue building" card instead of the raw checkpoint text as a
// normal reply. Scoped to `!result.proposal` server-side already
// (`ops.rs`'s `capped` field), but re-checked here too — a proposal
// means there's nothing left to "continue".
setCapped(result.capped && !result.proposal);
// Note: no local fallback append for `result.assistantText` here (B26).
// `ChatRuntimeProvider.onDone` is the SINGLE authoritative path that
// persists the assistant's reply on the turn's `chat_done` event — the
Expand Down Expand Up @@ -392,6 +413,7 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
threadId,
sending,
proposal,
capped,
messages,
displayMessages,
toolTimeline,
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4002,6 +4002,9 @@ const messages: TranslationMap = {
'flows.copilot.tool.dryRunning': 'تجربة تشغيل سير العمل…',
'flows.copilot.tool.saving': 'حفظ سير العمل…',
'flows.copilot.tool.usingTools': 'استخدام الأدوات…',
'flows.copilot.cappedNotice':
'بلغ المُنشئ الحد الأقصى لعدد التكرارات قبل الانتهاء من سير العمل هذا. عند المتابعة، سيستمر البناء انطلاقًا من المسودة الحالية.',
'flows.copilot.continueBuilding': 'متابعة الإنشاء',
'flows.list.view': 'عرض سير العمل',
'flows.list.export': 'تصدير',
'flows.list.exported': 'تم تصدير سير العمل',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/bn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4100,6 +4100,9 @@ const messages: TranslationMap = {
'flows.copilot.tool.dryRunning': 'ওয়ার্কফ্লো ড্রাই-রান করা হচ্ছে…',
'flows.copilot.tool.saving': 'ওয়ার্কফ্লো সংরক্ষণ করা হচ্ছে…',
'flows.copilot.tool.usingTools': 'টুল ব্যবহার করা হচ্ছে…',
'flows.copilot.cappedNotice':
'এই ওয়ার্কফ্লো শেষ করার আগেই বিল্ডার তার পুনরাবৃত্তির সীমায় পৌঁছে গেছে। চালিয়ে গেলে এটি বর্তমান খসড়া থেকে নির্মাণ চালিয়ে যাবে।',
'flows.copilot.continueBuilding': 'নির্মাণ চালিয়ে যান',
'flows.list.view': 'ওয়ার্কফ্লো দেখুন',
'flows.list.export': 'রপ্তানি',
'flows.list.exported': 'ওয়ার্কফ্লো রপ্তানি হয়েছে',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4218,6 +4218,9 @@ const messages: TranslationMap = {
'flows.copilot.tool.dryRunning': 'Workflow wird testweise ausgeführt…',
'flows.copilot.tool.saving': 'Workflow wird gespeichert…',
'flows.copilot.tool.usingTools': 'Werkzeuge werden verwendet…',
'flows.copilot.cappedNotice':
'Der Builder hat sein Iterationslimit erreicht, bevor dieser Workflow fertiggestellt wurde. Beim Fortsetzen wird auf Basis des aktuellen Entwurfs weitergebaut.',
'flows.copilot.continueBuilding': 'Weiter erstellen',
'flows.list.view': 'Workflow anzeigen',
'flows.list.export': 'Exportieren',
'flows.list.exported': 'Workflow exportiert',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4787,6 +4787,9 @@ const en: TranslationMap = {
'flows.copilot.tool.dryRunning': 'Dry-running workflow…',
'flows.copilot.tool.saving': 'Saving workflow…',
'flows.copilot.tool.usingTools': 'Using tools…',
'flows.copilot.cappedNotice':
'The builder reached its iteration limit before finishing this workflow. Continuing will keep building from the current draft.',
'flows.copilot.continueBuilding': 'Continue building',

// ── Workflow Canvas (issue B5b.1) — the read-only graph view of a saved
// flow at /flows/:id. `flows.nodeKind.*` labels the 12 tinyflows node
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4172,6 +4172,9 @@ const messages: TranslationMap = {
'flows.copilot.tool.dryRunning': 'Ejecutando prueba del flujo de trabajo…',
'flows.copilot.tool.saving': 'Guardando flujo de trabajo…',
'flows.copilot.tool.usingTools': 'Usando herramientas…',
'flows.copilot.cappedNotice':
'El generador alcanzó su límite de iteraciones antes de terminar este flujo de trabajo. Al continuar, seguirá construyendo a partir del borrador actual.',
'flows.copilot.continueBuilding': 'Continuar creando',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
'flows.list.view': 'Ver flujo de trabajo',
'flows.list.export': 'Exportar',
'flows.list.exported': 'Flujo de trabajo exportado',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4200,6 +4200,9 @@ const messages: TranslationMap = {
'flows.copilot.tool.dryRunning': 'Exécution d’essai du workflow…',
'flows.copilot.tool.saving': 'Enregistrement du workflow…',
'flows.copilot.tool.usingTools': 'Utilisation d’outils…',
'flows.copilot.cappedNotice':
'Le générateur a atteint sa limite d’itérations avant de terminer ce workflow. En continuant, la construction reprendra à partir du brouillon actuel.',
'flows.copilot.continueBuilding': 'Continuer la création',
'flows.list.view': 'Voir le workflow',
'flows.list.export': 'Exporter',
'flows.list.exported': 'Workflow exporté',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/hi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4098,6 +4098,9 @@ const messages: TranslationMap = {
'flows.copilot.tool.dryRunning': 'वर्कफ़्लो का ड्राई-रन किया जा रहा है…',
'flows.copilot.tool.saving': 'वर्कफ़्लो सहेजा जा रहा है…',
'flows.copilot.tool.usingTools': 'टूल का उपयोग किया जा रहा है…',
'flows.copilot.cappedNotice':
'यह वर्कफ़्लो पूरा होने से पहले बिल्डर अपनी पुनरावृत्ति सीमा तक पहुँच गया। जारी रखने पर यह मौजूदा ड्राफ़्ट से आगे निर्माण करेगा।',
'flows.copilot.continueBuilding': 'निर्माण जारी रखें',
'flows.list.view': 'वर्कफ़्लो देखें',
'flows.list.export': 'निर्यात',
'flows.list.exported': 'वर्कफ़्लो निर्यात किया गया',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4115,6 +4115,9 @@ const messages: TranslationMap = {
'flows.copilot.tool.dryRunning': 'Menjalankan uji coba alur kerja…',
'flows.copilot.tool.saving': 'Menyimpan alur kerja…',
'flows.copilot.tool.usingTools': 'Menggunakan alat…',
'flows.copilot.cappedNotice':
'Builder mencapai batas iterasinya sebelum menyelesaikan alur kerja ini. Jika dilanjutkan, builder akan terus membangun dari draf saat ini.',
'flows.copilot.continueBuilding': 'Lanjutkan membangun',
'flows.list.view': 'Lihat alur kerja',
'flows.list.export': 'Ekspor',
'flows.list.exported': 'Alur kerja diekspor',
Expand Down
Loading
Loading