fix: surface raw app build errors to the session AI (#11415)

* fix: surface raw app build errors to the session AI

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: route app build state per preview and report pending builds

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: name open previews when app logs are ambiguous, re-wait on retry

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-29 19:06:34 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c1b59f70dd
commit 50ffad52d7
6 changed files with 275 additions and 21 deletions
@@ -1189,7 +1189,13 @@ const getRuntimeLogsSchema = z.object({
.min(1)
.max(100)
.optional()
.describe('How many of the most recent runtime log lines to return. Defaults to 10.')
.describe('How many of the most recent runtime log lines to return. Defaults to 10.'),
app_path: z
.string()
.optional()
.describe(
'Path of the raw app whose preview to read. Pass it when several raw app previews are open; defaults to the visible one.'
)
})
const listAppRunsSchema = z.object({
@@ -1466,6 +1472,7 @@ ${pipelineBullet}`
- Building a data pipeline: call open_preview(kind="pipeline", path="<folder>") as the FIRST step, before creating any node — this opens the pipeline editor the user reviews in. path is the folder, not an item; an empty ${when(canCreateFolder, 'or not-yet-created ')}folder is fine${when(canCreateFolder, ' (create_folder first if needed, then open it)')}. Opening it registers build_pipeline_node / edit_pipeline_node — use ONLY those to add or change pipeline nodes, never write_script for a pipeline node — they apply directly as unsaved drafts on the canvas (no separate accept/reject step) that the user reviews and deploys. Do not write pipeline scripts without first opening the editor.`
)}
- When debugging a running raw app, call get_app_runtime_logs to read the live preview's browser console output. It needs the raw app preview open (open_preview kind="raw_app").
- Writing an app file does not compile it: the open preview rebuilds it afterwards. After editing a raw app's frontend files with its preview open, call get_app_runtime_logs to check the build — when it failed, it returns the build errors (e.g. syntax or import errors) and bundler logs to fix.
- To inspect what actually rendered in a running raw app (verify an edit landed on screen, diagnose a blank/empty or wrong view, answer "what's showing"), use search_dom (regex over the live HTML) and read_dom (a line-numbered window). Pass a \`selector\` to scope to an element — prefer the selector from a DOM element chip the user attached — or omit it for the whole page. When a chip lists an \`app_path\`, pass it too so the RIGHT app is read (several previews can be open; a query without \`app_path\` hits the visible one). The DOM is read live and is never in context; no match means the element isn't rendered. Both need the raw app preview open.
- get_app_runtime_logs only shows the app's browser console. For the server-side logs of a backend runnable the app invoked (a backend.<id> call), call list_app_runs to get that run's job_id from the live preview, then get_run with it. Use this when a backend call errors or returns something unexpected.
${
@@ -4438,7 +4445,7 @@ export const globalTools: SessionTool<{}>[] = [
def: createToolDef(
getRuntimeLogsSchema,
'get_app_runtime_logs',
'Fetch the most recent browser console logs (and uncaught errors) from the raw app preview currently open in this AI session.'
'Fetch the most recent browser console logs (and uncaught errors) from the raw app preview currently open in this AI session. Also reports the build: right after an edit it waits (up to 20s) for the rebuild, and when the build failed it returns the build error and bundler logs first.'
),
planModeSafe: true,
showDetails: true,
@@ -4446,7 +4453,11 @@ export const globalTools: SessionTool<{}>[] = [
fn: async (ctx) => {
const parsed = getRuntimeLogsSchema.parse(ctx.args)
ctx.toolCallbacks.setToolStatus(ctx.toolId, { content: 'Reading app runtime logs...' })
const result = await getSessionRuntimeLogs(parsed.limit ?? 10, sessionIdFromCtx(ctx))
const result = await getSessionRuntimeLogs(
parsed.limit ?? 10,
sessionIdFromCtx(ctx),
parsed.app_path
)
ctx.toolCallbacks.setToolStatus(ctx.toolId, {
content: result.uiMessage,
result: result.toolResult
@@ -4810,6 +4821,7 @@ function closeSessionPreviewTabs(
export type GetRuntimeLogsHandler = (req: {
sessionId: string | undefined
limit: number
appPath?: string
}) => Promise<SessionToolResult>
let getRuntimeLogsHandler: GetRuntimeLogsHandler | undefined
@@ -4820,7 +4832,8 @@ export function setGetRuntimeLogsHandler(handler: GetRuntimeLogsHandler | undefi
function getSessionRuntimeLogs(
limit: number,
sessionId: string | undefined
sessionId: string | undefined,
appPath: string | undefined
): Promise<SessionToolResult> {
if (!getRuntimeLogsHandler) {
return Promise.resolve({
@@ -4830,7 +4843,7 @@ function getSessionRuntimeLogs(
toolResult: 'Runtime logs unavailable'
})
}
return getRuntimeLogsHandler({ sessionId, limit })
return getRuntimeLogsHandler({ sessionId, limit, appPath })
}
export type ListAppRunsHandler = (req: {
@@ -20,6 +20,7 @@
WMILL_TS_PATH,
genWmillTs,
normalizeRawAppRuntimeLogs,
createRawAppBuildTracker,
type Runnable,
type RawAppRuntimeLogEntry,
type RawAppRuntimeLogRequester,
@@ -406,6 +407,9 @@
// Latest UI Builder error; cleared on next successful build.
let buildError = $state<string | undefined>(undefined)
const buildTracker = createRawAppBuildTracker()
const PREVIEW_SETTLE_MS = 1000
let editorDestroyed = false
// Latest uncaught runtime error thrown by the rendered app; cleared on next build.
let runtimeError = $state<string | undefined>(undefined)
// Set when a build ran cleanly but never mounted anything into #root — the
@@ -909,6 +913,7 @@
if (!target) return
iframeFiles = { ...newFiles }
iframeFocusPending = undefined
buildTracker.start()
const files = Object.fromEntries(
Object.entries(newFiles).filter(([path, _]) => !path.endsWith('/'))
)
@@ -928,6 +933,7 @@
iframeFiles = { ...newFiles }
const focus = iframeFocusPending === pathToSelect
iframeFocusPending = undefined
buildTracker.start()
const files = Object.fromEntries(
Object.entries(newFiles).filter(([path, _]) => !path.endsWith('/'))
)
@@ -1356,6 +1362,9 @@
lastBuild = { css: e.data.css, js: e.data.js }
feedPreviewIframe(lastBuild)
syncExternalPreview()
// Give the fed app a moment to run, so a console read after the wait sees its output.
const gen = buildTracker.generation
setTimeout(() => buildTracker.settle(gen), PREVIEW_SETTLE_MS)
return
}
@@ -1376,6 +1385,8 @@
// `message: undefined` arrives on the next successful build and clears the banner.
if (fromUiBuilder && e.data.type === 'buildError') {
buildError = typeof e.data.message === 'string' ? e.data.message : undefined
// A successful build settles once the preview has it (see `preview` above).
if (buildError !== undefined) buildTracker.settle()
return
}
@@ -1713,7 +1724,20 @@
pending.resolve(entries)
}
const requestRuntimeLogs: RawAppRuntimeLogRequester = (limit) => {
const requestRuntimeLogs: RawAppRuntimeLogRequester = async (limit) => {
await buildTracker.wait()
if (editorDestroyed) {
return { entries: undefined, buildError: undefined, buildPending: false, buildLogs: '' }
}
return {
entries: await requestPreviewConsoleLogs(limit),
buildError,
buildPending: buildTracker.pending,
buildLogs: logs
}
}
function requestPreviewConsoleLogs(limit: number) {
const win = previewIframe?.contentWindow
if (!win || !previewIframeLoaded) return Promise.resolve(undefined)
const requestId = randomUUID()
@@ -2029,6 +2053,8 @@
onScreenshotRequester?.(undefined)
for (const requestId of Array.from(pendingRuntimeLogReqs.keys()))
resolvePendingRuntimeLogRequest(requestId, undefined)
editorDestroyed = true
buildTracker.release()
}
})
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import {
canonicalRawAppDiffValue,
createRawAppBuildTracker,
formatBuildFailureForChat,
formatRuntimeLogsForChat,
genWmillTs,
normalizeRawAppRuntimeLogs,
@@ -85,6 +87,58 @@ describe('normalizeRawAppRuntimeLogs', () => {
})
})
describe('formatBuildFailureForChat', () => {
it('reports the build error with the bundler log tail, without install noise', () => {
const logs = [
'Installing react …',
'Using cached resolution for react@19.0.0: 19.0.0',
'Resolved react@19.0.0',
'Using idb cache for react@19.0.0 …',
'[esbuild] Build started...',
'[esbuild] Build failed: Build failed with 1 error:',
'App.tsx:1:15: ERROR: Unexpected ";"'
].join('\n')
const report = formatBuildFailureForChat('App.tsx:1:15: ERROR: Unexpected ";"', logs)
expect(report).toContain('Build error:\nApp.tsx:1:15: ERROR: Unexpected ";"')
expect(report).toContain('[esbuild] Build failed')
expect(report).toContain('Installing react')
expect(report).not.toContain('Using cached resolution')
expect(report).not.toContain('Resolved react')
})
})
describe('createRawAppBuildTracker', () => {
it('keeps a timed-out build pending until it settles', async () => {
vi.useFakeTimers()
try {
const tracker = createRawAppBuildTracker(1000)
tracker.start()
const first = tracker.wait()
await vi.advanceTimersByTimeAsync(1000)
await first
expect(tracker.pending).toBe(true)
let secondDone = false
void tracker.wait().then(() => (secondDone = true))
tracker.settle()
await vi.advanceTimersByTimeAsync(0)
expect(secondDone).toBe(true)
expect(tracker.pending).toBe(false)
} finally {
vi.useRealTimers()
}
})
it('ignores a settle for a generation an edit has superseded', () => {
const tracker = createRawAppBuildTracker(1000)
const gen = tracker.start()
tracker.start()
tracker.settle(gen)
expect(tracker.pending).toBe(true)
})
})
// A deployed raw-app row as returned by getAppByPath: nested `value`, plus the
// server-managed columns and a recomputed inline-script lock.
function deployedRow() {
+93 -3
View File
@@ -83,9 +83,71 @@ export type RawAppRuntimeLogEntry = {
message: string
ts: number
}
export type RawAppRuntimeLogRequester = (
limit: number
) => Promise<RawAppRuntimeLogEntry[] | undefined>
export type RawAppPreviewLogs = {
/** Console output of the rendered app; undefined when the preview document isn't loaded. */
entries: RawAppRuntimeLogEntry[] | undefined
/** Set while the latest UI Builder build failed. A failed build never reaches the preview
* document, so its console cannot report it. */
buildError: string | undefined
/** Files were sent to the UI Builder and its build has not reported back in time. */
buildPending: boolean
buildLogs: string
}
export type RawAppRuntimeLogRequester = (limit: number) => Promise<RawAppPreviewLogs>
export const RAW_APP_BUILD_WAIT_MS = 20_000
/** Whether files sent to the UI Builder have produced a build result yet, so a read made
* right after an edit waits for that edit's build instead of reporting the previous one.
* The UI Builder carries no build id: when edits land while a build runs, the result of
* the older build can settle the wait. */
export function createRawAppBuildTracker(timeoutMs = RAW_APP_BUILD_WAIT_MS) {
// Stays set after a timed-out wait: the UI Builder never builds an app with no
// entrypoint, so a build may never report, and a later read must not call it done.
let pending = false
let generation = 0
let waiters: (() => void)[] = []
function release() {
const toRelease = waiters
waiters = []
toRelease.forEach((w) => w())
}
return {
get pending() {
return pending
},
/** Files were sent for a build; returns the generation to pass to `settle`. */
start(): number {
pending = true
return ++generation
},
/** A build reported. With `gen`, only settles if no edit was sent since. */
settle(gen?: number) {
if (gen !== undefined && gen !== generation) return
pending = false
release()
},
/** Ends every wait without settling, e.g. when the editor unmounts. */
release,
get generation() {
return generation
},
wait(): Promise<void> {
if (!pending) return Promise.resolve()
return new Promise((resolve) => {
const done = () => {
clearTimeout(timer)
resolve()
}
const timer = setTimeout(() => {
waiters = waiters.filter((w) => w !== done)
resolve()
}, timeoutMs)
waiters.push(done)
})
}
}
}
const RAW_APP_RUNTIME_LOG_LEVELS = new Set<RawAppRuntimeLogLevel>([
'log',
@@ -127,6 +189,34 @@ export function formatRuntimeLogsForChat(entries: RawAppRuntimeLogEntry[]): stri
return lines.join('\n')
}
const BUILD_LOG_TAIL_LINES = 40
const BUILD_LOG_TAIL_CHARS = 6000
// Per-package install progress: dozens of lines per build that would crowd the errors out of the tail.
const ROUTINE_INSTALL_LOG_LINE = /^\s*(Using cached resolution|Using idb cache|Resolved \S+@|Extract(ing|ed) )/
export function formatBuildLogTailForChat(buildLogs: string): string {
// The bundler log accumulates every install and build since the editor opened.
const lines = buildLogs
.split('\n')
.filter((line) => line.trim() && !ROUTINE_INSTALL_LOG_LINE.test(line))
.slice(-BUILD_LOG_TAIL_LINES)
let tail = lines.join('\n')
while (tail.length > BUILD_LOG_TAIL_CHARS && lines.length > 1) {
lines.shift()
tail = lines.join('\n')
}
tail = tail.slice(-BUILD_LOG_TAIL_CHARS)
return tail ? `Recent bundler logs:\n${tail}` : 'No bundler logs yet.'
}
export function formatBuildFailureForChat(buildError: string, buildLogs: string): string {
return [
'The app build FAILED, so the preview is not running the current code. Fix these errors in the frontend files, then call get_app_runtime_logs again to confirm the build passes.',
`Build error:\n${buildError}`,
formatBuildLogTailForChat(buildLogs)
].join('\n\n')
}
export type RawAppRunSummary = {
job_id: string
component: string
@@ -79,9 +79,17 @@
invalidateWorkspaceDrafts(workspaceId)
}
let runtimeLogRequester = $state<RawAppRuntimeLogRequester | undefined>(undefined)
function registerRuntimeLogRequester(requester: RawAppRuntimeLogRequester | undefined) {
runtime.setRuntimeLogRequester(requester)
runtimeLogRequester = requester
}
$effect(() => {
const p = path
const r = runtimeLogRequester
if (!r) return
runtime.registerRuntimeLogRequester(p, r)
return () => runtime.unregisterRuntimeLogRequester(p, r)
})
function registerRunsProvider(provider: RawAppRunsProvider | undefined) {
runtime.setAppRunsProvider(provider)
@@ -86,8 +86,11 @@ import {
} from '$lib/components/copilot/chat/global/core'
import {
formatRuntimeLogsForChat,
formatBuildFailureForChat,
formatBuildLogTailForChat,
RAW_APP_BUILD_WAIT_MS,
formatAppRunsForChat,
type RawAppRuntimeLogEntry,
type RawAppPreviewLogs,
type RawAppRuntimeLogRequester,
type RawAppRunSummary,
type RawAppRunsProvider,
@@ -209,8 +212,16 @@ export interface SessionRuntime {
force?: boolean,
deployedOnly?: boolean
): Promise<void>
setRuntimeLogRequester(requester: RawAppRuntimeLogRequester | undefined): void
requestRuntimeLogs(limit: number): Promise<RawAppRuntimeLogEntry[] | undefined>
/** Register a mounted raw-app preview's log requester, keyed by app path, like
* `registerDomRequester`: build state is per editor, so reads route to the app edited. */
registerRuntimeLogRequester(appPath: string, requester: RawAppRuntimeLogRequester): void
unregisterRuntimeLogRequester(appPath: string, requester: RawAppRuntimeLogRequester): void
requestRuntimeLogs(
limit: number,
appPath?: string
): Promise<
RawAppPreviewLogs | { closedAppPath: string } | { ambiguousAppPaths: string[] } | undefined
>
/** Register a mounted raw-app preview's DOM requester, keyed by app path.
* ALL mounted preview tabs register (hidden ones stay mounted), so a
* DOM-scoped turn can read its own app even when another tab is visible. */
@@ -571,7 +582,7 @@ function createRuntime(session: Session): SessionRuntime {
// the pane unmounts on hide, and a component-local store would be discarded.
const pipelineEditorState = new PipelineEditorState()
let runtimeLogRequester: RawAppRuntimeLogRequester | undefined = undefined
const runtimeLogRequesters = new Map<string, RawAppRuntimeLogRequester>()
// appPath → requester, one entry per mounted raw-app preview tab.
const domRequesters = new Map<string, RawAppDomRequester>()
let activeDomAppPath: string | undefined = undefined
@@ -925,11 +936,24 @@ function createRuntime(session: Session): SessionRuntime {
armRestartOnFirstInteraction(workspace, kind, path)
},
setRuntimeLogRequester(requester) {
runtimeLogRequester = requester
registerRuntimeLogRequester(appPath, requester) {
runtimeLogRequesters.set(appPath, requester)
},
async requestRuntimeLogs(limit) {
return runtimeLogRequester ? runtimeLogRequester(limit) : undefined
unregisterRuntimeLogRequester(appPath, requester) {
if (runtimeLogRequesters.get(appPath) === requester) runtimeLogRequesters.delete(appPath)
},
async requestRuntimeLogs(limit, appPath) {
const path =
appPath ??
activeDomAppPath ??
(runtimeLogRequesters.size === 1 ? [...runtimeLogRequesters.keys()][0] : undefined)
if (path === undefined) {
return runtimeLogRequesters.size > 1
? { ambiguousAppPaths: [...runtimeLogRequesters.keys()] }
: undefined
}
const requester = runtimeLogRequesters.get(path)
return requester ? requester(limit) : { closedAppPath: path }
},
registerDomRequester(appPath, requester) {
domRequesters.set(appPath, requester)
@@ -1286,7 +1310,7 @@ setDeployedInSessionHandler(({ sessionId: callerSessionId, kind, path }) => {
runtime.syncPreviewWithDeployed(session.workspace_id, kind, path)
})
setGetRuntimeLogsHandler(async ({ sessionId: callerSessionId, limit }) => {
setGetRuntimeLogsHandler(async ({ sessionId: callerSessionId, limit, appPath }) => {
const sessionId = callerSessionId ?? sessionState.currentSessionId
const runtime = sessionId ? runtimes.get(sessionId) : undefined
if (!runtime) {
@@ -1297,7 +1321,46 @@ setGetRuntimeLogsHandler(async ({ sessionId: callerSessionId, limit }) => {
toolResult: 'Runtime logs unavailable'
}
}
const entries = await runtime.requestRuntimeLogs(limit)
const previewLogs = await runtime.requestRuntimeLogs(limit, appPath)
if (previewLogs && 'closedAppPath' in previewLogs) {
return {
aiResult: `The preview for "${previewLogs.closedAppPath}" is not open, so its logs can't be read. Call open_preview with kind="raw_app" and that path, then call get_app_runtime_logs again.`,
uiMessage: 'Runtime logs unavailable',
toolResult: 'Runtime logs unavailable'
}
}
if (previewLogs && 'ambiguousAppPaths' in previewLogs) {
return {
aiResult: `Several raw app previews are open (${previewLogs.ambiguousAppPaths.join(', ')}) and none is visible. Call get_app_runtime_logs again with app_path set to the app you want.`,
uiMessage: 'Runtime logs unavailable',
toolResult: 'Runtime logs unavailable'
}
}
// Checked before `buildError`: while a build is pending, that error belongs to the previous build.
if (previewLogs?.buildPending) {
const previous = previewLogs.buildError
? `\n\nThe PREVIOUS build failed with:\n${previewLogs.buildError}`
: ''
return {
aiResult:
`The app is still building (or its build never started, e.g. no index entrypoint file) — no build result arrived within ${RAW_APP_BUILD_WAIT_MS / 1000} seconds, so the preview may still run an older build. Check the bundler logs below, wait, then call get_app_runtime_logs again.${previous}\n\n` +
formatBuildLogTailForChat(previewLogs.buildLogs),
uiMessage: 'App build still running',
toolResult: 'App build still running'
}
}
if (previewLogs?.buildError !== undefined) {
const report = formatBuildFailureForChat(previewLogs.buildError, previewLogs.buildLogs)
const consoleLogs = previewLogs.entries?.length
? `\n\nConsole output of the last successful build still shown in the preview (stale until the build is fixed):\n${formatRuntimeLogsForChat(previewLogs.entries.slice(-limit))}`
: ''
return {
aiResult: `${report}${consoleLogs}`,
uiMessage: 'App build failed',
toolResult: report
}
}
const entries = previewLogs?.entries
if (entries === undefined) {
return {
aiResult: