550 lines · 21.0 KB · TypeScript
RawDownload
import {  ACP_CONTEXT_COMPACTION_ACTIVITY_TOOL_NAME,  getAcpRuntimeEventImage,  getAcpRuntimeEventText,  MAX_ACP_MESSAGE_IMAGE_BYTES_PER_MESSAGE,  MAX_ACP_MESSAGE_IMAGES_PER_MESSAGE,  MAX_ACP_SESSION_IMAGE_BYTES,  normalizeClaudeCodeRefusalText,  sanitizeAcpContextWindowSample,  type AcpRuntimeEvent} from './acp'import { getActivityGroupTitleFromToolEvent, isActivityGroupToolEvent } from './activity-groups'import { artifactCreatedAtMs, type ArtifactFile } from './artifacts'import {  projectConversationMessage,  resolveActiveConversationActivities,  resolveActiveConversationMessages,  resolveMessageBranchPath,  validateConversationGraph,  type PersistedBranchActivityGroup,  type PersistedAgentFrame,  type PersistedConversationGraph,  type PersistedMessageBranch,  type PersistedMessageNode} from './conversation-graph'import {  INTERRUPTED_TURN_ERROR,  retainRecentSessionEventIds,  type PersistedArtifact,  type PersistedChatSession,  type PersistedToolActivityStatus} from './session-persistence'import { isClaudeApiResponseInterruption } from './run-error-classification'import { createRuntimeAgentMessageId } from './runtime-message-identity' export type RuntimeSessionScope = {  promptMessageId: string  agentFrameId: string  messageBranchId: string  runtimeSegmentId: string} const scopedPath = (  session: PersistedChatSession,  scope: RuntimeSessionScope): {  graph: PersistedConversationGraph  frame: PersistedAgentFrame  branch: PersistedMessageBranch  path: PersistedMessageNode[]} => {  const graph = session.conversationGraph  if (!graph) throw new Error('Runtime Session projection requires a conversation graph.')  const frame = graph.frames.find(({ id }) => id === scope.agentFrameId)  const branch = graph.branches.find(({ id }) => id === scope.messageBranchId)  const segment = graph.runtimeSegments.find(({ id }) => id === scope.runtimeSegmentId)  if (!frame || !branch || branch.agentFrameId !== frame.id) {    throw new Error('Runtime Session scope does not identify one Message Branch.')  }  if (!segment || segment.agentFrameId !== frame.id) {    throw new Error('Runtime Session scope does not identify a Runtime Segment.')  }  const path = resolveMessageBranchPath(graph, branch.id)  if (!path.some(({ id, role }) => id === scope.promptMessageId && role === 'user')) {    throw new Error('Runtime Session prompt is not on the supplied Message Branch.')  }  return { graph, frame, branch, path }} const appendUnique = (  current: readonly string[] | undefined,  values: readonly string[]): string[] => [...new Set([...(current ?? []), ...values])] const isTerminal = (status: PersistedToolActivityStatus): boolean =>  status === 'completed' || status === 'failed' const toolStatus = (value: string | undefined): PersistedToolActivityStatus | undefined =>  value === 'pending' || value === 'in_progress' || value === 'completed' || value === 'failed'    ? value    : undefined const persistedArtifact = (  artifact: ArtifactFile,  fallbackCreatedAt: number): PersistedArtifact => {  const createdAt = artifactCreatedAtMs(artifact.createdAt) ?? fallbackCreatedAt  return {    id: artifact.id,    kind: 'managed-file',    path: artifact.path,    fileUrl: artifact.fileUrl,    name: artifact.name,    mimeType: artifact.mimeType,    size: artifact.size,    createdAt,    mtimeMs: artifact.mtimeMs,    ...(artifact.artifactId ? { artifactId: artifact.artifactId } : {}),    ...(artifact.versionId ? { versionId: artifact.versionId } : {}),    ...(artifact.versionNumber === undefined ? {} : { versionNumber: artifact.versionNumber }),    ...(artifact.checksum ? { sha256: artifact.checksum } : {})  }} const artifactsEqual = (left: PersistedArtifact, right: PersistedArtifact): boolean =>  left.id === right.id &&  left.kind === right.kind &&  left.path === right.path &&  left.fileUrl === right.fileUrl &&  left.name === right.name &&  left.mimeType === right.mimeType &&  left.size === right.size &&  left.createdAt === right.createdAt &&  left.mtimeMs === right.mtimeMs &&  left.sha256 === right.sha256 &&  left.artifactId === right.artifactId &&  left.versionId === right.versionId &&  left.versionNumber === right.versionNumber const synchronizeFlatProjection = (session: PersistedChatSession): PersistedChatSession => {  const graph = session.conversationGraph!  const messages = resolveActiveConversationMessages(graph).map(projectConversationMessage)  const activityProjection = resolveActiveConversationActivities(graph)  return {    ...session,    messages,    activities: activityProjection.activities,    activityGroups: activityProjection.activityGroups  }} const completeGroups = (  groups: PersistedBranchActivityGroup[],  scope: RuntimeSessionScope,  now: number): void => {  for (const group of groups) {    if (      group.agentFrameId === scope.agentFrameId &&      group.messageBranchId === scope.messageBranchId &&      group.promptMessageId === scope.promptMessageId &&      group.completedAt === undefined &&      group.activityIds.length > 0    ) {      group.completedAt = now      group.updatedAt = now    }  }} const terminalContextSample = (  event: AcpRuntimeEvent,  scope: RuntimeSessionScope): ReturnType<typeof sanitizeAcpContextWindowSample> => {  const terminal = event.terminalContextWindow  if (!terminal) return undefined  return sanitizeAcpContextWindowSample({    id: event.id,    timestamp: event.timestamp,    termination: terminal.termination,    contextWindow: terminal.contextWindow,    modelStepUsage: terminal.modelStepUsage,    source: event.kind === 'stop' ? 'stop' : 'error',    runtimeSegmentId: scope.runtimeSegmentId  })} const terminalize = (  session: PersistedChatSession,  scope: RuntimeSessionScope,  event: AcpRuntimeEvent): void => {  const graph = session.conversationGraph!  const cancelled = event.kind === 'stop' && event.text === 'cancelled'  const connectionLost =    event.kind === 'error' &&    event.providerError === true &&    isClaudeApiResponseInterruption(event.text)  const failed = event.kind === 'error' || cancelled  const responses = graph.messages.filter(    (message) =>      message.agentFrameId === scope.agentFrameId &&      message.introducedOnBranchId === scope.messageBranchId &&      message.role === 'agent' &&      message.responseToMessageId === scope.promptMessageId  )  const usageOwner = responses.at(-1)  for (const message of responses) {    if (message.status === 'streaming') {      message.status = failed ? 'error' : 'complete'      if (failed) message.failedAt = event.timestamp      else message.completedAt = event.timestamp    }    delete message.turnUsage    delete message.turnUsageUnavailable    delete message.modelCallUsage    if (message === usageOwner) {      if (event.turnUsage) {        message.turnUsage = structuredClone(event.turnUsage)        message.modelCallUsage = event.modelCallUsage?.map((usage) => structuredClone(usage))      } else {        message.turnUsageUnavailable = true      }    }    message.eventIds = retainRecentSessionEventIds(message.eventIds)    message.updatedAt = Math.max(message.updatedAt, event.timestamp)  }  const prompt = graph.messages.find(({ id }) => id === scope.promptMessageId)  const sample = terminalContextSample(event, scope)  if (prompt && sample && !prompt.contextWindowSamples?.some(({ id }) => id === sample.id)) {    prompt.contextWindowSamples = [...(prompt.contextWindowSamples ?? []), sample]    prompt.updatedAt = Math.max(prompt.updatedAt, event.timestamp)  }  if ((cancelled || connectionLost) && prompt) prompt.interrupted = true  const pendingPermission = session.runtimeContext?.permission  for (const activity of graph.activities) {    if (      activity.agentFrameId === scope.agentFrameId &&      activity.messageBranchId === scope.messageBranchId &&      activity.promptMessageId === scope.promptMessageId &&      !isTerminal(activity.status)    ) {      // A durable MCP approval still owns this invocation after the provider Attempt ends.      // Its decision/continuation, rather than an old stop, settles the tool activity.      if (        pendingPermission?.state === 'pending' &&        pendingPermission.request.isMcp === true &&        pendingPermission.request.sessionId === session.id &&        pendingPermission.originatingPromptMessageId === scope.promptMessageId &&        pendingPermission.request.toolCallId === activity.id      )        continue      activity.status = failed ? 'failed' : 'completed'      activity.updatedAt = event.timestamp      activity.eventIds = retainRecentSessionEventIds(activity.eventIds)    }  }  completeGroups(graph.activityGroups, scope, event.timestamp)  if (session.activeRun?.promptMessageId === scope.promptMessageId) {    const permissionPending = session.runtimeContext?.permission?.state === 'pending'    const elicitationPending = graph.activities.some(      (activity) =>        activity.agentFrameId === scope.agentFrameId &&        activity.messageBranchId === scope.messageBranchId &&        activity.promptMessageId === scope.promptMessageId &&        activity.elicitation?.state === 'pending' &&        activity.elicitation.durable?.kind === 'agent-user-choice'    )    const planPending = session.runtimeContext?.plan?.approval === 'pending'    const blocked = permissionPending || elicitationPending || planPending    session.activeRun = undefined    session.status = permissionPending      ? 'waiting-permission'      : elicitationPending        ? 'waiting-for-user'        : planPending          ? 'waiting-plan-approval'          : failed            ? 'error'            : 'idle'    session.error =      failed && !blocked        ? cancelled          ? INTERRUPTED_TURN_ERROR          : event.text || event.title        : undefined    session.errorReportable = event.kind === 'error' && !blocked ? !event.providerError : undefined    session.resumeRecovery =      (cancelled || connectionLost) && !blocked        ? {            kind: 'resume-required',            cause: cancelled ? 'cancelled' : 'connection-lost',            promptMessageId: scope.promptMessageId          }        : undefined  }} export const attachRuntimeSessionArtifacts = (  inputSession: PersistedChatSession,  scope: RuntimeSessionScope,  input: {    messageId?: string    eventId: string    runId: string    artifacts: readonly ArtifactFile[]    timestamp: number  }): { session: PersistedChatSession; messageId: string } => {  const session = structuredClone(inputSession)  const { graph, branch } = scopedPath(session, scope)  let owner = input.messageId    ? graph.messages.find(        (message) =>          message.id === input.messageId &&          message.agentFrameId === scope.agentFrameId &&          message.introducedOnBranchId === scope.messageBranchId &&          message.role === 'agent' &&          message.responseToMessageId === scope.promptMessageId &&          message.runtimeSegmentId === scope.runtimeSegmentId      )    : undefined  if (input.messageId && !owner) {    throw new Error('Runtime Artifact owner Message is outside the supplied Session scope.')  }  owner ??= graph.messages.findLast(    (message) =>      message.agentFrameId === scope.agentFrameId &&      message.introducedOnBranchId === scope.messageBranchId &&      message.role === 'agent' &&      message.responseToMessageId === scope.promptMessageId &&      message.runtimeSegmentId === scope.runtimeSegmentId &&      (message.streamId === input.runId || !input.messageId)  )  if (!owner) {    const id = createRuntimeAgentMessageId(session.id, input.runId, scope.promptMessageId)    owner = {      id,      role: 'agent',      content: '',      status: 'complete',      streamId: input.runId,      responseToMessageId: scope.promptMessageId,      eventIds: [],      createdAt: input.timestamp,      completedAt: input.timestamp,      updatedAt: input.timestamp,      agentFrameId: scope.agentFrameId,      introducedOnBranchId: scope.messageBranchId,      parentMessageId: branch.headMessageId,      runtimeSegmentId: scope.runtimeSegmentId    }    graph.messages.push(owner)    branch.headMessageId = id  }  const artifacts = input.artifacts.map((artifact) =>    persistedArtifact(artifact, owner?.createdAt ?? input.timestamp)  )  owner.eventIds = appendUnique(owner.eventIds, [input.eventId])  owner.artifactIds = appendUnique(    owner.artifactIds,    artifacts.map(({ id }) => id)  )  owner.updatedAt = Math.max(owner.updatedAt, input.timestamp)  branch.updatedAt = Math.max(branch.updatedAt, input.timestamp)  const byId = new Map((session.artifacts ?? []).map((artifact) => [artifact.id, artifact]))  let artifactsChanged = false  for (const artifact of artifacts) {    const current = byId.get(artifact.id)    if (!current || !artifactsEqual(current, artifact)) artifactsChanged = true    byId.set(artifact.id, artifact)  }  session.artifacts = [...byId.values()]  if (artifactsChanged) session.filesRevision = (session.filesRevision ?? 0) + 1  session.updatedAt = Math.max(session.updatedAt, input.timestamp)  validateConversationGraph(graph)  return { session: synchronizeFlatProjection(session), messageId: owner.id }} export const applyRuntimeSessionEvents = (  inputSession: PersistedChatSession,  scope: RuntimeSessionScope,  events: readonly AcpRuntimeEvent[]): PersistedChatSession => {  let session = structuredClone(inputSession)  scopedPath(session, scope)  const groupControls = new Set(    session      .conversationGraph!.activityGroups.filter(        (group) =>          group.agentFrameId === scope.agentFrameId &&          group.messageBranchId === scope.messageBranchId      )      .map(({ id }) => id)  )  for (const event of events) {    if (event.sessionId && event.sessionId !== session.id) continue    // A cancelled provider turn cannot regain transcript ownership through queued chunks. Native    // artifact publication remains admissible because finalization may complete after cancellation.    if (      event.kind !== 'artifact' &&      session.resumeRecovery?.cause === 'cancelled' &&      session.resumeRecovery.promptMessageId === scope.promptMessageId    ) {      continue    }    const graph = session.conversationGraph!    const branch = graph.branches.find(({ id }) => id === scope.messageBranchId)!    if (event.kind === 'artifact') {      const attached = attachRuntimeSessionArtifacts(session, scope, {        messageId: event.messageId,        eventId: event.id,        runId: event.runId,        artifacts: event.artifacts,        timestamp: event.timestamp      })      session = attached.session      continue    }    if (event.kind === 'message' && event.role === 'assistant') {      const text = getAcpRuntimeEventText(event) ?? ''      let image = getAcpRuntimeEventImage(event)      if (!text && !image) continue      completeGroups(graph.activityGroups, scope, event.timestamp)      const streamId = event.messageId ?? event.id      const id = createRuntimeAgentMessageId(session.id, streamId, scope.promptMessageId)      let message = graph.messages.find(({ id: candidate }) => candidate === id)      if (message?.eventIds.includes(event.id)) continue      if (message?.status === 'complete' && event.timestamp <= message.updatedAt) {        continue      }      if (image) {        const messageImages = message?.images ?? []        const messageBytes = messageImages.reduce(          (total, candidate) => total + candidate.byteLength,          0        )        const sessionBytes = graph.messages.reduce(          (total, candidate) =>            total + (candidate.images ?? []).reduce((sum, item) => sum + item.byteLength, 0),          0        )        if (          messageImages.length >= MAX_ACP_MESSAGE_IMAGES_PER_MESSAGE ||          messageBytes + image.byteLength > MAX_ACP_MESSAGE_IMAGE_BYTES_PER_MESSAGE ||          sessionBytes + image.byteLength > MAX_ACP_SESSION_IMAGE_BYTES        ) {          image = undefined          if (!text) continue        }      }      if (!message) {        message = {          id,          role: 'agent',          content: '',          status: 'streaming',          streamId,          responseToMessageId: scope.promptMessageId,          eventIds: [],          createdAt: event.timestamp,          updatedAt: event.timestamp,          agentFrameId: scope.agentFrameId,          introducedOnBranchId: scope.messageBranchId,          parentMessageId: branch.headMessageId,          runtimeSegmentId: scope.runtimeSegmentId        }        graph.messages.push(message)        branch.headMessageId = id      }      const mergedContent = `${message.content}${text}`      message.content =        session.agentFrameworkId === 'claude-code'          ? normalizeClaudeCodeRefusalText(mergedContent)          : mergedContent      if (image) message.images = [...(message.images ?? []), { id: event.id, ...image }]      message.eventIds.push(event.id)      message.updatedAt = Math.max(message.updatedAt, event.timestamp)      branch.updatedAt = Math.max(branch.updatedAt, event.timestamp)    } else if ((event.kind === 'tool' || event.kind === 'compaction') && event.toolCallId) {      if (event.kind === 'compaction') completeGroups(graph.activityGroups, scope, event.timestamp)      if (        event.kind === 'tool' &&        (isActivityGroupToolEvent(event) || groupControls.has(event.toolCallId))      ) {        groupControls.add(event.toolCallId)        const title = getActivityGroupTitleFromToolEvent(event)        if (title && !graph.activityGroups.some(({ id }) => id === event.toolCallId)) {          completeGroups(graph.activityGroups, scope, event.timestamp)          graph.activityGroups.push({            id: event.toolCallId,            title,            sortIndex: event.timestamp,            activityIds: [],            promptMessageId: scope.promptMessageId,            createdAt: event.timestamp,            updatedAt: event.timestamp,            agentFrameId: scope.agentFrameId,            messageBranchId: scope.messageBranchId          })        }        continue      }      let activity = graph.activities.find(        ({ id, agentFrameId, messageBranchId }) =>          id === event.toolCallId &&          agentFrameId === scope.agentFrameId &&          messageBranchId === scope.messageBranchId      )      if (activity?.eventIds.includes(event.id)) continue      const status = toolStatus(event.status === 'cancelled' ? 'completed' : event.status)      if (!activity) {        const group = graph.activityGroups.findLast(          (candidate) =>            candidate.agentFrameId === scope.agentFrameId &&            candidate.messageBranchId === scope.messageBranchId &&            candidate.promptMessageId === scope.promptMessageId &&            candidate.completedAt === undefined        )        activity = {

Lines 1–500 of 550.