From 5a4f147172041be91ad0a5126781c576ac84dbc7 Mon Sep 17 00:00:00 2001 From: John Smith Date: Thu, 24 Sep 2026 04:19:19 -0400 Subject: [PATCH] feat(ai): resume a stalled studio manager with a bounded nudge --- AUTONOMOUS_STATE.json | 14 +- fork-divergence.json | 4 + newIDE/app/src/AI/CustomAIClient.js | 1 - newIDE/app/src/AiGeneration/AiRequestUtils.js | 4 - .../src/AiGeneration/AskAiEditorContainer.js | 13 ++ .../src/AiGeneration/Studio/NudgePolicy.js | 95 ++++++++ .../AiGeneration/Studio/NudgePolicy.spec.js | 152 +++++++++++++ .../src/AiGeneration/Studio/UseStudioNudge.js | 159 +++++++++++++ .../Studio/UseStudioNudge.spec.js | 215 ++++++++++++++++++ runs/RUNS.md | 7 + 10 files changed, 656 insertions(+), 8 deletions(-) create mode 100644 newIDE/app/src/AiGeneration/Studio/NudgePolicy.js create mode 100644 newIDE/app/src/AiGeneration/Studio/NudgePolicy.spec.js create mode 100644 newIDE/app/src/AiGeneration/Studio/UseStudioNudge.js create mode 100644 newIDE/app/src/AiGeneration/Studio/UseStudioNudge.spec.js diff --git a/AUTONOMOUS_STATE.json b/AUTONOMOUS_STATE.json index 6641916a8..70d40ae86 100644 --- a/AUTONOMOUS_STATE.json +++ b/AUTONOMOUS_STATE.json @@ -7,7 +7,7 @@ "asset-curator", "qa-playtester" ], - "last_cycle": 249, + "last_cycle": 250, "completed_targets": [ { "cycle": 75, @@ -1468,10 +1468,16 @@ "target": "Tier 1/2 multi-agent UI: reveal each finished sub-agent's report inline from the dashboard", "metric_delta": "A finished row in the per-agent dashboard is now clickable and expands to that sub-agent's own report - the 'what did each agent do' half of a multi-agent panel, using data already in the parent transcript (no child persistence). Added the pure, tested extractFunctionCallReport (reads the studio {message} object, a JSON-string payload's message/report, and a plain-text report; returns null for empty values and for machine JSON/scalar payloads with no message - so a generic tool blob is never shown as prose), added `report` to SubAgentActivityRow/listSubAgentActivity (first-output-wins, matching call de-duplication), and made the panel render a truncated (<4000 chars) report. +8 specs (report shapes, still-working null, extract edge cases). Live-verified: the real benchmark chat's finished designer row expanded to its report text; the working developer row stayed non-clickable. Tests 1670 AI-surface (up from 1662); build green; ESLint/Prettier/divergence clean.", "commit": "feat(ai): reveal each finished sub-agent's report inline in the dashboard" + }, + { + "cycle": 250, + "target": "Tier 1 orchestrator reliability: resume a studio manager that stalls after planning (bounded, tested)", + "metric_delta": "Under BYOK nothing resumed a manager that produced a plan, then ended its turn without delegating - the multi-agent loop idled forever (the recurring 'local model plans then stops' gap). Added the fix end-to-end: (1) pure, tested NudgePolicy (shouldNudgeStudioManager + getActionablePlanSignature + buildStudioNudgeMessage, 13 specs) decides when to nudge; (2) useStudioNudge hook (8 specs with fake timers + react-test-renderer) arms a 5s grace timer, sends once per plan state, caps at 5 per request, only for the top-level non-role manager, only a recent (<=10min) request, skips suspended/error/sending/in-progress/live-or-pending-sub-agent; (3) wired beside useStudioRuntime in AskAiEditorContainer. Live end-to-end proof against the scriptable mock: transcript user -> plan output -> user[NUDGE] -> assistant create_or_update_plan -> assistant spawn_agent (the stalled manager resumed and delegated; exactly one nudge). Also deduplicated two merge-artifact comment blocks (CustomAIClient.js, AiRequestUtils.js). Tests 1691 AI-surface / 83 suites (up from 1670/81); build green; ESLint/Prettier/divergence clean (4 new forkOnly files recorded).", + "commit": "feat(ai): resume a stalled studio manager with a bounded nudge" } ], "backlog": [ - "Model-compliance (Tier 1): the local deepseek-v4.1-flash:cloud often plans/inspects then stops without calling spawn_agent; a stronger model or an automatic continuation nudge (design as a small scheduler outside AI/CustomAIClient, which cannot import Studio/) would improve real-model completion.", + "Tier 1 orchestration (follow-up to cycle 250): the nudge resumes a stalled manager, but the manager prompt itself could be tightened and a stronger local model still delegates more reliably; consider measuring the nudge's real-model effect with the prompt-city benchmark.", "Harness: the committed scripts/dev/mock-ai-provider.js lets @feedback-loop run offline; use it to script deterministic tool-call sequences and assert the studio loop without a real model.", "Watch for the ubuntu-latest -> Ubuntu 26 runner migration notice (dated 2026-10-19); workflows are restored from local master by upstream-sync.", "Upstream community 3D PRs (#8343-#8349): cherry-pick only if upstream stalls (roadmap direction B). Strategic, not queued.", @@ -1677,6 +1683,8 @@ "The agent loop's client guard (LoopGuard, repeatedToolLimit 8) and the harness's thrash signature (>=3 identical probes) are different things by design: the guard must avoid constraining legitimate long tasks, the audit flags early for human review. Do not 'align' them by lowering the guard.", "The sub-agent audit line is now an expandable dashboard; keep listSubAgentActivity the single source of truth for per-agent rows and derive summarizeSubAgentActivity/sumSubAgentTokens from it rather than re-walking the output.", "Run Jest through `npm test` only: the package's test script is `react-app-rewired test --env=node`, and invoking `npx react-app-rewired test` without `--env=node` makes 21 CustomAIClient streaming specs fail with 'TextEncoder is not defined'. A bare-invocation failure is environmental, not a regression.", - "For a multi-agent UI, the sub-agent's report is already in the parent's output as a function_call_output (written by useStudioRuntime); surface that instead of persisting child transcripts. extractFunctionCallReport handles both the {message} object and JSON-string shapes." + "For a multi-agent UI, the sub-agent's report is already in the parent's output as a function_call_output (written by useStudioRuntime); surface that instead of persisting child transcripts. extractFunctionCallReport handles both the {message} object and JSON-string shapes.", + "The CRA dev server may hold a stale bundle after long edit sessions; verify a new feature is actually served by fetching the loaded app chunk (the big src_ExportAndShare_* chunk contains the app under test) and grepping for a unique symbol, not the small bundle.js. To restart it, kill the `node .../react-app-rewired/scripts/start.js` PID (pkill on 'react-app-rewired start' misses it); wait for 'webpack compiled successfully' in its log.", + "A hook that sends through a timer must flush microtasks in its spec: after jest.advanceTimersByTime, `await TestRenderer.act(async () => { await Promise.resolve(); })`, or the deferred send has not run when the assertions execute." ] } diff --git a/fork-divergence.json b/fork-divergence.json index c9549f594..ea9b56bba 100644 --- a/fork-divergence.json +++ b/fork-divergence.json @@ -166,6 +166,8 @@ "newIDE/app/src/AiGeneration/Studio/FinalizeSubAgents.spec.js", "newIDE/app/src/AiGeneration/Studio/LoopGuard.js", "newIDE/app/src/AiGeneration/Studio/LoopGuard.spec.js", + "newIDE/app/src/AiGeneration/Studio/NudgePolicy.js", + "newIDE/app/src/AiGeneration/Studio/NudgePolicy.spec.js", "newIDE/app/src/AiGeneration/Studio/PlanStore.js", "newIDE/app/src/AiGeneration/Studio/PlanStore.spec.js", "newIDE/app/src/AiGeneration/Studio/RequestWriteGate.js", @@ -178,6 +180,8 @@ "newIDE/app/src/AiGeneration/Studio/SafeTruncation.spec.js", "newIDE/app/src/AiGeneration/Studio/SpawnSubAgents.js", "newIDE/app/src/AiGeneration/Studio/SpawnSubAgents.spec.js", + "newIDE/app/src/AiGeneration/Studio/UseStudioNudge.js", + "newIDE/app/src/AiGeneration/Studio/UseStudioNudge.spec.js", "newIDE/app/src/AiGeneration/Studio/UseStudioRuntime.js", "newIDE/app/src/AiGeneration/Studio/UseStudioRuntime.spec.js", "newIDE/app/src/MainFrame/Preferences/PreferencesStorage.js", diff --git a/newIDE/app/src/AI/CustomAIClient.js b/newIDE/app/src/AI/CustomAIClient.js index 901d73927..c509600f6 100644 --- a/newIDE/app/src/AI/CustomAIClient.js +++ b/newIDE/app/src/AI/CustomAIClient.js @@ -4272,7 +4272,6 @@ export const customAddMessageToAiRequest = async ({ if (functionCallOutputs && functionCallOutputs.length > 0) { for (const fcOutput of functionCallOutputs) { - // Network-supplied: a hole would throw on the property read. // Network-supplied: a hole would throw on the property read. if (!fcOutput || typeof fcOutput !== 'object') continue; output.push({ diff --git a/newIDE/app/src/AiGeneration/AiRequestUtils.js b/newIDE/app/src/AiGeneration/AiRequestUtils.js index a9f82805b..e0f942939 100644 --- a/newIDE/app/src/AiGeneration/AiRequestUtils.js +++ b/newIDE/app/src/AiGeneration/AiRequestUtils.js @@ -862,10 +862,6 @@ export const getFunctionCallOutputsFromEditorFunctionCallResults = ( return null; } if (functionCallOutput.status === 'finished') { - // `output: any`: spreading a non-object (a string becomes {0:'a',...}, - // an array becomes {0:...}) corrupts the JSON the model receives, so - // only a plain object is spread; anything else is nested under - // `output` so its value survives without numeric keys. // `output: any`: spreading a non-object (a string becomes {0:'a',...}, // an array becomes {0:...}) corrupts the JSON the model receives, so // only a plain object is spread; anything else is nested under diff --git a/newIDE/app/src/AiGeneration/AskAiEditorContainer.js b/newIDE/app/src/AiGeneration/AskAiEditorContainer.js index 3b5a2e0a4..31896fcc1 100644 --- a/newIDE/app/src/AiGeneration/AskAiEditorContainer.js +++ b/newIDE/app/src/AiGeneration/AskAiEditorContainer.js @@ -85,6 +85,7 @@ import UrlStorageProvider from '../ProjectsStorage/UrlStorageProvider'; import { prepareAiUserContent } from './PrepareAiUserContent'; import { AiRequestContext } from './AiRequestContext'; import { useStudioRuntime } from './Studio/UseStudioRuntime'; +import { useStudioNudge } from './Studio/UseStudioNudge'; import { getAiConfigurationPresetsWithAvailability } from './AiConfiguration'; import { setEditorHotReloadNeeded, @@ -1204,6 +1205,18 @@ export const AskAiEditor: React.ComponentType = React.memo( enqueueRequestWrite, }); + // Keeps the local manager moving when it plans, then ends its turn without + // delegating (BYOK-only, bounded, one nudge per plan state). A no-op for + // the hosted path and for sub-agent requests. + useStudioNudge({ + selectedAiRequest, + aiRequests, + activeSubAgents, + getEditorFunctionCallResults, + isSendingAiRequest, + onSendMessage, + }); + // Wrap onProcessFunctionCalls to bind the selected AI request for the chat UI. const onProcessSelectedAiRequestFunctionCalls = React.useCallback( async (functionCalls: Array) => { diff --git a/newIDE/app/src/AiGeneration/Studio/NudgePolicy.js b/newIDE/app/src/AiGeneration/Studio/NudgePolicy.js new file mode 100644 index 000000000..8eb5bb400 --- /dev/null +++ b/newIDE/app/src/AiGeneration/Studio/NudgePolicy.js @@ -0,0 +1,95 @@ +// @flow + +import { type StudioPlanTask } from './PlanStore'; + +/** + * When the local studio's manager has stalled: it produced a plan, then ended + * its turn with an actionable task still undelegated (and no sub-agent running), + * so the multi-agent loop would sit idle forever under BYOK. + * + * This is the decision half of `useStudioNudge`, kept pure and dependency-light + * (`PlanStore`'s types only) so it can be unit-tested. The caller supplies the + * facts it already has: whether the request is the top-level manager, whether a + * turn is in flight, how many sub-agents are live and how many spawn calls are + * still unanswered. + * + * The nudge is deliberately conservative: + * - only the top-level manager (never a sub-agent); + * - never while a turn is in flight or work is pending; + * - never with a live or unanswered sub-agent; + * - never twice for the same plan state (the caller passes the signature it + * already nudged), so a stalled plan is nudged once, not every render; + * - a suspended/errored request is left alone (the user may have stopped it). + */ +export type StudioNudgeInput = {| + /** True for the top-level manager request (no parent, no studio role). */ + isTopLevel: boolean, + status: string, + hasWorkInProgress: boolean, + isSending: boolean, + liveSubAgentCount: number, + pendingSubAgentCallCount: number, + /** Non-null only when an actionable (pending/in_progress) task exists. */ + planSignature: string | null, + previouslyNudgedSignature: string | null, + /** + * True only for a request touched recently. Opening an old, abandoned chat + * must not resurrect its plan and spend tokens. + */ + isRecent: boolean, +|}; + +export const shouldNudgeStudioManager = (input: StudioNudgeInput): boolean => { + if (!input.isTopLevel) return false; + if (!input.isRecent) return false; + if (input.status === 'suspended' || input.status === 'error') return false; + if (input.isSending) return false; + if (input.hasWorkInProgress) return false; + if (input.liveSubAgentCount > 0) return false; + if (input.pendingSubAgentCallCount > 0) return false; + if (!input.planSignature) return false; + return input.planSignature !== input.previouslyNudgedSignature; +}; + +/** + * A stable signature of a plan's actionable state: the ids and statuses of the + * tasks that still need attention, in order. Null when there is no plan or + * nothing is actionable (every task is done or voided). + * + * `agentCallId` is included so a task that was delegated (but whose status has + * not flipped yet) is still distinguishable; a status-only signature would + * miss it only in the window before the spawn is stamped. + */ +export const getActionablePlanSignature = ( + tasks: Array | null | void +): string | null => { + if (!Array.isArray(tasks) || tasks.length === 0) return null; + const actionable = tasks.filter( + task => + task && + typeof task === 'object' && + (task.status === 'pending' || task.status === 'in_progress') + ); + if (actionable.length === 0) return null; + return actionable + .map(task => `${task.id}:${task.status}:${task.agentCallId || ''}`) + .join('|'); +}; + +/** + * The follow-up message the nudge sends to the stalled manager. Written as the + * user so it flows through the existing send path unchanged, and prefixed so + * the transcript makes clear it was the studio, not the person. + */ +export const buildStudioNudgeMessage = ( + nextReadyTask: StudioPlanTask | null +): string => { + const taskHint = nextReadyTask + ? ` Next ready task: ${nextReadyTask.id} (${nextReadyTask.title}).` + : ''; + return ( + '[Automatic reminder from the studio] Your plan still has a task that is ' + + `not delegated.${taskHint} Call spawn_agent for it now, or report plainly ` + + 'why it cannot be done. Do not answer with prose only.' + ); +}; diff --git a/newIDE/app/src/AiGeneration/Studio/NudgePolicy.spec.js b/newIDE/app/src/AiGeneration/Studio/NudgePolicy.spec.js new file mode 100644 index 000000000..4e65e0d9f --- /dev/null +++ b/newIDE/app/src/AiGeneration/Studio/NudgePolicy.spec.js @@ -0,0 +1,152 @@ +// @flow +import { + shouldNudgeStudioManager, + getActionablePlanSignature, + buildStudioNudgeMessage, +} from './NudgePolicy'; + +const baseInput = { + isTopLevel: true, + status: 'ready', + hasWorkInProgress: false, + isSending: false, + liveSubAgentCount: 0, + pendingSubAgentCallCount: 0, + planSignature: 'task_1:pending:', + previouslyNudgedSignature: null, + isRecent: true, +}; + +describe('shouldNudgeStudioManager', () => { + it('nudges a stalled top-level manager with an actionable task', () => { + expect(shouldNudgeStudioManager((baseInput: any))).toBe(true); + }); + + it('never nudges a sub-agent (not top-level)', () => { + expect( + shouldNudgeStudioManager(({ ...baseInput, isTopLevel: false }: any)) + ).toBe(false); + }); + + it('never nudges an old, abandoned request', () => { + expect( + shouldNudgeStudioManager(({ ...baseInput, isRecent: false }: any)) + ).toBe(false); + }); + + it('never nudges while a turn is in flight or work is pending', () => { + expect( + shouldNudgeStudioManager(({ ...baseInput, isSending: true }: any)) + ).toBe(false); + expect( + shouldNudgeStudioManager(({ ...baseInput, hasWorkInProgress: true }: any)) + ).toBe(false); + }); + + it('never nudges while a sub-agent is live or its call is unanswered', () => { + expect( + shouldNudgeStudioManager(({ ...baseInput, liveSubAgentCount: 1 }: any)) + ).toBe(false); + expect( + shouldNudgeStudioManager( + ({ + ...baseInput, + pendingSubAgentCallCount: 1, + }: any) + ) + ).toBe(false); + }); + + it('does not nudge a plan with nothing actionable', () => { + expect( + shouldNudgeStudioManager(({ ...baseInput, planSignature: null }: any)) + ).toBe(false); + }); + + it('leaves a suspended or errored request alone', () => { + expect( + shouldNudgeStudioManager(({ ...baseInput, status: 'suspended' }: any)) + ).toBe(false); + expect( + shouldNudgeStudioManager(({ ...baseInput, status: 'error' }: any)) + ).toBe(false); + }); + + it('does not re-nudge the same plan state, but does nudge a new one', () => { + expect( + shouldNudgeStudioManager( + ({ + ...baseInput, + previouslyNudgedSignature: 'task_1:pending:', + }: any) + ) + ).toBe(false); + expect( + shouldNudgeStudioManager( + ({ + ...baseInput, + planSignature: 'task_2:in_progress:', + previouslyNudgedSignature: 'task_1:pending:', + }: any) + ) + ).toBe(true); + }); +}); + +describe('getActionablePlanSignature', () => { + it('is null for no plan or nothing actionable', () => { + expect(getActionablePlanSignature(null)).toBeNull(); + expect(getActionablePlanSignature([])).toBeNull(); + expect( + getActionablePlanSignature([ + ({ id: 'a', title: 'A', description: '', status: 'done' }: any), + ({ id: 'b', title: 'B', description: '', status: 'voided' }: any), + ]) + ).toBeNull(); + }); + + it('signs the actionable tasks with their status and delegated call', () => { + const signature = getActionablePlanSignature([ + ({ id: 'a', title: 'A', description: '', status: 'done' }: any), + ({ + id: 'b', + title: 'B', + description: '', + status: 'in_progress', + agentCallId: 'call_1', + }: any), + ({ id: 'c', title: 'C', description: '', status: 'pending' }: any), + ]); + expect(signature).toBe('b:in_progress:call_1|c:pending:'); + }); + + it('tolerates null holes in the task list', () => { + const signature = getActionablePlanSignature([ + (null: any), + ({ id: 'c', title: 'C', description: '', status: 'pending' }: any), + ]); + expect(signature).toBe('c:pending:'); + }); +}); + +describe('buildStudioNudgeMessage', () => { + it('names the next ready task and demands delegation', () => { + const message = buildStudioNudgeMessage( + ({ + id: 'task_2', + title: 'Build the scene', + description: '', + status: 'pending', + }: any) + ); + expect(message).toContain('task_2'); + expect(message).toContain('Build the scene'); + expect(message).toContain('spawn_agent'); + }); + + it('still demands delegation with no ready task', () => { + const message = buildStudioNudgeMessage(null); + expect(message).toContain('spawn_agent'); + expect(message).not.toContain('Next ready task'); + }); +}); diff --git a/newIDE/app/src/AiGeneration/Studio/UseStudioNudge.js b/newIDE/app/src/AiGeneration/Studio/UseStudioNudge.js new file mode 100644 index 000000000..f94a43ce4 --- /dev/null +++ b/newIDE/app/src/AiGeneration/Studio/UseStudioNudge.js @@ -0,0 +1,159 @@ +// @flow + +import * as React from 'react'; +import { type AiRequest } from '../../Utils/GDevelopServices/Generation'; +import { type EditorFunctionCallResult } from '../../EditorFunctions'; +import { + aiRequestHasWorkInProgress, + getLatestActivePlan, + getPendingSubAgentFunctionCalls, +} from '../AiRequestUtils'; +import { getNextReadyTask, type StudioPlanTask } from './PlanStore'; +import { + shouldNudgeStudioManager, + getActionablePlanSignature, + buildStudioNudgeMessage, +} from './NudgePolicy'; + +type ActiveSubAgent = {| + parentAiRequestId: string, + callId: string, +|}; + +/** + * How long the manager must stay stalled before the nudge is sent. A grace + * period, not a poll interval: the effect is re-run (and the timer cleared) on + * every relevant state change, so this only has to outlast the sub-second gap + * between writing a tool result and the follow-up turn starting. + */ +export const STUDIO_NUDGE_GRACE_MS = 5000; + +/** Hard cap on nudges for one request, so a pathological model cannot loop. */ +export const MAX_STUDIO_NUDGES_PER_REQUEST = 5; + +/** + * Only a request touched within this window may be nudged. Opening an old, + * abandoned chat must not resurrect its plan and spend tokens; the window is + * generous enough that a genuinely-stalled live run is always in range. + */ +export const STUDIO_NUDGE_MAX_AGE_MS = 10 * 60 * 1000; + +/** + * Keep the local studio's manager moving. + * + * Under BYOK nothing resumes a manager that produced a plan and then ended its + * turn without delegating: the multi-agent loop waits forever. This hook sends + * at most one continuation per stalled plan state (`NudgePolicy` decides when), + * with a short grace period so it never races a turn already in flight. + * + * It is a no-op for the hosted path and for sub-agent requests (only the + * top-level, non-role request is nudged) and for a user-stopped request. + */ +export const useStudioNudge = ({ + selectedAiRequest, + aiRequests, + activeSubAgents, + getEditorFunctionCallResults, + isSendingAiRequest, + onSendMessage, +}: {| + selectedAiRequest: ?AiRequest, + aiRequests: { [string]: AiRequest }, + activeSubAgents: { [string]: ActiveSubAgent }, + getEditorFunctionCallResults: string => Array | null, + isSendingAiRequest: (aiRequestId: string | null) => boolean, + onSendMessage: (options: Object) => Promise, +|}): void => { + // Per-request: the plan state already nudged, and how many nudges were sent. + const nudgedSignatureByRequestRef = React.useRef>( + new Map() + ); + const nudgedCountByRequestRef = React.useRef>(new Map()); + + React.useEffect( + () => { + if (!selectedAiRequest) return; + const aiRequestId = selectedAiRequest.id; + + const isTopLevel = + !selectedAiRequest.parentAiRequestId && !selectedAiRequest.studioRoleId; + if (!isTopLevel) return; + + const plan = getLatestActivePlan(selectedAiRequest); + const tasks: Array = plan && plan.tasks ? plan.tasks : []; + const planSignature = getActionablePlanSignature(tasks); + const liveSubAgentCount = Object.keys(activeSubAgents).filter( + subAgentId => + activeSubAgents[subAgentId] && + activeSubAgents[subAgentId].parentAiRequestId === aiRequestId + ).length; + + const updatedAtMs = Date.parse(selectedAiRequest.updatedAt || ''); + const isRecent = + Number.isFinite(updatedAtMs) && + Date.now() - updatedAtMs <= STUDIO_NUDGE_MAX_AGE_MS; + + const shouldNudge = shouldNudgeStudioManager({ + isTopLevel, + isRecent, + status: selectedAiRequest.status, + hasWorkInProgress: aiRequestHasWorkInProgress( + selectedAiRequest, + getEditorFunctionCallResults(aiRequestId) + ), + isSending: isSendingAiRequest(aiRequestId), + liveSubAgentCount, + pendingSubAgentCallCount: getPendingSubAgentFunctionCalls({ + aiRequest: selectedAiRequest, + }).length, + planSignature, + previouslyNudgedSignature: + nudgedSignatureByRequestRef.current.get(aiRequestId) || null, + }); + if (!shouldNudge || !planSignature) return; + + const nudgedCount = nudgedCountByRequestRef.current.get(aiRequestId) || 0; + if (nudgedCount >= MAX_STUDIO_NUDGES_PER_REQUEST) return; + + const nextReadyTask = getNextReadyTask(tasks); + const timer = setTimeout(() => { + // Mark before the send: a re-render landing during the await must not + // arm a second nudge for the same plan state. + nudgedSignatureByRequestRef.current.set(aiRequestId, planSignature); + nudgedCountByRequestRef.current.set(aiRequestId, nudgedCount + 1); + console.info( + `[studio] nudging the stalled manager #${aiRequestId} for plan ${planSignature}.` + ); + // `onSendMessage` can return undefined (its own early-return path), so it + // is not necessarily a promise and may also throw synchronously. + Promise.resolve() + .then(() => + onSendMessage({ + aiRequestId, + userMessage: buildStudioNudgeMessage(nextReadyTask), + }) + ) + .catch(error => { + console.error('[studio] error while nudging the manager:', error); + // Let the next pass retry the same state. + if ( + nudgedSignatureByRequestRef.current.get(aiRequestId) === + planSignature + ) { + nudgedSignatureByRequestRef.current.delete(aiRequestId); + } + }); + }, STUDIO_NUDGE_GRACE_MS); + + return () => clearTimeout(timer); + }, + [ + selectedAiRequest, + aiRequests, + activeSubAgents, + getEditorFunctionCallResults, + isSendingAiRequest, + onSendMessage, + ] + ); +}; diff --git a/newIDE/app/src/AiGeneration/Studio/UseStudioNudge.spec.js b/newIDE/app/src/AiGeneration/Studio/UseStudioNudge.spec.js new file mode 100644 index 000000000..2403ceac1 --- /dev/null +++ b/newIDE/app/src/AiGeneration/Studio/UseStudioNudge.spec.js @@ -0,0 +1,215 @@ +// @flow +import * as React from 'react'; +import TestRenderer from 'react-test-renderer'; +import { useStudioNudge, STUDIO_NUDGE_GRACE_MS } from './UseStudioNudge'; + +/** + * The hook is mounted through a tiny harness so its timer/effect behavior can be + * driven deterministically with fake timers - the mock provider cannot prove + * this half (it depends on the client's real turn loop), and a real model's + * compliance is variable. + */ +const Harness = (props: any) => { + useStudioNudge(props); + return null; +}; + +const PLAN_OUTPUT = JSON.stringify({ + success: true, + plan: { + tasks: [ + { + id: 'task_1', + title: 'Design', + description: 'Write the design.', + status: 'pending', + }, + ], + }, +}); + +const makeRequest = (overrides: Object = {}) => ({ + id: 'req-1', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + userId: 'local-byok-user', + status: 'ready', + error: null, + output: [ + { + type: 'function_call_output', + call_id: 'plan_call', + output: PLAN_OUTPUT, + }, + ], + ...overrides, +}); + +const makeProps = (overrides: Object = {}) => { + const request = makeRequest(); + return { + selectedAiRequest: request, + aiRequests: { 'req-1': request }, + activeSubAgents: {}, + getEditorFunctionCallResults: () => null, + isSendingAiRequest: () => false, + onSendMessage: jest.fn(async () => true), + ...overrides, + }; +}; + +describe('useStudioNudge', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + const render = (props: Object) => { + let renderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(React.createElement(Harness, props)); + }); + return renderer; + }; + + const advancePastGrace = async () => { + // The timer callback defers the send through a microtask, so flush it. + await TestRenderer.act(async () => { + jest.advanceTimersByTime(STUDIO_NUDGE_GRACE_MS + 10); + await Promise.resolve(); + await Promise.resolve(); + }); + }; + + it('nudges a stalled manager once after the grace period', async () => { + const onSendMessage = jest.fn(async () => true); + render(makeProps({ onSendMessage })); + await advancePastGrace(); + + expect(onSendMessage).toHaveBeenCalledTimes(1); + expect(onSendMessage.mock.calls[0][0].aiRequestId).toBe('req-1'); + expect(String(onSendMessage.mock.calls[0][0].userMessage)).toContain( + 'spawn_agent' + ); + }); + + it('does not nudge while a sub-agent is live', async () => { + const onSendMessage = jest.fn(async () => true); + render( + makeProps({ + onSendMessage, + activeSubAgents: { + 'sub-1': { parentAiRequestId: 'req-1', callId: 'call_1' }, + }, + }) + ); + await advancePastGrace(); + expect(onSendMessage).not.toHaveBeenCalled(); + }); + + it('does not nudge while a turn is in flight', async () => { + const onSendMessage = jest.fn(async () => true); + render(makeProps({ onSendMessage, isSendingAiRequest: () => true })); + await advancePastGrace(); + expect(onSendMessage).not.toHaveBeenCalled(); + }); + + it('does not nudge a sub-agent request', async () => { + const onSendMessage = jest.fn(async () => true); + const request = makeRequest({ + parentAiRequestId: 'parent-1', + studioRoleId: 'developer', + }); + render( + makeProps({ + onSendMessage, + selectedAiRequest: request, + aiRequests: { 'req-1': request }, + }) + ); + await advancePastGrace(); + expect(onSendMessage).not.toHaveBeenCalled(); + }); + + it('does not nudge a suspended request', async () => { + const onSendMessage = jest.fn(async () => true); + const request = makeRequest({ status: 'suspended' }); + render( + makeProps({ + onSendMessage, + selectedAiRequest: request, + aiRequests: { 'req-1': request }, + }) + ); + await advancePastGrace(); + expect(onSendMessage).not.toHaveBeenCalled(); + }); + + it('does not nudge an old, abandoned request', async () => { + const onSendMessage = jest.fn(async () => true); + const request = makeRequest({ + updatedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + }); + render( + makeProps({ + onSendMessage, + selectedAiRequest: request, + aiRequests: { 'req-1': request }, + }) + ); + await advancePastGrace(); + expect(onSendMessage).not.toHaveBeenCalled(); + }); + + it('does not nudge a plan whose tasks are all done', async () => { + const onSendMessage = jest.fn(async () => true); + const request = makeRequest({ + output: [ + { + type: 'function_call_output', + call_id: 'plan_call', + output: JSON.stringify({ + success: true, + plan: { + tasks: [ + { + id: 'task_1', + title: 'Design', + description: 'Done.', + status: 'done', + }, + ], + }, + }), + }, + ], + }); + render( + makeProps({ + onSendMessage, + selectedAiRequest: request, + aiRequests: { 'req-1': request }, + }) + ); + await advancePastGrace(); + expect(onSendMessage).not.toHaveBeenCalled(); + }); + + it('does not re-nudge the same plan state on a re-render', async () => { + const onSendMessage = jest.fn(async () => true); + const renderer = render(makeProps({ onSendMessage })); + await advancePastGrace(); + expect(onSendMessage).toHaveBeenCalledTimes(1); + + // A re-render (new props, same stalled plan) must not arm a second nudge. + TestRenderer.act(() => { + renderer.update( + React.createElement(Harness, makeProps({ onSendMessage })) + ); + }); + await advancePastGrace(); + expect(onSendMessage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/runs/RUNS.md b/runs/RUNS.md index 99544e786..e2b885e89 100644 --- a/runs/RUNS.md +++ b/runs/RUNS.md @@ -98,6 +98,13 @@ work; `endpoint` is the AI endpoint under test for that cycle. looping until the guard tripped (cycles 239-241).. ## Proven workflows +- Orchestrator stall recovery, live end-to-end (cycle 250): a manager that + plans and then ends its turn without delegating is now nudged automatically + (bounded, once per plan state). Proven against the scriptable mock provider: + the request transcript went `user → plan output → user[NUDGE] → assistant + create_or_update_plan → assistant spawn_agent` - the nudge resumed the stalled + manager, which then delegated. Only one nudge was sent (no loop); screenshot + /tmp/opencode/cycle250-nudge.png. - Per-agent report on demand (cycle 249): a finished row in the per-agent dashboard is now clickable and reveals that sub-agent's own report (read by the pure, tested `extractFunctionCallReport`, handling the studio `{message}`