import type { AgentBrowserService } from '../api/context'; import type { CodingConversationService } from './conversation-service'; import type { CodingConversationRuntime } from './contracts'; import { OPEN_WORK_PROMPT, type WorkPreviewResult } from '../../shared/coding-work-preview'; import { codingRecovery } from '../../shared/coding-recovery'; import type { AgentBrowserSnapshot } from '../../shared/agent-browser'; type Project = { id: string; path: string }; type Attempt = { requestId: string; conversationId?: string; submitted?: boolean; runId?: string; submittedAt?: number; result?: WorkPreviewResult; }; /** Only probe a known loopback work address; never scan ports or follow redirects. */ export async function workPageResponds(address: string): Promise { try { const url = new URL(address); if (!['http:', 'https:'].includes(url.protocol) || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) || url.username || url.password) return false; const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(2_000) }); await response.body?.cancel(); return response.ok; } catch { return false; } } export class CodingWorkPreviewService { private readonly attempts = new Map(); private readonly flights = new Map>(); // Native views are disposable during background sleep; the running project // server and its verified address have a separate lifetime. private readonly pages = new Map(); constructor(private readonly deps: { browser: Pick; conversations: Pick; runtime: Pick; ensureActive(project: Project): Promise; responds?(url: string): Promise; }) {} remember(project: Project, snapshot: AgentBrowserSnapshot): void { if (snapshot.projectId !== project.id || !snapshot.url || !snapshot.browserId || snapshot.state !== 'attached' || snapshot.error) return; this.pages.delete(project.id); if (this.pages.size >= 100) this.pages.delete(this.pages.keys().next().value!); this.pages.set(project.id, { path: project.path, url: snapshot.url }); this.attempts.delete(project.id); // A successful manual/Agent open supersedes a slow older startup check. this.flights.delete(project.id); } async ensure(project: Project, requestId: string, conversationId?: string): Promise { await this.deps.ensureActive(project); // Serialize browser checks as well as prompt submission for each project. const existing = this.flights.get(project.id); if (existing) return existing; const flight = this.check(project, requestId, conversationId); this.flights.set(project.id, flight); try { return await flight; } finally { if (this.flights.get(project.id) === flight) this.flights.delete(project.id); } } private async check(project: Project, requestId: string, conversationId?: string): Promise { const { browser, conversations } = this.deps; const snapshot = await browser.getSnapshot(project.path); const responds = this.deps.responds ?? workPageResponds; const remembered = this.pages.get(project.id); const address = snapshot.projectId === project.id && snapshot.url ? snapshot.url : remembered?.path === project.path ? remembered.url : undefined; if (address && await responds(address)) { await this.deps.ensureActive(project); const ready = snapshot.projectId === project.id && snapshot.url === address && snapshot.browserId && snapshot.state === 'attached' && !snapshot.error ? snapshot : await browser.open({ projectId: project.id, projectPath: project.path, url: address, visible: false }); await this.deps.ensureActive(project); this.remember(project, ready); return { status: 'ready', browser: ready }; } if (this.pages.has(project.id) && this.pages.get(project.id) !== remembered) return this.check(project, requestId, conversationId); this.pages.delete(project.id); await this.deps.ensureActive(project); let attempt = this.attempts.get(project.id); if (attempt?.submitted && attempt.conversationId) { const current = await conversations.getSnapshot(attempt.conversationId); if (this.pages.has(project.id)) return this.check(project, requestId, conversationId); if (!['idle', 'error'].includes(current.run.status)) { return { status: 'starting', conversation: await conversations.getConversation(attempt.conversationId) }; } if (attempt.runId && current.run.runId !== attempt.runId && Date.now() - attempt.submittedAt! < 15_000) { return { status: 'starting', conversation: await conversations.getConversation(attempt.conversationId) }; } // The accepted action settled without opening a reachable work page. const errorCode = current.run.error?.code; attempt.result ??= { status: 'failed', errorCode, message: codingRecovery(errorCode, true).message }; } if (attempt?.result && attempt.requestId === requestId) return attempt.result; if (!attempt || attempt.result || attempt.requestId !== requestId) { attempt = { requestId, conversationId }; // Keep only a bounded number of inactive project attempts. if (this.attempts.size >= 100) this.attempts.delete(this.attempts.keys().next().value!); this.attempts.set(project.id, attempt); } const list = await conversations.listConversations(project.id); const projectIds = new Set(list.map(({ id }) => id)); const busy = this.deps.runtime.getDiagnostics().workers.some((worker) => ( projectIds.has(worker.conversationId) && ['starting', 'queued', 'running'].includes(worker.stage) )); if (busy) return { status: 'waiting' }; let conversation = attempt.conversationId ? await conversations.getConversation(attempt.conversationId) : list.find((item) => !item.archivedAt); await this.deps.ensureActive(project); conversation ??= await conversations.createConversation({ projectId: project.id, title: '新对话' }); attempt.conversationId = conversation.id; const current = await conversations.getSnapshot(conversation.id); if (this.pages.has(project.id)) return this.check(project, requestId, conversationId); if (!['idle', 'error'].includes(current.run.status)) return { status: 'waiting', conversation }; await this.deps.ensureActive(project); // Mark before dispatch: uncertain acceptance must never cause automatic resubmission. attempt.submitted = true; attempt.submittedAt = Date.now(); try { const acceptance = await conversations.acceptPrompt({ conversationId: conversation.id, clientRequestId: `work-preview-${attempt.requestId}`, mode: 'prompt', text: OPEN_WORK_PROMPT, }); attempt.runId = acceptance.runId; } catch (error) { const errorCode = error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' ? error.code : 'CODING_REQUEST_UNCERTAIN'; attempt.result = { status: 'failed', conversation, errorCode, message: codingRecovery(errorCode, true).message }; return attempt.result; } return { status: 'starting', conversation }; } }