Files
GDevelop-Unlocked/patches/12-ai-request-utils.patch
BillyOutlast fc9790474f Forward-port Tier-2 session glue: patches 12/13/14 (release-build break fix)
Webpack compile caught what markers cannot: the rebased AskAi container
imports session/studio helpers from three more fork-diverged files.
- 12 AiRequestUtils.js (verbatim, no upstream drift): 10 session/studio
  helpers incl. canStartAiRequestCreate (the exact webpack failure).
- 13 AiRequestContext.js (1 conflict, keep-both): BYOK session handling,
  local-cache merge, sub-agent finalize wiring.
- 14 AiGeneration/Utils.js (2 conflicts, keep-both): useProcessFunctionCalls
  studio args + enqueueRequestWrite return; upstream attachment-save prop kept.
- Permanent gate: patched-import-closure test resolves every relative
  import across the patched surface (negative-controlled vs patch 12).
- Driver markers, apply-test file list, tier-2 GLUE extended; docs updated.
2026-10-10 22:43:03 -04:00

746 lines
29 KiB
Diff
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--- a/newIDE/app/src/AiGeneration/AiRequestUtils.js
+++ b/newIDE/app/src/AiGeneration/AiRequestUtils.js
@@ -12,11 +12,17 @@
import { type RelatedAiRequestLastMessages } from '../EditorFunctions';
/** The text typed by the user in one of their messages. */
-export const getUserRequestText = (message: AiRequestUserMessage): string =>
- message.content
- .filter(content => content.type === 'user_request')
- .map(content => content.text)
+export const getUserRequestText = (message: AiRequestUserMessage): string => {
+ // `content` comes from the server unvalidated (a hosted summary carries the
+ // raw first message). A payload missing it, or holding a non-array, would
+ // throw here and take the chat list down with it, so tolerate both.
+ const content = message && message.content;
+ if (!Array.isArray(content)) return '';
+ return content
+ .filter(item => item && item.type === 'user_request' && item.text)
+ .map(item => item.text)
.join(' ');
+};
/**
* The name of a chat: the title the user gave to it, or its first message
@@ -49,6 +55,559 @@
(aiRequest.retriesInARowCount || 0) >= MAX_AI_REQUEST_RETRIES_IN_A_ROW
);
+/**
+ * Whether this session may offer the error row's Retry for one request id.
+ * Mirrors canSendAiRequestForSession: any profile, or a local-ai-* id that
+ * lives only in the local cache (no hosted API / auth needed). Non-local ids
+ * without a profile are refused — hosted /action/retry requires a userId.
+ */
+export const canRetryAiRequestForSession = (
+ profile: ?{ id: string },
+ aiRequestId: string
+): boolean => !!profile || aiRequestId.startsWith('local-ai-');
+
+/**
+ * Whether this session may send message feedback for one request id.
+ * Mirrors canRetryAiRequestForSession: any profile, or a local-ai-* id that
+ * lives only in the local cache (the Generation layer short-circuits those
+ * before any hosted call). Hosted non-local ids without a profile are refused.
+ */
+export const canSendFeedbackForSession = (
+ profile: ?{ id: string },
+ aiRequestId: string
+): boolean => !!profile || aiRequestId.startsWith('local-ai-');
+
+/**
+ * Whether a createAiRequest return value is a first-turn failure or cancel
+ * that must not be treated as a successful start (local BYOK returns
+ * status:'error' on failure and status:'suspended' when the user aborts a hung
+ * create, instead of throwing).
+ */
+export const isFailedAiRequestStart = (aiRequest: {
+ status: string,
+ ...
+}): boolean => aiRequest.status === 'error' || aiRequest.status === 'suspended';
+
+/**
+ * Standalone create outcome: failed or cancelled first-turn starts stay on the
+ * error row (project left open so an offline failure or Stop does not discard
+ * the user's work); only successful starts hand off to the Ask AI tab / close
+ * the project.
+ */
+export const getStandaloneCreateOutcome = (aiRequest: {
+ status: string,
+ ...
+}): 'error-row' | 'handoff' =>
+ isFailedAiRequestStart(aiRequest) ? 'error-row' : 'handoff';
+
+/**
+ * Whether a send/continue of an AI request may proceed for this session:
+ * any logged-in profile, or a local/BYOK session with a custom endpoint
+ * enabled (the hosted API is never involved for those). Hosted non-local
+ * requests without a profile are refused.
+ *
+ * Shared by the editor container and the stand-alone form so the two cannot
+ * drift — the stand-alone form used to require a profile for function-call
+ * outputs even when a custom endpoint was enabled, which silently broke the
+ * agent loop for offline BYOK sessions.
+ *
+ * A local-ai-* id is also allowed without a profile or custom endpoint: those
+ * chats live only in the local cache (cycle 64 lists them after logout /
+ * toggle-off, and Generation addMessage short-circuits them before any
+ * hosted call).
+ */
+export const canSendAiRequestForSession = (
+ profile: ?{ id: string },
+ customEndpointEnabled: boolean,
+ aiRequestId: ?string
+): boolean =>
+ !!profile ||
+ customEndpointEnabled ||
+ (!!aiRequestId && aiRequestId.startsWith('local-ai-'));
+
+/**
+ * Whether the AI editor tools (event generation, asset search, resource
+ * search) may run for this session.
+ *
+ * Always allowed: a profile uses the hosted APIs when the custom endpoint is
+ * off (or the local client when it is on). Without a profile the hooks set
+ * activeUserId to LOCAL_BYOK_USER_ID ('local-byok-user'), which Generation
+ * and prepareAiUserContent dual-gate onto the offline client before any
+ * authorized call — so a missing profile must not throw.
+ *
+ * The old `!profile && !customEndpointEnabled` throw blocked agent
+ * function-calls for local-ai-* chats after logout / endpoint toggle-off
+ * (cycles 64–66 list, send, and watch those chats offline).
+ */
+export const canUseEditorAiTools = (
+ profile: ?{ id: string },
+ customEndpointEnabled: boolean
+): boolean => {
+ if (profile) return true;
+ // Logged-out: LOCAL_BYOK_USER_ID starts with 'local-', so every Generation
+ // dual-gate accepts it regardless of the endpoint toggle. Accept the flag
+ // for call-site symmetry with canSendAiRequestForSession but do not require it.
+ return customEndpointEnabled || !profile;
+};
+
+/**
+ * Whether a new AI request may be created for this session (the stand-alone
+ * form and the editor container's create effect).
+ *
+ * Always allowed: a profile uses the hosted API when the custom endpoint is
+ * off (or the local client when it is on). Without a profile the forms set
+ * activeUserId to LOCAL_BYOK_USER_ID ('local-byok-user'), which Generation
+ * and prepareAiUserContent dual-gate onto the offline client — so create
+ * must not open the account dialog first.
+ *
+ * The old `!profile && !customEndpointEnabled` account-dialog gate blocked
+ * new local chats after logout / endpoint toggle-off even though cycles 64–66
+ * already list, send, and watch those sessions offline.
+ */
+export const canStartAiRequestCreate = (
+ profile: ?{ id: string },
+ customEndpointEnabled: boolean
+): boolean => {
+ if (profile) return true;
+ // Logged-out create uses LOCAL_BYOK_USER_ID → Generation local path.
+ return customEndpointEnabled || !profile;
+};
+
+/**
+ * Whether the editor container's mount-time "tab open" full fetch should run
+ * for the currently selected chat. Mirrors AiRequestContext.loadAiRequest:
+ * a profile always can; without one, local-ai-* ids (cache-only) and any id
+ * while a custom endpoint is enabled may fetch. Hosted non-local ids without
+ * a profile and without a custom endpoint are refused (the hosted API would
+ * 401).
+ */
+export const shouldFetchAiRequestOnTabOpen = (options: {|
+ selectedAiRequest: ?{ id: string },
+ profile: ?{ id: string },
+ customEndpointEnabled: boolean,
+|}): boolean => {
+ const { selectedAiRequest, profile, customEndpointEnabled } = options;
+ if (!selectedAiRequest) return false;
+ if (profile) return true;
+ if (selectedAiRequest.id.startsWith('local-ai-')) return true;
+ return customEndpointEnabled;
+};
+
+/**
+ * Whether the suggestions side-fetch should run for this session and request
+ * state: an agent/orchestrator request that is ready with a project loaded,
+ * not mid-send, and allowed for this session (profile or custom endpoint).
+ *
+ * Offline BYOK without a profile used to be refused by a bare `!profile`
+ * guard in Utils, so local chats never received next-step suggestions even
+ * though the local client can generate them without the hosted API.
+ */
+export const shouldFetchAiRequestSuggestions = (options: {|
+ selectedAiRequest: ?AiRequest,
+ isSending: boolean,
+ isFetchingSuggestions: boolean,
+ profile: ?{ id: string },
+ customEndpointEnabled: boolean,
+ hasProject: boolean,
+|}): boolean => {
+ const {
+ selectedAiRequest,
+ isSending,
+ isFetchingSuggestions,
+ profile,
+ customEndpointEnabled,
+ hasProject,
+ } = options;
+ if (!selectedAiRequest) return false;
+ if (
+ selectedAiRequest.mode !== 'agent' &&
+ selectedAiRequest.mode !== 'orchestrator'
+ ) {
+ return false;
+ }
+ if (isSending) return false;
+ if (!selectedAiRequest.output || selectedAiRequest.output.length === 0) {
+ return false;
+ }
+ if (selectedAiRequest.status !== 'ready') return false;
+ if (
+ !canSendAiRequestForSession(
+ profile,
+ customEndpointEnabled,
+ selectedAiRequest.id
+ )
+ ) {
+ return false;
+ }
+ if (isFetchingSuggestions) return false;
+ if (!hasProject) return false;
+ return true;
+};
+
+/**
+ * The share of the model's context window the opened chat has used, 0 to 1
+ * (it can exceed 1), or null when it is not known.
+ *
+ * The hosted path reports this on `aiRequest.contextStats`. The local BYOK
+ * path never sets that field — it accounts token counts itself — so without
+ * this the usage indicator stayed empty in exactly the mode where small local
+ * context windows make the number most useful.
+ *
+ * `totalTokens` is the chat's cumulative token count and `budgetTokens` the
+ * per-request input budget; both come from the caller so this stays pure.
+ */
+export const getLocalAiRequestContextUsedRatio = (
+ totalTokens: number,
+ budgetTokens: number
+): ?number => {
+ const usable = (value: number): boolean =>
+ typeof value === 'number' && Number.isFinite(value) && value > 0;
+ if (!usable(totalTokens) || !usable(budgetTokens)) return null;
+ return totalTokens / budgetTokens;
+};
+/**
+ * Whether a persisted output entry is a user chat message. Output entries are
+ * not shape-validated on load, so every `.filter(message => ...)` over them
+ * must tolerate a null hole — and this predicate lives here (not inline in
+ * the dispatcher) so a spec can pin it: Utils.js pulls in three.js through
+ * the editor-function map and cannot be loaded by a test.
+ */
+/**
+ * How many sub-agents a request spawned and how many are finished (their call
+ * has a `function_call_output`). The chat shows this as a small audit line so
+ * the multi-agent activity is visible without opening each sub-agent. Derived
+ * purely from the request output, so it is also usable as the data layer for a
+ * per-agent dashboard.
+ */
+export const summarizeSubAgentActivity = (aiRequest: {
+ output?: Array<any>,
+}): { total: number, done: number, running: number, roles: Object } => {
+ // Single source of truth: the per-agent rows carry the role and the
+ // finished/live status; the summary is just their aggregate.
+ const rows = listSubAgentActivity((aiRequest: any), () => 0);
+ // Null-prototype so a model-authored role string like '__proto__' or
+ // 'constructor' becomes a normal key instead of touching Object.prototype.
+ const roles = Object.create(null);
+ let done = 0;
+ rows.forEach(row => {
+ if (row.role) roles[row.role] = (roles[row.role] || 0) + 1;
+ if (row.status === 'finished') done++;
+ });
+ const total = rows.length;
+ return { total, done, running: total - done, roles };
+};
+
+/**
+ * The summed token usage of a request's sub-agents, read through a getter
+ * (the token registry). Kept pure/testable: ChatMessages renders it, and the
+ * registry read is injected rather than imported, so this module stays
+ * dependency-free for its spec.
+ */
+export const sumSubAgentTokens = (
+ aiRequest: { output?: Array<any> },
+ getTokenTotal: string => number
+): number => {
+ const seen = new Set<string>();
+ return listSubAgentActivity((aiRequest: any), getTokenTotal).reduce(
+ (total, row) => {
+ // Token accounting is per sub-agent: a call repeated under one child id
+ // must not be counted twice.
+ if (!row.childId || seen.has(row.childId)) return total;
+ seen.add(row.childId);
+ return total + row.tokens;
+ },
+ 0
+ );
+};
+
+/** One row of the per-agent audit dashboard. */
+export type SubAgentActivityRow = {|
+ callId: string,
+ childId: string | null,
+ role: string | null,
+ shortTitle: string,
+ relatedTaskId: string | null,
+ status: 'working' | 'finished',
+ tokens: number,
+ /** The sub-agent's report, when it has finished and the output is readable. */
+ report: string | null,
+|};
+
+/**
+ * The human-readable report carried by a finished sub-agent's
+ * `function_call_output`.
+ *
+ * The output is network-/model-adjacent data and is only type-asserted, and the
+ * report is written by two different paths: `useStudioRuntime` posts
+ * `{ message: 'Report from the …' }`, while a hosted server may post a JSON
+ * string. Read both, and fall back to the raw string so a report is never
+ * silently dropped. `null` when there is nothing readable.
+ */
+export const extractFunctionCallReport = (output: any): string | null => {
+ if (typeof output === 'string') {
+ const trimmed = output.trim();
+ if (!trimmed) return null;
+ try {
+ const parsed = JSON.parse(trimmed);
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ return extractFunctionCallReport(parsed);
+ }
+ // Parsed to a scalar/array: a machine payload, not a readable report.
+ return null;
+ } catch (ignored) {
+ // Not JSON: the string itself is the report.
+ return trimmed;
+ }
+ }
+ if (output && typeof output === 'object' && !Array.isArray(output)) {
+ if (typeof output.message === 'string' && output.message.trim())
+ return output.message;
+ if (typeof output.report === 'string' && output.report.trim())
+ return output.report;
+ }
+ return null;
+};
+
+/**
+ * Per-agent rows for the audit dashboard: one row per spawned sub-agent, in
+ * launch order, with its role, task link, live/finished status, its own token
+ * meter and its report once finished.
+ *
+ * `summarizeSubAgentActivity` answers "how many"; this answers "which ones" and
+ * is the data layer the dashboard (and any future multi-agent panel) reads.
+ * Derived purely from the parent's output plus an injected token getter, so it
+ * is testable without the React context or the token registry.
+ *
+ * A spawn call repeated under the same `call_id` (fork/merge) is counted once,
+ * matching `summarizeSubAgentActivity`.
+ */
+export const listSubAgentActivity = (
+ aiRequest: { output?: Array<any> },
+ getTokenTotal: string => number
+): Array<SubAgentActivityRow> => {
+ const output = (aiRequest && aiRequest.output) || [];
+ const outputByCallId = new Map<string, any>();
+ const firstCallByCallId = new Map<string, any>();
+
+ for (const message of output) {
+ if (!message || typeof message !== 'object') continue;
+ if (
+ message.type === 'function_call_output' &&
+ typeof message.call_id === 'string'
+ ) {
+ // First output wins, matching the call de-duplication below.
+ if (!outputByCallId.has(message.call_id)) {
+ outputByCallId.set(message.call_id, message);
+ }
+ continue;
+ }
+ if (
+ message.type === 'message' &&
+ message.role === 'assistant' &&
+ Array.isArray(message.content)
+ ) {
+ for (const entry of message.content) {
+ if (
+ !entry ||
+ typeof entry !== 'object' ||
+ entry.type !== 'function_call' ||
+ typeof entry.subAgentAiRequestId !== 'string' ||
+ typeof entry.call_id !== 'string'
+ ) {
+ continue;
+ }
+ if (!firstCallByCallId.has(entry.call_id)) {
+ firstCallByCallId.set(entry.call_id, entry);
+ }
+ }
+ }
+ }
+
+ const rows: Array<SubAgentActivityRow> = [];
+ firstCallByCallId.forEach((call, callId) => {
+ let role = null;
+ let shortTitle = '';
+ let relatedTaskId = null;
+ try {
+ const parsed =
+ typeof call.arguments === 'string'
+ ? JSON.parse(call.arguments || 'null')
+ : call.arguments;
+ if (parsed && typeof parsed === 'object') {
+ if (typeof parsed.role === 'string' && parsed.role) role = parsed.role;
+ if (typeof parsed.short_title === 'string')
+ shortTitle = parsed.short_title;
+ if (
+ typeof parsed.related_task_id === 'string' &&
+ parsed.related_task_id
+ )
+ relatedTaskId = parsed.related_task_id;
+ }
+ } catch (ignored) {
+ // Malformed arguments: the row still shows the child and its status.
+ }
+ const childId =
+ typeof call.subAgentAiRequestId === 'string'
+ ? call.subAgentAiRequestId
+ : null;
+ let tokens = 0;
+ if (childId) {
+ const candidate = getTokenTotal(childId);
+ if (typeof candidate === 'number' && Number.isFinite(candidate))
+ tokens = candidate;
+ }
+ const outputMessage = outputByCallId.get(callId);
+ rows.push({
+ callId,
+ childId,
+ role,
+ shortTitle,
+ relatedTaskId,
+ status: outputMessage ? 'finished' : 'working',
+ tokens,
+ report: outputMessage
+ ? extractFunctionCallReport(outputMessage.output)
+ : null,
+ });
+ });
+
+ 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 &&
+ typeof message === 'object' &&
+ message.type === 'message' &&
+ message.role === 'user';
+
+/** How recently an unselected request must have changed to keep being processed. */
+export const UNSELECTED_ACTIVE_WINDOW_MS = 3 * 60 * 1000;
+
+/** Cap on extra requests processed per render, so a busy cache cannot stall the loop. */
+export const MAX_UNSELECTED_ACTIVE_REQUESTS = 4;
+
+/**
+ * Local, top-level requests that must keep being processed even though they are
+ * not the selected chat.
+ *
+ * The dispatcher only processes `[selectedRequest, ...activeSubAgents]`, so a
+ * request that a user switches away from - or that a mid-run `initialize_project`
+ * deselects - has its pending function calls stall forever (see feedback-loop
+ * Run 4). This returns the ones that are unambiguously still live: changed
+ * within `freshnessWindowMs` (so an old abandoned chat is never resumed), local
+ * (never the hosted backend), not suspended/errored, and with a call still to
+ * process. Pure and injectable for a spec.
+ */
+export const getUnselectedActiveRequests = ({
+ aiRequests,
+ excludeIds,
+ getEditorFunctionCallResults,
+ now,
+ freshnessWindowMs,
+ maxCount,
+}: {|
+ aiRequests: { [string]: AiRequest },
+ excludeIds: Set<string>,
+ getEditorFunctionCallResults: (
+ aiRequestId: string
+ ) => Array<EditorFunctionCallResult> | null,
+ now: number,
+ freshnessWindowMs: number,
+ maxCount: number,
+|}): Array<AiRequest> => {
+ const result: Array<AiRequest> = [];
+ const requests = aiRequests || {};
+ for (const id of Object.keys(requests)) {
+ if (result.length >= maxCount) break;
+ if (excludeIds.has(id)) continue;
+ const request = requests[id];
+ if (!request || request.parentAiRequestId) continue;
+ if (typeof id !== 'string' || !id.startsWith('local-ai-')) continue;
+ if (request.status === 'suspended' || request.status === 'error') continue;
+ const updatedAt = Date.parse(request.updatedAt || '');
+ if (!Number.isFinite(updatedAt) || now - updatedAt > freshnessWindowMs) {
+ continue;
+ }
+ const hasPendingCall =
+ getFunctionCallsToProcess({
+ aiRequest: request,
+ editorFunctionCallResults: getEditorFunctionCallResults(id),
+ }).length > 0 ||
+ getPendingSubAgentFunctionCalls({ aiRequest: request }).length > 0;
+ if (!hasPendingCall) continue;
+ result.push(request);
+ }
+ return result;
+};
+
export const getFunctionCallToFunctionCallOutputMap = ({
aiRequest,
}: {|
@@ -73,10 +632,19 @@
const output = aiRequest.output || [];
for (let i = 0; i < output.length; i++) {
const message = output[i];
+ // Persisted output is only checked for id/status on load and
+ // normalizePersistedMessages passes non-message entries through, so a
+ // null hole reaches this loop and its first read threw.
+ if (!message || typeof message !== 'object') continue;
if (message.type === 'message' && message.role === 'assistant') {
- // Process function calls in this message
+ // Process function calls in this message. `content` is network-supplied
+ // and only type-asserted, and this runs on every chat render (the map
+ // feeds ChatMessages), so a payload without an array would crash the
+ // chat view itself rather than one field of it.
+ if (!Array.isArray(message.content)) continue;
message.content.forEach(content => {
+ if (!content || typeof content !== 'object') return;
if (content.type === 'function_call') {
// Initialize with null output - will be updated if we find a matching output
functionCallsToOutputs.set(content, null);
@@ -122,6 +690,7 @@
const output = aiRequest.output || [];
for (let i = output.length - 1; i >= 0; i--) {
const message = output[i];
+ if (!message || typeof message !== 'object') continue;
// Track already processed function call outputs
if (message.type === 'function_call_output') {
@@ -130,8 +699,14 @@
// Collect function calls that need processing
if (message.type === 'message' && message.role === 'assistant') {
+ // Network-supplied and only type-asserted: a missing array would crash
+ // the caller that builds the function-call list for the chat.
+ if (!Array.isArray(message.content)) continue;
const functionCalls = message.content.filter(
- content => content.type === 'function_call'
+ content =>
+ content &&
+ typeof content === 'object' &&
+ content.type === 'function_call'
);
if (functionCalls.length > 0) {
@@ -176,8 +751,11 @@
const output = aiRequest.output || [];
for (let i = 0; i < output.length; i++) {
const message = output[i];
+ if (!message || typeof message !== 'object') continue;
if (message.type === 'message' && message.role === 'assistant') {
+ if (!Array.isArray(message.content)) continue;
for (const content of message.content) {
+ if (!content || typeof content !== 'object') continue;
if (content.type === 'function_call' && content.subAgentAiRequestId) {
subAgentCalls.push(content);
}
@@ -230,6 +808,7 @@
const output = aiRequest.output || [];
for (let i = 0; i < output.length; i++) {
const message = output[i];
+ if (!message || typeof message !== 'object') continue;
if (message.type === 'function_call_output') {
processedCallIds.add(message.call_id);
}
@@ -250,8 +829,11 @@
const output = aiRequest.output || [];
for (let i = 0; i < output.length; i++) {
const message = output[i];
+ if (!message || typeof message !== 'object') continue;
if (message.type === 'message' && message.role === 'assistant') {
+ if (!Array.isArray(message.content)) continue;
for (const content of message.content) {
+ if (!content || typeof content !== 'object') continue;
if (content.type === 'function_call' && content.call_id === callId) {
return content.name;
}
@@ -272,11 +854,27 @@
const outputMessages = aiRequest.output || [];
for (let i = outputMessages.length - 1; i >= 0; i--) {
const message = outputMessages[i];
+ if (!message || typeof message !== 'object') continue;
if (message.type === 'function_call_output' && message.output) {
try {
const output = JSON.parse(message.output);
- if (output && output.plan && output.plan.tasks) {
- latestPlan = output.plan;
+ // `tasks` must be an array, not merely truthy: a non-array (an object,
+ // a string) satisfies the truthiness test but throws on `.some` below,
+ // and the try/catch above only covers JSON.parse. A request restored
+ // from localStorage is not shape-revalidated on load, and the plan
+ // output is model-authored, so neither the array nor the shape is
+ // guaranteed by the time this runs.
+ if (output && output.plan && Array.isArray(output.plan.tasks)) {
+ // Sanitize here, at the single choke point every consumer reads:
+ // drop non-object task entries so `tasks.some(task => task.status)`
+ // below (and OrchestratorPlan's filter, and the studio's scans)
+ // cannot throw on a hole in the model-authored plan JSON.
+ latestPlan = {
+ ...output.plan,
+ tasks: output.plan.tasks.filter(
+ task => task && typeof task === 'object'
+ ),
+ };
break;
}
} catch (e) {
@@ -389,13 +987,29 @@
let hasUnfinishedResult = false;
const functionCallOutputs = editorFunctionCallResults
.map(functionCallOutput => {
+ // Skip malformed entries rather than throwing out of the turn: the
+ // results array is app-built, but the sibling reads all tolerate a hole.
+ if (!functionCallOutput || typeof functionCallOutput !== 'object') {
+ 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.
+ const rawOutput = functionCallOutput.output;
+ const outputFields =
+ rawOutput &&
+ typeof rawOutput === 'object' &&
+ !Array.isArray(rawOutput)
+ ? (rawOutput: Object)
+ : { output: rawOutput };
return {
type: 'function_call_output',
call_id: functionCallOutput.call_id,
output: JSON.stringify({
success: functionCallOutput.success,
- ...functionCallOutput.output,
+ ...outputFields,
}),
};
}
@@ -427,15 +1041,21 @@
for (let i = output.length - 1; i >= 0; i--) {
const message = output[i];
+ if (!message || typeof message !== 'object') continue;
if (message.type === 'message' && message.role === 'user') {
- const textContent = message.content.find(c => c.type === 'user_request');
+ if (!Array.isArray(message.content)) break;
+ const textContent = message.content.find(
+ c => c && typeof c === 'object' && c.type === 'user_request'
+ );
if (textContent) {
lastUserMessage = textContent.text;
}
break;
}
if (message.type === 'message' && message.role === 'assistant') {
+ if (!Array.isArray(message.content)) continue;
for (const content of message.content) {
+ if (!content || typeof content !== 'object') continue;
if (
content.type === 'output_text' &&
lastAssistantMessages.length < 5