Files
ycode/src/main/chat-turn-service.ts
T

583 lines
31 KiB
TypeScript
Raw 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.
/** Shared main-process turn execution. IPC and background automations use the same locks, native history and persistence. */
import { app, type WebContents } from 'electron'
import { randomUUID } from 'node:crypto'
import { readFile, unlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { ChatEvent, ChatMessage, TokenUsage, ContextUsageCategory } from '@shared/types'
import type { ActiveChatRun, SendPayload, SendResult } from '@shared/ipc'
import { freezeAssistantModelMetadata } from '@shared/assistant-model'
import { applyAssistantEvent } from '@shared/message-parts'
import * as config from './config'
import * as sessions from './sessions'
import * as models from './models'
import { Gateway } from './gateway'
import { runTurn } from './agent/loop'
import { OmpKernel, detectOmpPath } from './omp'
import { runOmpTurn } from './agent/omp-turn'
import { createMessageCheckpoint } from './message-checkpoint'
import { classifyWorkspacePath } from './resources'
import { createInteractionBroker } from './interaction-broker'
import { attachmentPrompt, commitAttachments, getAttachment } from './attachments'
import { rollbackEditedArtifacts } from './workspace-revert'
import { normalizeUserContent } from '../shared/user-content'
import { assistantWireHistory } from './agent/history'
import { buildProjectPrompt, type ProjectRepository } from './projects'
import * as generalSettings from './general-settings'
import * as personalization from './personalization'
import * as memorySettings from './memory-settings'
import { prepareOmpModel, supportsOmpModel } from './omp-model'
import { modelThinkingLevel } from '@shared/model-thinking'
import type { ResolvedModel } from '@shared/models'
import { createCapabilitySnapshot } from './capabilities/runtime'
import { agentMode } from './agent/mode-policy'
import { prepareContextObserver, writeContextRequest, observeOmpContextSamples } from './omp-context-observer'
import { contextInputOrigins, estimateContextInput } from './context-usage-estimator'
import { getAgentSettings, onAgentSettingsChanged } from './agent-settings'
import { SubagentScheduler } from './subagents/scheduler'
import { createSubagentWorker } from './subagents/worker'
import { SUBAGENT_INSTRUCTIONS } from './subagents/tools'
import { addUsage, zeroUsage } from '@shared/subagents'
import { toolSpecs } from './agent/tools'
import { modeToolSpecs } from './agent/mode-policy'
export interface ChatTurnCompletion {
runId: string; sessionId: string; assistantMessageId: string
status: 'done' | 'cancelled' | 'error'; error?: string
}
export interface StartedChatTurn extends SendResult { completion: Promise<ChatTurnCompletion> }
export interface ChatTurnServicePorts {
projects: ProjectRepository
sessionRoot(sessionId: string): Promise<string>
broadcastChat(channel: string, payload: unknown, sender?: WebContents): void
getDefaultRecipient?(): WebContents | undefined
}
/** Compensation for a managed task rejected before its first turn was persisted. */
export async function removeEmptyManagedSession(sessionId: string): Promise<boolean> {
return withChatSessionLock(sessionId, async () => {
if ([...runTasks.values()].some(run => run.sessionId === sessionId)) return false
if ((await sessions.getMessages(sessionId)).length) return false
await disposeSessionKernel(sessionId)
await removeOmpSessionFile(sessionId)
await sessions.deleteSession(sessionId)
return true
})
}
/** runId → 取消控制器 */
export const activeRuns = new Map<string, AbortController>()
export const runTasks = new Map<string, { sessionId: string; promise: Promise<void>; snapshot: () => ActiveChatRun }>()
export const runEmitters = new Map<string, (event: ChatEvent) => void>()
const chatSessionLocks = new Map<string, Promise<void>>()
export async function withChatSessionLock<T>(id: string, operation: () => Promise<T>): Promise<T> {
const previous = chatSessionLocks.get(id) ?? Promise.resolve()
let release!: () => void
const pending = new Promise<void>((resolve) => { release = resolve })
chatSessionLocks.set(id, pending)
await previous
try { return await operation() }
finally { release(); if (chatSessionLocks.get(id) === pending) chatSessionLocks.delete(id) }
}
interface OmpKernelEntry {
kernel: OmpKernel
workspaceRoot: string
appliedBranchId?: string
fingerprint?: string
stopContextObserver?: () => void
}
export async function applyPendingNativeBranch(sessionId: string, kernel: OmpKernel): Promise<void> {
const pendingId = await sessions.getPendingNativeBranch(sessionId)
const entry = ompKernels.get(sessionId)
if (!pendingId || entry?.appliedBranchId === pendingId) return
const response = await kernel.send({ type: 'branch', entryId: pendingId })
if (response.success === false) throw new Error('无法恢复已保存的原生会话分支')
if (entry) entry.appliedBranchId = pendingId
}
/** Each YCode session gets a native OMP transcript; existing YCode-only history is not imported. */
export const ompKernels = new Map<string, OmpKernelEntry>()
const ompKernelLocks = new Map<string, Promise<void>>()
const ompRetirements = new Map<string, Promise<void>>()
let ompShutdownStarted = false
let disposeAllPromise: Promise<void> | null = null
export function ompSessionPath(sessionId: string): string {
return join(
app.getPath('userData'),
'omp-sessions',
`session-${encodeURIComponent(sessionId)}.jsonl`
)
}
async function withOmpKernelLock<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
const previous = ompKernelLocks.get(sessionId) ?? Promise.resolve()
let release = (): void => {}
const current = new Promise<void>((resolve) => {
release = resolve
})
ompKernelLocks.set(sessionId, current)
await previous
try {
return await operation()
} finally {
release()
if (ompKernelLocks.get(sessionId) === current) ompKernelLocks.delete(sessionId)
}
}
function evictOmpKernel(sessionId: string, kernel: OmpKernel): void {
if (ompKernels.get(sessionId)?.kernel !== kernel) return
ompKernels.get(sessionId)?.stopContextObserver?.()
ompKernels.delete(sessionId)
const retirement = Promise.resolve()
.then(() => kernel.dispose())
.catch(() => undefined)
ompRetirements.set(sessionId, retirement)
void retirement.then(() => {
if (ompRetirements.get(sessionId) === retirement) ompRetirements.delete(sessionId)
})
}
async function waitForOmpRetirement(sessionId: string): Promise<void> {
await ompRetirements.get(sessionId)
}
export async function getOmpKernel(sessionId: string, workspaceRoot: string, model?: ResolvedModel | null): Promise<OmpKernel | null> {
return withOmpKernelLock(sessionId, async () => {
if (ompShutdownStarted) return null
if (model && !supportsOmpModel(model)) {
const existing = ompKernels.get(sessionId)
if (existing) { ompKernels.delete(sessionId); existing.stopContextObserver?.(); await existing.kernel.dispose() }
await waitForOmpRetirement(sessionId)
return null
}
const prepared = model ? await prepareOmpModel(model) : null
const existing = ompKernels.get(sessionId)
if (existing?.workspaceRoot === workspaceRoot && existing?.fingerprint === prepared?.fingerprint) return existing.kernel
if (existing) {
ompKernels.delete(sessionId)
existing.stopContextObserver?.()
await existing.kernel.dispose()
}
await waitForOmpRetirement(sessionId)
const exePath = detectOmpPath()
if (!exePath) return null
const observer = await prepareContextObserver(app.getPath('userData'),sessionId)
const kernel = new OmpKernel(exePath, workspaceRoot, prepared?.profile ?? 'ycode', ompSessionPath(sessionId), prepared?.model,
{...prepared?.env,YCODE_CONTEXT_OBSERVER_EXTENSION:observer.extensionPath,YCODE_CONTEXT_REQUEST_FILE:observer.configPath,YCODE_CONTEXT_SAMPLE_FILE:observer.samplePath})
const entry: OmpKernelEntry = { kernel, workspaceRoot, fingerprint: prepared?.fingerprint,
stopContextObserver: observeOmpContextSamples(kernel, observer.samplePath) }
const evict = (): void => evictOmpKernel(sessionId, kernel)
kernel.on('error', evict)
kernel.on('exit', evict)
ompKernels.set(sessionId, entry)
return kernel
})
}
export async function disposeOmpKernels(): Promise<void> {
if (disposeAllPromise) return disposeAllPromise
ompShutdownStarted = true
disposeAllPromise = (async () => {
for (const controller of activeRuns.values()) controller.abort()
while (ompKernelLocks.size > 0) {
await Promise.all([...ompKernelLocks.values()])
}
const entries = [...ompKernels.values()]
ompKernels.clear()
await Promise.all(
entries.map(async ({ kernel, stopContextObserver }) => {
stopContextObserver?.()
await kernel.dispose().catch(() => undefined)
})
)
await Promise.all([...ompRetirements.values()])
await Promise.all([...runTasks.values()].map((task) => task.promise))
})()
return disposeAllPromise
}
export async function disposeSessionKernel(sessionId: string): Promise<void> {
await withOmpKernelLock(sessionId, async () => {
const entry = ompKernels.get(sessionId)
if (entry) ompKernels.delete(sessionId)
if (entry) { entry.stopContextObserver?.(); await entry.kernel.dispose() }
await waitForOmpRetirement(sessionId)
})
}
export async function removeOmpSessionFile(sessionId: string): Promise<void> {
try {
await unlink(ompSessionPath(sessionId))
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
}
}
export async function snapshotNativeSession(sessionId: string): Promise<() => Promise<void>> {
let before: Buffer | null = null
try { before = await readFile(ompSessionPath(sessionId)) }
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
return async () => {
// Stop the writer before restoring the exact transcript consumed by --resume.
await disposeSessionKernel(sessionId)
if (before === null) await removeOmpSessionFile(sessionId)
else await writeFile(ompSessionPath(sessionId), before)
}
}
export async function compensateFailedAction(error: unknown, actions: Array<(() => Promise<void>) | undefined>): Promise<never> {
const failures: unknown[] = []
for (const action of actions) {
try { await action?.() } catch (failure) { failures.push(failure) }
}
if (failures.length) throw new AggregateError([error, ...failures], '操作失败,且原有会话或产物未能完全恢复,请检查工作区后重试。')
throw error
}
export function createChatTurnService({ projects, sessionRoot, broadcastChat, getDefaultRecipient }: ChatTurnServicePorts) {
const startTurn = async (payload: SendPayload, sender?: WebContents): Promise<StartedChatTurn> => withChatSessionLock(payload.sessionId, async () => {
if (ompShutdownStarted) throw new Error('应用正在退出')
const recipient = sender ?? getDefaultRecipient?.()
const { sessionId, text } = payload
if (payload.rollbackArtifacts !== undefined && typeof payload.rollbackArtifacts !== 'boolean') throw new Error('产物回退选项无效')
if (!(await sessions.listSessions()).some((session) => session.id === sessionId)) throw new Error('会话不存在')
if (typeof text !== 'string' || (!text.trim() && !payload.attachments?.length && !payload.quotes?.length && !payload.resources?.length && !payload.skillIds?.length)) throw new Error('消息为空')
if ([...runTasks.values()].some((run) => run.sessionId === sessionId)) throw new Error('该会话正在运行')
const attachments = await Promise.all((payload.attachments ?? []).map((item) => getAttachment(item.id)))
const promptAttachments = await attachmentPrompt(attachments)
const context = await sessions.getSessionContext(sessionId)
const project = context.projectId ? await projects.get(context.projectId) : undefined
const mode = agentMode(context.agentMode)
const selectedModel = await models.resolveModel(context.modelId)
if (!selectedModel) throw new Error('请先添加并选择自定义模型')
const snapshot = selectedModel?.config ?? await config.getGatewayConfig()
const workspaceRoot = await sessionRoot(sessionId)
const references = [...new Set(payload.resources ?? [])]
const capabilitySnapshot = await createCapabilitySnapshot(payload.skillIds ?? [], workspaceRoot, { sessionId, expertId: context.expertId, agentMode: mode })
const [personalizationView, memoryView, agentSettings] = await Promise.all([
personalization.getPersonalization(), memorySettings.getMemorySettings(), getAgentSettings()
])
const personalizationPrompt = personalization.buildPersonalizationPrompt(personalizationView, {
includeLongTermMemory: memoryView.conversationMemory
})
const promptContext = [capabilitySnapshot.context, buildProjectPrompt(project), personalizationPrompt,
agentSettings.teamManagement && mode === 'craft' && selectedModel.profile.supportsToolCall !== false ? SUBAGENT_INSTRUCTIONS : ''].filter(Boolean).join('\n\n')
for (const path of references) {
if (typeof path !== 'string' || await classifyWorkspacePath(workspaceRoot, path) === 'unknown') throw new Error('引用资源不在工作区内或已不存在')
}
const kernel = await getOmpKernel(sessionId, workspaceRoot, selectedModel)
if (kernel) await applyPendingNativeBranch(sessionId, kernel)
const nativeBefore = kernel ? await kernel.send({ type: 'get_branch_messages' }) : null
const nativeBeforeIds = new Set((nativeBefore?.data as { messages?: Array<{ entryId?: string }> } | undefined)?.messages?.map((item) => item.entryId) ?? [])
await commitAttachments(attachments)
let history = [...await sessions.getMessages(sessionId)]
let selectedNativeBranch: string | undefined
let editUndo: (() => Promise<void>) | undefined
let undoNative: (() => Promise<void>) | undefined
if (payload.action) {
let targetIndex = history.findIndex((message) => message.id === payload.fromMessageId)
if (targetIndex < 0) throw new Error('未找到需要重发的消息')
if (payload.action === 'regenerate') {
if (history[targetIndex].role !== 'assistant') throw new Error('只能重新生成助手消息')
targetIndex = history.slice(0, targetIndex).findLastIndex((message) => message.role === 'user')
}
const target = history[targetIndex]
if (!target || target.role !== 'user') throw new Error('未找到对应用户消息')
if (kernel) undoNative = await snapshotNativeSession(sessionId)
// 原版编辑语义(userMessage.edit.hint):产物回退至此消息前版本,再从此处重新开始对话。
editUndo = payload.action === 'edit' && payload.rollbackArtifacts !== false
? await rollbackEditedArtifacts(workspaceRoot, history, payload.fromMessageId!) : undefined
try {
if (kernel) {
if (target.nativeSessionPath && target.nativeSessionPath !== ompSessionPath(sessionId)) throw new Error('这条历史消息没有当前 OMP 会话的分支记录')
let entryId = target.nativeEntryId
if (!entryId) {
const following = history[targetIndex + 1]
const prefixUsers = history.slice(0, targetIndex).filter((message) => message.role === 'user')
const nativeUsers = (nativeBefore?.data as { messages?: Array<{ entryId: string; text: string }> } | undefined)?.messages ?? []
const isLastInput = !history.slice(targetIndex + 1).some((message) => message.role === 'user')
const canRecover = isLastInput && following?.role === 'assistant' && ['cancelled', 'error'].includes(following.status ?? '') &&
prefixUsers.every((message, index) => !!message.nativeEntryId && message.nativeEntryId === nativeUsers[index]?.entryId)
if (!canRecover) throw new Error('这条历史消息没有当前 OMP 会话的分支记录')
const attached = await attachmentPrompt(target.attachments ?? [])
const nativePrompt = [target.content, target.quotes?.map((quote) => quote.markdown.trim()).filter(Boolean).join('\n\n'), attached.text, target.resources?.length ? `引用工作区资源:\n${target.resources.join('\n')}` : '', target.capabilityContext].filter(Boolean).join('\n\n')
if (nativeUsers.length === prefixUsers.length + 1 && nativeUsers.at(-1)?.text === nativePrompt) entryId = nativeUsers.at(-1)!.entryId
else if (nativeUsers.length !== prefixUsers.length) throw new Error('中断会话的原生历史与消息记录不一致')
}
if (entryId) {
try {
const result = await kernel.send({ type: 'branch', entryId })
if (result.success === false) throw new Error(String(result.error ?? '原生会话分支失败'))
} catch (error) { await disposeSessionKernel(sessionId); throw error }
selectedNativeBranch = entryId
}
const entry = ompKernels.get(sessionId)
if (entry && entryId) entry.appliedBranchId = entryId
}
} catch (error) {
await compensateFailedAction(error, [undoNative, editUndo])
}
history = history.slice(0, targetIndex)
}
const runId = randomUUID()
const userMessage: ChatMessage = {
id: randomUUID(),
role: 'user',
content: text,
requestId: runId,
planId: payload.planId,
attachments,
resources: references,
quotes: payload.quotes?.length ? payload.quotes : undefined,
userContent: normalizeUserContent(payload.userContent, text, { attachments, resources: references, quotes: payload.quotes, skills: capabilitySnapshot.references }),
skills: capabilitySnapshot.references,
capabilityContext: promptContext || undefined,
expertId: context.expertId ?? undefined,
createdAt: Date.now(),
status: 'done'
}
let assistant: ChatMessage = {
id: randomUUID(),
role: 'assistant',
content: '',
...freezeAssistantModelMetadata(selectedModel.profile),
assistantDisplay: capabilitySnapshot.assistantDisplay,
requestId: runId,
tools: [],
parts: [],
createdAt: Date.now(),
turnStartedAt: userMessage.createdAt,
status: 'streaming'
}
try { await sessions.startTurn(sessionId, userMessage, assistant, history, payload.action, selectedNativeBranch) }
catch (error) {
await compensateFailedAction(error, [undoNative, editUndo])
}
editUndo = undefined
undoNative = undefined
let eventSequence = 0
let usage: TokenUsage | undefined
const declaredContextWindow = selectedModel.profile.contextWindowTokens ?? selectedModel.profile.maxInputTokens
const contextWindowAtStart = typeof declaredContextWindow === 'number' && Number.isFinite(declaredContextWindow) && declaredContextWindow > 0
? declaredContextWindow : undefined
// 回合开始时冻结配置:运行中改设置不影响本回合
const controller = new AbortController()
activeRuns.set(runId, controller)
// 通用设置「运行时防止系统休眠」:任务运行期间持有 powerSaveBlocker。
generalSettings.updateActiveRuns(1)
const publish = (event: ChatEvent): void => {
broadcastChat('chat:event', { ...event, sessionId }, recipient)
}
const checkpoint = createMessageCheckpoint(
(message) => sessions.patchMessage(sessionId, message.id, {
content: message.content,
tools: message.tools,
parts: message.parts,
interactions: message.interactions,
status: message.status,
turnStartedAt: message.turnStartedAt,
turnDurationMs: message.turnDurationMs,
contextUsage: message.contextUsage,
contextBreakdown: message.contextBreakdown,
runUsage: message.runUsage,
error: message.error
}),
(error) => console.error('会话增量保存失败,将在回合结束时重试', error)
)
let terminalEvent: ChatEvent | null = null
let scheduler: SubagentScheduler | undefined
let mainUsage = zeroUsage(), childUsage = zeroUsage()
const publishReportedUsage = (): void => {
if (scheduler?.hasTasks) emit({ runId, type: 'run_usage', usage: { main: mainUsage, subagents: childUsage, total: addUsage(mainUsage, childUsage), reportedOnly: true } })
}
const emit = (event: ChatEvent): void => {
const isTerminal = event.type === 'done' || event.type === 'cancelled' || event.type === 'error'
let observedEvent: ChatEvent = isTerminal
? { ...event, sequence: ++eventSequence, turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt) }
: { ...event, sequence: ++eventSequence }
if (observedEvent.type === 'usage') {
mainUsage = addUsage(mainUsage, observedEvent.usage)
observedEvent = { ...observedEvent, contextWindow: contextWindowAtStart,source:'upstream',sampledAt:Date.now(),modelId:assistant.modelId }
usage = observedEvent.usage
}
if (observedEvent.type === 'context_breakdown') usage = undefined
assistant = applyAssistantEvent(assistant, observedEvent)
if (isTerminal) {
terminalEvent = observedEvent
} else {
checkpoint.schedule(assistant)
publish(observedEvent)
}
if (event.type === 'usage') publishReportedUsage()
}
runEmitters.set(runId, emit)
const interactionBroker = createInteractionBroker(runId, controller.signal, emit)
scheduler = new SubagentScheduler({ runId, workspaceRoot, modelName: selectedModel.profile.name,
enabled: agentSettings.teamManagement && mode === 'craft' && selectedModel.profile.supportsToolCall !== false,
signal: controller.signal, emit, capabilities: capabilitySnapshot,
onUsage: reported => { childUsage = addUsage(childUsage, reported); publishReportedUsage() },
worker: (task, tools) => createSubagentWorker({ task, tools, model: selectedModel, workspaceRoot, parentRunId: runId, parentSessionId: sessionId,
native: !!kernel,
thinkingLevel: modelThinkingLevel(selectedModel.profile, context.thinkingLevel), emitParent: emit })
})
const teamScheduler = scheduler
const unsubscribeAgents = onAgentSettingsChanged(next => teamScheduler.setEnabled(next.teamManagement && mode === 'craft' && selectedModel.profile.supportsToolCall !== false))
teamScheduler.onToolsChanged = () => {
if (!kernel || controller.signal.aborted) return
const available = modeToolSpecs([...toolSpecs(), ...teamScheduler.tools.specs], mode)
void kernel.setHostTools(available.map(spec => ({ ...spec.function, label: spec.function.name })))
.catch(error => console.error('更新智能体工具列表失败;宿主调用限制仍然生效', error))
}
// 对话中引用的内容块(原版 message-quote)随消息一起交给模型;表格 markdown 原样附带。
const quoteText = (payload.quotes ?? []).map((quote) => quote.markdown.trim()).filter(Boolean).join('\n\n')
// Freeze appended context with this message so fallback history and interrupted retries use the same input.
const promptText = [text, quoteText, promptAttachments.text, references.length ? `引用工作区资源:\n${references.join('\n')}` : '', promptContext].filter(Boolean).join('\n\n')
const origins = contextInputOrigins([...history.map(message=>message.capabilityContext??''),promptContext])
const toolCategories:Record<string,ContextUsageCategory> = {}
for (const spec of capabilitySnapshot.specs) {
if(capabilitySnapshot.metadata?.(spec.function.name)?.capability==='mcp')toolCategories[spec.function.name]='mcp'
}
for (const message of history) for(const tool of message.tools??[]) {
if(tool.metadata?.capability==='mcp')toolCategories[tool.name]='mcp'
}
// 回合在后台推进,立刻把 runId 还给渲染进程
const initialMessages = payload.action ? structuredClone([...history, userMessage, assistant]) : undefined
const runSnapshot = (): ActiveChatRun => ({
sessionId, runId, assistantMessageId: assistant.id, modelId: assistant.modelId, modelName: assistant.modelName,
assistantDisplay: assistant.assistantDisplay, userMessage: structuredClone(userMessage),
messages: structuredClone([...history, userMessage, assistant]), sequence: eventSequence,
...(usage ? { usage: { ...usage } } : {})
})
// Publish the run identity before any sidecar can emit its first delta.
const task = Promise.resolve().then(async () => {
try {
// 内核选择:优先 OMP(外部 Agent 内核),不可用时回退自建循环
if (kernel) {
const observer = await prepareContextObserver(app.getPath('userData'),sessionId)
await writeContextRequest(observer.configPath,{runId,sessionPath:ompSessionPath(sessionId),modelId:assistant.modelId??'',
modelName:selectedModel.profile.model,contextWindow:contextWindowAtStart,origins,toolCategories})
const result = await runOmpTurn({
sessionId,
capabilityTools: teamScheduler.tools,
beforeFinish: () => teamScheduler.beforeFinish(),
refreshHostTools: true,
agentMode: mode,
thinkingLevel: modelThinkingLevel(selectedModel.profile, context.thinkingLevel),
runId,
kernel,
nativeSessionPath:ompSessionPath(sessionId),
supportsToolCall: selectedModel?.profile.supportsToolCall,
supportsImages: selectedModel.profile.supportsImages === true,
userText: promptText,
images: promptAttachments.images,
workspaceRoot,
interactions: interactionBroker,
signal: controller.signal,
emit
})
assistant.content = result.content
assistant.tools = result.tools
// 终态由内核回合给出(abort 后到达的 agent_end 记为 cancelled)
assistant.status = result.status
const branch = await kernel.send({ type: 'get_branch_messages' })
const data = branch.data as { messages?: Array<{ entryId?: string; text?: string }> } | undefined
const native = data?.messages?.findLast(message => message.text === promptText && !!message.entryId && !nativeBeforeIds.has(message.entryId))
if (native?.entryId && !nativeBeforeIds.has(native.entryId) && native.text === promptText) {
await sessions.patchMessage(sessionId, userMessage.id, { nativeEntryId: native.entryId, nativeSessionPath: ompSessionPath(sessionId) })
await sessions.setPendingNativeBranch(sessionId, undefined)
}
} else {
const wireHistory = (await Promise.all(history.map(async (message) => {
if (message.role === 'assistant') return assistantWireHistory(message, { supportsImages: selectedModel.profile.supportsImages === true })
const attached = await attachmentPrompt(message.attachments ?? [])
const content = [message.content, message.quotes?.map((quote) => quote.markdown.trim()).filter(Boolean).join('\n\n'), attached.text, message.resources?.length ? `引用工作区资源:\n${message.resources.join('\n')}` : '', message.capabilityContext].filter(Boolean).join('\n\n')
return [{ role: message.role, content: attached.images.length ? [{ type: 'text' as const, text: content }, ...attached.images.map((image) => ({ type: 'image_url' as const, image_url: { url: `data:${image.mimeType};base64,${image.data}` } }))] : content }]
}))).flat()
const result = await runTurn({
sessionId,
supportsImages: selectedModel.profile.supportsImages === true,
capabilityTools: teamScheduler.tools,
beforeFinish: () => teamScheduler.beforeFinish(),
agentMode: mode,
runId,
history,
wireHistory,
userText: promptText,
images: promptAttachments.images,
interactions: interactionBroker,
signal: controller.signal,
emit,
gateway: new Gateway({ ...snapshot, thinkingLevel: modelThinkingLevel(selectedModel.profile, context.thinkingLevel) },body=>{
const input=body as {messages?:unknown[];input?:unknown[];contents?:unknown[];instructions?:unknown;system?:unknown;systemInstruction?:{parts?:unknown[]};tools?:unknown[]}
const counts=estimateContextInput({messages:input.messages??input.input??input.contents??[],systemPrompt:input.instructions??input.system??input.systemInstruction?.parts,tools:input.tools??[],origins,toolCategories})
emit({runId,type:'context_breakdown',breakdown:{...counts,requestId:runId,sequence:(assistant.contextBreakdown?.sequence??0)+1,
sampledAt:Date.now(),modelId:assistant.modelId,contextWindow:contextWindowAtStart,method:'utf8-estimate',scope:'provider-request'}})
}),
supportsToolCall: selectedModel?.profile.supportsToolCall,
model: snapshot.model,
maxToolRounds: snapshot.maxToolRounds,
workspaceRoot
})
assistant.content = result.content
assistant.tools = result.tools
// 旧循环只在正常完成时走到这里;abort/error 走下面的 catch
assistant.status = 'done'
}
} catch (err) {
if ((err as Error).name === 'AbortError' || controller.signal.aborted) {
emit({ runId, type: 'cancelled' })
} else {
emit({ runId, type: 'error', message: err instanceof Error ? err.message : String(err) })
}
} finally {
controller.abort()
unsubscribeAgents()
await teamScheduler.close()
publishReportedUsage()
if(kernel)await unlink(join(app.getPath('userData'),'context-usage',`${encodeURIComponent(sessionId)}.json`)).catch(()=>undefined)
try {
// Child cleanup can publish final snapshots after the kernel has yielded.
// The terminal event must remain the last sequence consumed by live renderers.
if (terminalEvent) {
terminalEvent = { ...terminalEvent, sequence: ++eventSequence, turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt) }
assistant = applyAssistantEvent(assistant, terminalEvent)
}
await checkpoint.finish(assistant)
if (terminalEvent) publish(terminalEvent)
} catch (error) {
const failure: ChatEvent = { runId, type: 'error', sequence: ++eventSequence,
message: `会话保存失败:${error instanceof Error ? error.message : String(error)}` }
assistant = applyAssistantEvent(assistant, failure)
publish(failure)
} finally {
activeRuns.delete(runId)
generalSettings.updateActiveRuns(-1)
runEmitters.delete(runId)
}
}
})
runTasks.set(runId, { sessionId, promise: task, snapshot: runSnapshot })
broadcastChat('chat:run-started', runSnapshot(), recipient)
void task.finally(() => runTasks.delete(runId))
const completion = task.then((): ChatTurnCompletion => ({ runId, sessionId, assistantMessageId: assistant.id,
status: assistant.status === 'done' ? 'done' : assistant.status === 'cancelled' ? 'cancelled' : 'error',
...(assistant.error ? { error: assistant.error } : {}) }), (error): ChatTurnCompletion => ({
runId, sessionId, assistantMessageId: assistant.id, status: 'error', error: error instanceof Error ? error.message : String(error) }))
return { completion, runId, assistantMessageId: assistant.id, modelId: assistant.modelId, modelName: assistant.modelName,
assistantDisplay: assistant.assistantDisplay, userMessage, ...(initialMessages ? { messages: initialMessages } : {}) } })
return { startTurn, cancel(runId: string) { const controller = activeRuns.get(runId); controller?.abort(); return { cancelled: !!controller } } }
}