Files
hapi/shared/src/sessionSummary.ts
T
AnanovoandGitHub 3fc15e92cf fix(dsh): resume DSH ACP sessions after HAPI restart (#1926)
* fix(dsh): resume DSH ACP sessions after HAPI restart

* fix(dsh): register active session handoff

* fix(dsh): preserve native id in summaries

* fix(dsh): keep native resume on recorded machine

* test: refresh CI checks
2026-11-02 18:24:08 +08:00

273 lines
9.5 KiB
TypeScript

import type { AgentState, Metadata, Session, TodoItem, WorktreeMetadata } from './schemas'
import type { SessionReadState } from './sessionReadState'
import type { ConversationHistoryCapabilities } from './conversationHistory'
import { isKnownFlavor } from './flavors'
import type { AgentFlavor } from './modes'
export type ForkSessionSummary = {
text: string
updatedAt: number
}
/** Seed a fork's generated/native title without claiming manual-name ownership. */
export function buildForkSessionSummary(
metadata: { name?: string; summary?: { text?: string } | null } | null | undefined,
updatedAt: number = Date.now()
): ForkSessionSummary | undefined {
const sourceTitle = metadata?.name?.trim() || metadata?.summary?.text?.trim()
if (!sourceTitle) return undefined
return {
text: `Fork: ${sourceTitle}`,
updatedAt
}
}
export type PendingRequestKind = 'permission' | 'input'
const INPUT_REQUEST_TOOLS = new Set([
'AskUserQuestion',
'ask_user_question',
'ExitPlanMode',
'exit_plan_mode',
'request_user_input'
])
/** Cap on `pendingRequests` carried in `SessionSummary`. The list is meant for
* per-row hover copy ("Approve `Bash`, `Edit` (+1 more)"); deep inspection
* should use `Session.agentState.requests`. The `pendingRequestsCount` field
* is the authoritative total — `pendingRequests.length` may be smaller. */
export const PENDING_REQUEST_SUMMARY_CAP = 5
export type PendingRequest = {
id: string
kind: PendingRequestKind
tool: string
/** Epoch ms when the request was raised; falls back to the caller-supplied
* `fallbackSince` (typically `session.updatedAt`) for older requests
* stored without `createdAt`. */
since: number
}
/** Whether a request asks the user to answer something (a question, a plan)
* or to allow a tool. */
export function classifyKind(tool: string): PendingRequestKind {
return INPUT_REQUEST_TOOLS.has(tool) ? 'input' : 'permission'
}
export type SessionSummaryMetadata = {
name?: string
titleLocked?: boolean
path: string
machineId?: string
summary?: { text: string }
promptTitle?: string
flavor?: string | null
worktree?: WorktreeMetadata
agentSessionId?: string
lifecycleState?: string
conversationHistory?: ConversationHistoryCapabilities
/** Loopback MCP URL when session CLI happy server is running (#956). */
hapiMcpUrl?: string
}
export type SessionSummary = {
contentRevision?: number
readState?: SessionReadState
hasConversationContent?: boolean
id: string
active: boolean
thinking: boolean
activeAt: number
updatedAt: number
/** Creation time is the stable order key before the first user message. */
createdAt?: number
/** Original timestamp of the latest user message; absent before any input. */
lastUserMessageAt?: number
pinned?: boolean
globalPinned?: boolean
metadata: SessionSummaryMetadata | null
/** Watermarks for structured SSE patches (PR #897). List cache must gate
* without requiring a detail query — otherwise global SSE forces O(N)
* /sessions invalidation for every versioned write. */
metadataVersion: number
agentStateVersion: number
todosUpdatedAt: number
todoProgress: { completed: number; total: number } | null
pendingRequestsCount: number
pendingRequestKinds: PendingRequestKind[]
/** Capped, oldest-first slice of pending tool requests. Use this for tooltip
* / per-row UX. The full count (which may exceed the cap) is in
* `pendingRequestsCount`. */
pendingRequests: PendingRequest[]
backgroundTaskCount: number
futureScheduledMessageCount: number
/** Epoch ms of the soonest uninvoked future scheduled message, or null. */
nextScheduledAt: number | null
model: string | null
modelReasoningEffort?: string | null
effort: string | null
}
// Re-exported as a standalone derivation so SSE patch handlers can recompute
// summary fields from a structured `agentState` patch without needing the
// full Session in hand.
export function computePendingRequestKinds(agentState: AgentState | null | undefined): PendingRequestKind[] {
const requests = agentState?.requests
if (!requests) {
return []
}
const kinds = new Set<PendingRequestKind>()
for (const request of Object.values(requests)) {
kinds.add(classifyKind(request.tool))
}
return kinds.has('permission') && kinds.has('input')
? ['permission', 'input']
: Array.from(kinds)
}
export function computePendingRequests(
agentState: AgentState | null | undefined,
fallbackSince: number,
cap: number = PENDING_REQUEST_SUMMARY_CAP
): PendingRequest[] {
const requests = agentState?.requests
if (!requests) {
return []
}
const items: PendingRequest[] = []
for (const [id, request] of Object.entries(requests)) {
items.push({
id,
kind: classifyKind(request.tool),
tool: request.tool,
since: typeof request.createdAt === 'number' ? request.createdAt : fallbackSince
})
}
items.sort((a, b) => {
if (a.since !== b.since) return a.since - b.since
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
})
return cap >= items.length ? items : items.slice(0, cap)
}
export function getPendingRequestKinds(session: Session): PendingRequestKind[] {
return computePendingRequestKinds(session.agentState)
}
export function getPendingRequests(
session: Session,
cap: number = PENDING_REQUEST_SUMMARY_CAP
): PendingRequest[] {
return computePendingRequests(session.agentState, session.updatedAt, cap)
}
export function computePendingRequestsCount(agentState: AgentState | null | undefined): number {
return agentState?.requests ? Object.keys(agentState.requests).length : 0
}
export function computeTodoProgress(todos: TodoItem[] | undefined): SessionSummary['todoProgress'] {
if (!todos?.length) {
return null
}
return {
completed: todos.filter((todo) => todo.status === 'completed').length,
total: todos.length
}
}
const AGENT_SESSION_ID_FIELD_BY_FLAVOR: Partial<Record<AgentFlavor, keyof Metadata>> = {
claude: 'claudeSessionId',
codex: 'codexSessionId',
gemini: 'geminiSessionId',
opencode: 'opencodeSessionId',
grok: 'grokSessionId',
agy: 'agySessionId',
cursor: 'cursorSessionId',
kimi: 'kimiSessionId',
copilot: 'copilotSessionId',
pi: 'piSessionId',
dsh: 'dshSessionId'
}
function getSummaryAgentSessionId(metadata: Metadata): string | undefined {
const flavor = metadata.flavor
if (isKnownFlavor(flavor)) {
const flavorField = AGENT_SESSION_ID_FIELD_BY_FLAVOR[flavor]
if (!flavorField) return undefined
const flavorSessionId = metadata[flavorField]
return typeof flavorSessionId === 'string' && flavorSessionId.trim()
? flavorSessionId.trim()
: undefined
}
// Legacy fallback only applies when the stored flavor is missing or unknown.
return metadata.codexSessionId
?? metadata.claudeSessionId
?? metadata.geminiSessionId
?? metadata.opencodeSessionId
?? metadata.grokSessionId
?? metadata.agySessionId
?? metadata.cursorSessionId
?? metadata.kimiSessionId
?? metadata.copilotSessionId
?? metadata.dshSessionId
?? undefined
}
export function toSessionSummaryMetadata(metadata: Metadata | null | undefined): SessionSummaryMetadata | null {
if (!metadata) {
return null
}
return {
name: metadata.name,
titleLocked: metadata.titleLocked,
path: metadata.path,
machineId: metadata.machineId ?? undefined,
summary: metadata.summary ? { text: metadata.summary.text } : undefined,
promptTitle: metadata.promptTitle,
flavor: metadata.flavor ?? null,
worktree: metadata.worktree,
agentSessionId: getSummaryAgentSessionId(metadata),
lifecycleState: metadata.lifecycleState,
conversationHistory: metadata.capabilities?.conversationHistory,
hapiMcpUrl: metadata.hapiMcpUrl ?? undefined
}
}
export function toSessionSummary(session: Session): SessionSummary {
return {
hasConversationContent: session.hasConversationContent ?? false,
id: session.id,
active: session.active,
thinking: session.thinking,
activeAt: session.activeAt,
updatedAt: session.updatedAt,
contentRevision: session.contentRevision,
readState: session.readState,
createdAt: session.createdAt,
lastUserMessageAt: session.lastUserMessageAt,
pinned: session.pinned ?? false,
globalPinned: session.globalPinned ?? false,
metadata: toSessionSummaryMetadata(session.metadata),
metadataVersion: session.metadataVersion,
agentStateVersion: session.agentStateVersion,
todosUpdatedAt: session.todosUpdatedAt ?? 0,
todoProgress: computeTodoProgress(session.todos),
pendingRequestsCount: computePendingRequestsCount(session.agentState),
pendingRequestKinds: computePendingRequestKinds(session.agentState),
pendingRequests: computePendingRequests(session.agentState, session.updatedAt),
backgroundTaskCount: session.backgroundTaskCount ?? 0,
futureScheduledMessageCount: 0,
nextScheduledAt: null,
model: session.model,
modelReasoningEffort: session.modelReasoningEffort,
effort: session.effort
}
}