feat(ai): resume a stalled studio manager with a bounded nudge

This commit is contained in:
John Smith
2026-09-24 04:19:19 -04:00
parent ec740df324
commit 5a4f147172
10 changed files with 656 additions and 8 deletions
+11 -3
View File
@@ -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."
]
}
+4
View File
@@ -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",
-1
View File
@@ -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({
@@ -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
@@ -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<Props> = React.memo<Props>(
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<AiRequestMessageAssistantFunctionCall>) => {
@@ -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<StudioPlanTask> | 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.'
);
};
@@ -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');
});
});
@@ -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<EditorFunctionCallResult> | null,
isSendingAiRequest: (aiRequestId: string | null) => boolean,
onSendMessage: (options: Object) => Promise<boolean>,
|}): void => {
// Per-request: the plan state already nudged, and how many nudges were sent.
const nudgedSignatureByRequestRef = React.useRef<Map<string, string>>(
new Map()
);
const nudgedCountByRequestRef = React.useRef<Map<string, number>>(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<StudioPlanTask> = 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,
]
);
};
@@ -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);
});
});
+7
View File
@@ -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}`