feat(ai): show each sub-agent's transcript inline in the dashboard

This commit is contained in:
John Smith
2026-09-24 04:34:08 -04:00
parent a11eb64de3
commit 853e6dcc98
8 changed files with 394 additions and 26 deletions
+10 -4
View File
@@ -7,7 +7,7 @@
"asset-curator",
"qa-playtester"
],
"last_cycle": 251,
"last_cycle": 252,
"completed_targets": [
{
"cycle": 75,
@@ -1480,14 +1480,19 @@
"target": "Tier 1 multi-agent UI: link a spawn call to its plan task so the chat renders the delegation inside the task row",
"metric_delta": "A spawn_agent call was never linked to its plan task: ChatMessages' functionCallItemsByTaskId reads functionCall.taskId, but nothing in the BYOK path stamped it (the hosted server does), so delegated calls were shown only at the top level and never under their task. Added: (1) spawnSubAgent now returns relatedTaskId (the plan task the call named); (2) Utils stamps spawnCall.taskId and, in the same write, lifts the plan task to in_progress + agentCallId via new pure PlanStore helpers patchPlanTaskAsDelegated/replacePlanMessage (the status flip is required: a pending task does not render its linked calls, so the spawn would be hidden otherwise); (3) refactored UseStudioRuntime's plan rewrite onto the shared replacePlanMessage (one implementation). Tests +5 PlanStore +1 SpawnSubAgents (1697 AI-surface / 83 suites, up from 1692). Live proof via the mock: spawn carried taskId 'design'; plan became design done (agentCallId c_design), build in_progress; WebUI rendered the 'Design' call row under the 'Design the city-builder GDD' task. Build green; ESLint/Prettier/divergence clean.",
"commit": "feat(ai): link spawn calls to their plan task in the chat"
},
{
"cycle": 252,
"target": "Tier 1 multi-agent chat: view a sub-agent's own transcript inline from the per-agent dashboard",
"metric_delta": "Completed the multi-agent chat surface: expanding an agent row now shows that agent's own conversation (its user request, assistant text and tool calls) via the pure, tested summarizeSubAgentTranscript (caps to 30 steps / 600 chars, tolerant of persisted null holes). Sub-agent requests were not reachable after a reload (children were deliberately excluded from the 20-chat parent budget), so they now persist in their own bounded store gd-custom-ai-subagent-requests (40, gameProjectJson stripped, corrupt/na-parent entries dropped) loaded at startup. +4 transcript specs, +3 persistence specs (1704 AI-surface / 83 suites, up from 1697). Live proof via the mock: the designer child persisted with a read_game_project_json call and the dashboard row expanded to the report + '▸ Task: Write GDD_Overview.', 'Reading the project to ground the design.', '⚙ read_game_project_json', 'GDD_Overview written: a small harbor town.'. Build green; ESLint/Prettier/divergence clean.",
"commit": "feat(ai): show each sub-agent's transcript inline in the dashboard"
}
],
"backlog": [
"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.",
"Tier 1 UI (remaining): true multi-agent chat switching (open a sub-agent's full transcript). The audit dashboard now shows per-agent role/status/tokens/report (cycles 247-249); a full transcript view still needs child-request persistence plus a retention policy, deliberately avoided so far. Reassess only if a user asks for the verbatim sub-agent conversation."
"Upstream community 3D PRs (#8343-#8349): cherry-pick only if upstream stalls (roadmap direction B). Strategic, not queued."
],
"heuristics": [
{
@@ -1692,6 +1697,7 @@
"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.",
"The BYOK studio must stamp what the hosted server stamps on function calls/plan tasks (functionCall.taskId, planTask.agentCallId, planTask.status): the UI reads those fields and silently shows nothing when they are absent. Check the UI reader before assuming the studio writes a linkage field."
"The BYOK studio must stamp what the hosted server stamps on function calls/plan tasks (functionCall.taskId, planTask.agentCallId, planTask.status): the UI reads those fields and silently shows nothing when they are absent. Check the UI reader before assuming the studio writes a linkage field.",
"Sub-agent children need their own bounded persistence store: giving them the parent chat budget evicts real chats, but excluding them entirely makes the per-agent transcript unavailable after a reload. Keep the two budgets separate."
]
}
+78
View File
@@ -70,7 +70,14 @@ const LOCAL_STORAGE_CONFIG_KEY = 'gd-custom-ai-config';
const LOCAL_STORAGE_REQUESTS_KEY = 'gd-custom-ai-requests';
const LOCAL_STORAGE_MODEL_OVERRIDES_KEY = 'gd-custom-ai-model-overrides';
const LOCAL_STORAGE_TOKEN_TOTALS_KEY = 'gd-custom-ai-token-totals';
const LOCAL_STORAGE_SUBAGENT_REQUESTS_KEY = 'gd-custom-ai-subagent-requests';
const MAX_LOCAL_SAVED_REQUESTS = 20;
/**
* Cap on persisted sub-agent requests. They cannot share the parent recency
* budget (20 chats would evict real conversations after one multi-agent run),
* so they get their own store. A run spawns a handful; 40 covers several.
*/
const MAX_LOCAL_SAVED_SUBAGENT_REQUESTS = 40;
/**
* Cap on persisted per-request token totals. The map is keyed by request id
* (sub-agent children included, so it outgrows the 20 saved chats), and must
@@ -1213,13 +1220,84 @@ export const saveLocalAiRequests = () => {
LOCAL_STORAGE_REQUESTS_KEY,
JSON.stringify(persistableMap)
);
// Sub-agent children get their own bounded store. They are internal
// (excluded from the history and from the parent recency budget), but the
// per-agent dashboard's transcript needs them after a reload.
const recentChildKeys = Object.keys(localAiRequestsCache)
.filter(key => {
const req = localAiRequestsCache[key];
return req && req.parentAiRequestId;
})
.sort((a, b) => {
const aAt = new Date(localAiRequestsCache[a].updatedAt).getTime();
const bAt = new Date(localAiRequestsCache[b].updatedAt).getTime();
const safeA = Number.isFinite(aAt) ? aAt : 0;
const safeB = Number.isFinite(bAt) ? bAt : 0;
return safeB - safeA;
})
.slice(0, MAX_LOCAL_SAVED_SUBAGENT_REQUESTS);
const persistableChildMap: { [id: string]: AiRequest } = {};
for (const key of recentChildKeys) {
const req = localAiRequestsCache[key];
if (req) {
const { gameProjectJson, ...persistableReq } = req;
persistableChildMap[key] = (persistableReq: any);
}
}
localStorage.setItem(
LOCAL_STORAGE_SUBAGENT_REQUESTS_KEY,
JSON.stringify(persistableChildMap)
);
} catch (err) {
console.warn('Error saving local AI requests to localStorage:', err);
}
};
/**
* Load the persisted sub-agent requests into the same cache as their parents.
* Called at startup after `loadLocalAiRequests`; a corrupt entry is skipped by
* the same id/status check.
*/
export const loadLocalAiSubAgentRequests = (): void => {
try {
if (typeof localStorage === 'undefined') return;
const persisted = localStorage.getItem(LOCAL_STORAGE_SUBAGENT_REQUESTS_KEY);
if (!persisted) return;
const parsed = JSON.parse(persisted);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return;
const entries = Object.entries(parsed);
for (let i = 0; i < entries.length; i++) {
const key = entries[i][0];
const value = entries[i][1];
if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
typeof value.id === 'string' &&
typeof value.status === 'string' &&
typeof value.parentAiRequestId === 'string'
) {
localAiRequestsCache[key] = (normalizePersistedMessages(
(value: any)
): any);
}
}
} catch (err) {
console.warn(
'Error loading local AI sub-agent requests from localStorage:',
err
);
}
};
/** Exposed so a test can simulate a fresh session's load. */
export const loadLocalAiSubAgentRequestsForTesting = (): void =>
loadLocalAiSubAgentRequests();
// Initialize requests, per-chat model choices and token meters from localStorage
loadLocalAiRequests();
loadLocalAiSubAgentRequests();
loadLocalAiRequestModelOverrides();
loadLocalAiRequestTokenTotals();
+82
View File
@@ -65,6 +65,7 @@ import {
loadLocalAiRequestModelOverridesForTesting,
loadLocalAiRequestTokenTotalsForTesting,
recordLocalAiRequestTokenUsageForTesting,
loadLocalAiSubAgentRequestsForTesting,
saveLocalAiRequests,
_resetCustomAiClientForTesting as _resetForStreamTests,
} from './CustomAIClient';
@@ -3565,6 +3566,87 @@ describe('CustomAIClient', () => {
});
});
describe('sub-agent requests survive a reload', () => {
const memoryStorage = {};
const fakeStorage = {
getItem: key => (key in memoryStorage ? memoryStorage[key] : null),
setItem: (key, value) => {
memoryStorage[key] = String(value);
},
removeItem: key => {
delete memoryStorage[key];
},
};
beforeEach(() => {
Object.keys(memoryStorage).forEach(key => delete memoryStorage[key]);
global.localStorage = fakeStorage;
_resetCustomAiClientForTesting();
});
afterEach(() => {
delete global.localStorage;
});
const makeChild = async (parentId, roleId) => {
setCustomEndpointConfig({
enabled: true,
baseUrl: 'http://localhost:11434/v1',
apiKey: '',
model: 'llama3.2',
temperature: 0.7,
});
// $FlowFixMe
axios.post.mockResolvedValueOnce({
status: 200,
data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] },
});
return customCreateSubAgentAiRequest({
parentAiRequestId: parentId,
roleId,
userRequest: 'do it',
gameProjectJson: null,
projectSpecificExtensionsSummaryJson: null,
spawnContextNote: null,
});
};
it('persists a child in its own store, not the parent chat list', async () => {
const child = await makeChild('local-ai-parent-1', 'developer');
const childStore = JSON.parse(
memoryStorage['gd-custom-ai-subagent-requests'] || '{}'
);
expect(Object.keys(childStore)).toContain(child.id);
const parentStore = JSON.parse(
memoryStorage['gd-custom-ai-requests'] || '{}'
);
expect(Object.keys(parentStore)).not.toContain(child.id);
});
it('reloads the child in a fresh session', async () => {
const child = await makeChild('local-ai-parent-2', 'tester');
_resetCustomAiClientForTesting();
// Gone from memory: the fallback carries no parent link.
expect(customGetAiRequest(child.id).parentAiRequestId).toBeUndefined();
loadLocalAiRequests();
loadLocalAiSubAgentRequestsForTesting();
const reloaded = customGetAiRequest(child.id);
expect(reloaded.parentAiRequestId).toBe('local-ai-parent-2');
expect(reloaded.studioRoleId).toBe('tester');
});
it('ignores a corrupt child store payload without throwing', () => {
memoryStorage['gd-custom-ai-subagent-requests'] = '{not json';
expect(() => loadLocalAiSubAgentRequestsForTesting()).not.toThrow();
// An entry without a parent link is not a child and is dropped.
memoryStorage['gd-custom-ai-subagent-requests'] = JSON.stringify({
'local-ai-orphan': { id: 'local-ai-orphan', status: 'ready' },
});
loadLocalAiSubAgentRequestsForTesting();
expect(
customGetAiRequest('local-ai-orphan').parentAiRequestId
).toBeUndefined();
});
});
describe('legacy persisted chats render after upgrading', () => {
const memoryStorage = {};
const fakeStorage = {
@@ -6,7 +6,11 @@ import Check from '../../UI/CustomSvgIcons/Check';
import ChevronArrowRight from '../../UI/CustomSvgIcons/ChevronArrowRight';
import ChevronArrowBottom from '../../UI/CustomSvgIcons/ChevronArrowBottom';
import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext';
import { type SubAgentActivityRow } from '../AiRequestUtils';
import {
type SubAgentActivityRow,
summarizeSubAgentTranscript,
} from '../AiRequestUtils';
import { customGetAiRequest } from '../../AI/CustomAIClient';
import classes from './SubAgentActivityPanel.module.css';
type Props = {|
@@ -100,8 +104,14 @@ export const SubAgentActivityPanel = ({ rows }: Props): React.Node => {
{isExpanded && (
<Column noMargin>
{rows.map(row => {
const hasReport = row.status === 'finished' && !!row.report;
const isReportExpanded = expandedCallId === row.callId;
const child = row.childId ? customGetAiRequest(row.childId) : null;
const steps = summarizeSubAgentTranscript(child);
const hasContent = !!row.report || steps.length > 0;
const isRowExpanded = expandedCallId === row.callId;
const toggle = () =>
setExpandedCallId(current =>
current === row.callId ? null : row.callId
);
return (
<Column noMargin key={row.callId}>
<Line noMargin>
@@ -117,26 +127,17 @@ export const SubAgentActivityPanel = ({ rows }: Props): React.Node => {
</div>
<div
className={
hasReport ? classes.rowClickable : classes.rowStatic
hasContent ? classes.rowClickable : classes.rowStatic
}
onClick={
hasReport
? () =>
setExpandedCallId(current =>
current === row.callId ? null : row.callId
)
: undefined
}
role={hasReport ? 'button' : undefined}
tabIndex={hasReport ? 0 : undefined}
onClick={hasContent ? toggle : undefined}
role={hasContent ? 'button' : undefined}
tabIndex={hasContent ? 0 : undefined}
onKeyDown={
hasReport
hasContent
? e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setExpandedCallId(current =>
current === row.callId ? null : row.callId
);
toggle();
}
}
: undefined
@@ -158,11 +159,31 @@ export const SubAgentActivityPanel = ({ rows }: Props): React.Node => {
</Text>
</div>
</Line>
{isReportExpanded && row.report && (
{isRowExpanded && (
<div className={classes.reportContainer}>
<Text noMargin size="body-small" color="secondary">
{truncateReport(row.report)}
</Text>
{row.report && (
<Text noMargin size="body-small" color="secondary">
{truncateReport(row.report)}
</Text>
)}
{steps.length > 0 && (
<div className={classes.transcriptContainer}>
{steps.map((step, index) => (
<Text
key={`${step.kind}-${index}`}
noMargin
size="body-small"
color="secondary"
>
{step.kind === 'tool'
? `⚙ ${step.text}`
: step.kind === 'user'
? `▸ ${step.text}`
: step.text}
</Text>
))}
</div>
)}
</div>
)}
</Column>
@@ -55,3 +55,11 @@
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.transcriptContainer {
margin-top: 4px;
padding-left: 8px;
border-left: 2px solid rgba(128, 128, 128, 0.35);
display: flex;
flex-direction: column;
}
@@ -474,6 +474,74 @@ export const listSubAgentActivity = (
return rows;
};
/** One step of a sub-agent's own transcript. */
export type SubAgentTranscriptStep = {|
kind: 'user' | 'text' | 'tool',
text: string,
|};
/** Cap one step's text so a verbose agent cannot flood the chat. */
const MAX_TRANSCRIPT_STEP_LENGTH = 600;
/**
* A compact, ordered view of a sub-agent's own conversation: its user request,
* its assistant text and the tools it called. This is the "multi-agent chat"
* half of the per-agent dashboard - what the agent actually did - without
* rendering the full, tool-heavy transcript.
*
* `maxSteps` bounds the list (the last step then marks the truncation). Derived
* purely from a request's output, so it is testable and safe on persisted,
* unvalidated data (null holes, non-array content).
*/
export const summarizeSubAgentTranscript = (
aiRequest: ?{ output?: Array<any> },
maxSteps?: number
): Array<SubAgentTranscriptStep> => {
const output = (aiRequest && aiRequest.output) || [];
if (!Array.isArray(output)) return [];
const limit = typeof maxSteps === 'number' && maxSteps > 0 ? maxSteps : 30;
const steps: Array<SubAgentTranscriptStep> = [];
const truncate = (text: string): string =>
text.length > MAX_TRANSCRIPT_STEP_LENGTH
? `${text.slice(0, MAX_TRANSCRIPT_STEP_LENGTH)}…`
: text;
for (const message of output) {
if (!message || typeof message !== 'object') continue;
if (message.type !== 'message' || !Array.isArray(message.content)) continue;
for (const content of message.content) {
if (!content || typeof content !== 'object') continue;
if (
message.role === 'user' &&
content.type === 'user_request' &&
typeof content.text === 'string' &&
content.text.trim()
) {
steps.push({ kind: 'user', text: truncate(content.text) });
} else if (
message.role === 'assistant' &&
typeof content.text === 'string' &&
content.text.trim()
) {
steps.push({ kind: 'text', text: truncate(content.text) });
} else if (
message.role === 'assistant' &&
content.type === 'function_call' &&
typeof content.name === 'string' &&
content.name
) {
steps.push({ kind: 'tool', text: content.name });
}
}
}
if (steps.length <= limit) return steps;
return [
...steps.slice(0, limit),
{ kind: 'text', text: `… ${steps.length - limit} more step(s)` },
];
};
export const isUserMessage = (message: any): boolean =>
!!message && message.type === 'message' && message.role === 'user';
@@ -30,6 +30,7 @@ import {
sumSubAgentTokens,
listSubAgentActivity,
extractFunctionCallReport,
summarizeSubAgentTranscript,
isUserMessage,
} from './AiRequestUtils';
import { type AiRequest } from '../Utils/GDevelopServices/Generation';
@@ -1217,6 +1218,100 @@ describe('listSubAgentActivity', () => {
});
});
describe('summarizeSubAgentTranscript', () => {
const userMessage = (text: string) => ({
type: 'message',
role: 'user',
content: [{ type: 'user_request', status: 'completed', text }],
});
const assistantMessage = (contents: Array<any>) => ({
type: 'message',
role: 'assistant',
content: contents,
});
const outputText = (text: string) => ({
type: 'output_text',
status: 'completed',
text,
});
const functionCall = (name: string) => ({
type: 'function_call',
status: 'completed',
call_id: `c-${name}`,
name,
arguments: '{}',
});
it('lists the user request, the assistant text and the tools, in order', () => {
const steps = summarizeSubAgentTranscript(
(({
output: [
userMessage('Build the grid.'),
assistantMessage([
outputText('Reading the project.'),
functionCall('read_game_project_json'),
]),
{
type: 'function_call_output',
call_id: 'c-read_game_project_json',
output: '{}',
},
assistantMessage([outputText('Done.')]),
],
}: any): any)
);
expect(steps).toEqual([
{ kind: 'user', text: 'Build the grid.' },
{ kind: 'text', text: 'Reading the project.' },
{ kind: 'tool', text: 'read_game_project_json' },
{ kind: 'text', text: 'Done.' },
]);
});
it('tolerates null holes and non-array content', () => {
const steps = summarizeSubAgentTranscript(
(({
output: [
null,
{ type: 'message', role: 'assistant', content: 'a string' },
{
type: 'message',
role: 'assistant',
content: [null, { type: 'output_text' }],
},
],
}: any): any)
);
expect(steps).toEqual([]);
expect(summarizeSubAgentTranscript(null)).toEqual([]);
expect(
summarizeSubAgentTranscript((({ output: 'nope' }: any): any))
).toEqual([]);
});
it('caps the list and marks the truncation', () => {
const output = [];
for (let i = 0; i < 10; i++) {
output.push(assistantMessage([outputText(`step ${i}`)]));
}
const steps = summarizeSubAgentTranscript((({ output }: any): any), 3);
expect(steps).toHaveLength(4);
expect(steps[2].text).toBe('step 2');
expect(steps[3].text).toContain('7 more step');
});
it('truncates a very long step', () => {
const steps = summarizeSubAgentTranscript(
(({
output: [assistantMessage([outputText('x'.repeat(900))])],
}: any): any)
);
expect(steps).toHaveLength(1);
expect(steps[0].text.length).toBeLessThanOrEqual(601);
expect(steps[0].text.endsWith('…')).toBe(true);
});
});
describe('extractFunctionCallReport', () => {
it('reads a studio message object', () => {
expect(extractFunctionCallReport({ message: 'hello' })).toBe('hello');
+10
View File
@@ -98,6 +98,16 @@ work; `endpoint` is the AI endpoint under test for that cycle.
looping until the guard tripped (cycles 239-241)..
## Proven workflows
- Sub-agent transcript view, live (cycle 252): expanding an agent row in the
dashboard now reveals that agent's own conversation - its user request, its
assistant text and the tools it called - via the pure, tested
`summarizeSubAgentTranscript`. A sub-agent's request was not accessible after a
reload, so children are now persisted in their own bounded store
(`gd-custom-ai-subagent-requests`, 40, separate from the 20-chat parent
budget). Verified through the mock: the designer child persisted with its
`read_game_project_json` call, and the row expanded to `▸ Task: …`,
`Reading the project…`, `⚙ read_game_project_json`, `GDD_Overview written…`;
screenshot /tmp/opencode/cycle252-transcript.png.
- Delegations linked to their plan task, live (cycle 251): a successful
`spawn_agent` now stamps `functionCall.taskId` and lifts its plan task to
`in_progress` with `agentCallId`, so the chat renders the spawn inside that