feat(debug): wire ScriptEditor to the agent controller and human preempt

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alexandre Herve
2026-07-12 18:44:53 +02:00
co-authored by Claude Opus 4.8
parent e5f5611676
commit b513998d0e
3 changed files with 120 additions and 48 deletions
+98 -38
View File
@@ -73,8 +73,11 @@
fetchContextualVariables,
signDebugRequest,
signMultiplayerRequest,
getDebugErrorMessage
getDebugErrorMessage,
preemptToHuman,
type DebugController
} from '$lib/components/debug'
import type { DAPClient } from '$lib/components/debug'
import { SvelteSet } from 'svelte/reactivity'
import { setLicense } from '$lib/enterpriseUtils'
import type { ScriptEditorWhitelabelCustomUi } from './custom_ui'
@@ -1205,51 +1208,96 @@
monacoEditor.revealLineInCenter(line)
}
// `onClientReady` fires after connect+initialize but before launch, so the agent can
// attach its transition listener in time to catch the first breakpoint stop. Rethrows
// so the agent path can report a signing/connection failure to the model.
async function runDebugSession(opts?: {
agentArgs?: Record<string, unknown>
agentOriginated?: boolean
onClientReady?: (client: DAPClient) => void
}): Promise<void> {
showDebugConsole = true
selectedDebugFrameId = null
if (!debugMode) {
debugMode = true
if (testPanelSize === 0) {
expandTestPanel()
}
}
resetDAPClient()
dapClient = getDAPClient(dapServerUrl)
const env = await fetchContextualVariables(opWs ?? '')
const signedPayload = await signDebugRequest(
opWs ?? '',
code ?? '',
lang ?? 'python3',
opts?.agentOriginated
)
debugSessionJobId = signedPayload.job_id
await dapClient.connect()
await dapClient.initialize()
opts?.onClientReady?.(dapClient)
await dapClient.setBreakpoints(debugFilePath, Array.from(debugBreakpoints))
await dapClient.configurationDone()
await dapClient.launch({
code,
cwd: '/tmp',
args: opts?.agentArgs ?? args ?? {},
callMain: true,
env,
token: signedPayload.token
})
}
async function startDebugging(): Promise<void> {
try {
// Show console when starting a debug session
showDebugConsole = true
// Reset selected frame when starting new session
selectedDebugFrameId = null
// Always reset and create a fresh DAP client with the correct URL for the current language
// This ensures we connect to the correct endpoint even if language changed
resetDAPClient()
dapClient = getDAPClient(dapServerUrl)
// Fetch contextual variables (WM_WORKSPACE, WM_TOKEN, etc.) from backend
const env = await fetchContextualVariables(opWs ?? '')
// Sign the debug request (creates audit log entry)
let signedPayload
try {
signedPayload = await signDebugRequest(opWs ?? '', code ?? '', lang ?? 'python3')
debugSessionJobId = signedPayload.job_id
} catch (signError) {
sendUserToast(getDebugErrorMessage(signError), true)
return
}
await dapClient.connect()
await dapClient.initialize()
await dapClient.setBreakpoints(debugFilePath, Array.from(debugBreakpoints))
await dapClient.configurationDone()
// Pass the signed token along with other launch parameters
await dapClient.launch({
code,
cwd: '/tmp',
args: args ?? {},
callMain: true,
env,
// JWT token for verification by the debugger
token: signedPayload.token
})
// Claim after the reset (runDebugSession's resetDAPClient clears the owner);
// claiming before it would be wiped, leaving the session seizable by the agent.
await runDebugSession({ onClientReady: () => preemptToHuman() })
} catch (error) {
console.error('Failed to start debugging:', error)
sendUserToast(getDebugErrorMessage(error), true)
}
}
const agentDebugController: DebugController = {
language: () => lang ?? 'python3',
start: async (agentArgs, onClientReady) => {
await runDebugSession({ agentArgs, agentOriginated: true, onClientReady })
},
stop: () => stopDebugging(),
signExpression: (expression) => signAgentExpression(expression),
setBreakpoints: async (lines) => {
debugBreakpoints.clear()
for (const line of lines) {
debugBreakpoints.add(line)
}
updateBreakpointDecorations()
await syncBreakpointsWithServer()
}
}
async function signAgentExpression(expression: string): Promise<string | undefined> {
if (!opWs || !debugSessionJobId) return undefined
try {
const response = await fetch(`/api/w/${opWs}/debug/sign_expression`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expression, job_id: debugSessionJobId, agent_originated: true })
})
if (!response.ok) return undefined
const result = await response.json()
return result.token
} catch {
return undefined
}
}
async function stopDebugging(): Promise<void> {
if (!dapClient) return
try {
@@ -1264,21 +1312,25 @@
}
async function continueExecution(): Promise<void> {
preemptToHuman()
if (!dapClient) return
await dapClient.continue_()
}
async function stepOver(): Promise<void> {
preemptToHuman()
if (!dapClient) return
await dapClient.stepOver()
}
async function stepIn(): Promise<void> {
preemptToHuman()
if (!dapClient) return
await dapClient.stepIn()
}
async function stepOut(): Promise<void> {
preemptToHuman()
if (!dapClient) return
await dapClient.stepOut()
}
@@ -1437,6 +1489,10 @@
await inferSchema(code, { applyInitialArgs: true })
}
aiChatManager.saveAndClear()
// Register before changeMode so the debug tools are gated in on first SCRIPT setup.
aiChatManager.scriptEditorDebugController = isDebuggableScript
? agentDebugController
: undefined
aiChatManager.changeMode(AIMode.SCRIPT)
if (customUi?.previewPanel?.loadLastRunOnMount) {
void loadLastRunIntoTestPanel()
@@ -1551,6 +1607,7 @@
aiChatManager.scriptEditorApplyCode = undefined
aiChatManager.scriptEditorShowDiffMode = undefined
aiChatManager.scriptEditorGetLintErrors = undefined
aiChatManager.scriptEditorDebugController = undefined
aiChatManager.scriptEditorOptions = undefined
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.NAVIGATOR)
@@ -1742,6 +1799,9 @@
editor?.getLintErrors() ?? { errorCount: 0, warningCount: 0, errors: [], warnings: [] }
)
}
aiChatManager.scriptEditorDebugController = isDebuggableScript
? agentDebugController
: undefined
})
})
</script>
@@ -1,6 +1,7 @@
<script lang="ts">
import { X, AlertCircle, Trash2 } from 'lucide-svelte'
import type { DAPClient } from './dapClient'
import { preemptToHuman } from './agentDebugSession'
interface Props {
client: DAPClient | null
@@ -94,6 +95,9 @@
const expression = inputValue.trim()
if (!expression || !client || isEvaluating) return
// A human typing in the console takes control back from the agent.
preemptToHuman()
// Add to command history
if (commandHistory[commandHistory.length - 1] !== expression) {
commandHistory = [...commandHistory, expression]
@@ -226,7 +230,10 @@
<div class="flex items-center gap-1">
<button
class="p-0.5 hover:bg-[#3c3c3c] rounded text-[#969696] hover:text-[#d4d4d4]"
onclick={(e) => { e.stopPropagation(); clearConsole(); }}
onclick={(e) => {
e.stopPropagation()
clearConsole()
}}
title="Clear console (Ctrl+L)"
>
<Trash2 size={12} />
@@ -234,7 +241,10 @@
{#if onClose}
<button
class="p-0.5 hover:bg-[#3c3c3c] rounded text-[#969696] hover:text-[#d4d4d4]"
onclick={(e) => { e.stopPropagation(); onClose?.(); }}
onclick={(e) => {
e.stopPropagation()
onClose?.()
}}
title="Close console (Esc)"
>
<X size={14} />
@@ -244,10 +254,7 @@
</div>
<!-- Console output -->
<div
bind:this={consoleRef}
class="flex-1 overflow-auto min-h-0"
>
<div bind:this={consoleRef} class="flex-1 overflow-auto min-h-0">
{#if history.length === 0}
<div class="px-3 py-2 text-[#969696] text-[11px]">
Evaluate expressions in the current scope. Use ↑↓ for history.
@@ -263,7 +270,9 @@
<span class="text-[#ce9178] break-all whitespace-pre-wrap">{entry.content}</span>
{:else if entry.type === 'output'}
<span class="text-[#569cd6] mr-2 select-none opacity-0">&gt;</span>
<span class="{getValueClass(entry.content, 'output')} break-all whitespace-pre-wrap">{formatValue(entry.content, 'output')}</span>
<span class="{getValueClass(entry.content, 'output')} break-all whitespace-pre-wrap"
>{formatValue(entry.content, 'output')}</span
>
{:else}
<AlertCircle size={12} class="text-red-500 mr-2 mt-0.5 flex-shrink-0" />
<span class="text-red-400 break-all whitespace-pre-wrap">{entry.content}</span>
@@ -289,7 +298,9 @@
spellcheck="false"
/>
{#if isEvaluating}
<div class="w-3 h-3 border border-[#569cd6] border-t-transparent rounded-full animate-spin ml-2"></div>
<div
class="w-3 h-3 border border-[#569cd6] border-t-transparent rounded-full animate-spin ml-2"
></div>
{/if}
</div>
</div>
@@ -50,7 +50,8 @@ export async function fetchContextualVariables(workspace: string): Promise<Recor
export async function signDebugRequest(
workspace: string,
code: string,
language: string
language: string,
agentOriginated?: boolean
): Promise<{ token: string; code: string; job_id: string }> {
if (!workspace) {
throw new Error('No workspace selected')
@@ -59,7 +60,7 @@ export async function signDebugRequest(
const response = await fetch(`/api/w/${workspace}/debug/sign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, language })
body: JSON.stringify({ code, language, agent_originated: agentOriginated ?? false })
})
if (!response.ok) {