fix(background-agent): track session.next activity

Convert OpenCode v2 session.next stream events into the existing message part activity path so child sessions that are still producing text, reasoning, or tool output refresh lastUpdate before stale polling runs.

This keeps the timeout poller from cancelling active subagents and preserves tool-call progress for session.next.tool.called events.

Plan: .omo/plans/subagent-timeout-active-output.md
This commit is contained in:
YeonGyu-Kim
2026-05-21 15:23:26 +09:00
parent 94d6d5b495
commit b68af25e41
3 changed files with 293 additions and 65 deletions
@@ -0,0 +1,137 @@
import { isRecord } from "../../shared"
export const SESSION_NEXT_EVENT_PREFIX = "session.next."
export interface MessagePartInfo {
readonly id: string | undefined
readonly sessionID: string | undefined
readonly type: string | undefined
readonly tool: string | undefined
readonly input: Record<string, unknown> | undefined
readonly state: {
readonly status: string | undefined
readonly input: Record<string, unknown> | undefined
} | undefined
readonly field: string | undefined
readonly activityTime: Date | undefined
}
function getStringField(record: Record<string, unknown> | undefined, key: string): string | undefined {
const value = record?.[key]
return typeof value === "string" && value.length > 0 ? value : undefined
}
function getRecordField(record: Record<string, unknown> | undefined, key: string): Record<string, unknown> | undefined {
const value = record?.[key]
return isRecord(value) ? value : undefined
}
function getDateField(record: Record<string, unknown> | undefined, key: string): Date | undefined {
const value = record?.[key]
if (value instanceof Date) return Number.isFinite(value.getTime()) ? value : undefined
if (typeof value === "number" && Number.isFinite(value)) return new Date(value)
if (typeof value !== "string") return undefined
const parsed = new Date(value)
return Number.isFinite(parsed.getTime()) ? parsed : undefined
}
function resolveState(record: Record<string, unknown> | undefined): MessagePartInfo["state"] {
const state = getRecordField(record, "state")
if (!state) return undefined
return {
status: getStringField(state, "status"),
input: getRecordField(state, "input"),
}
}
function buildPartInfo(
source: Record<string, unknown>,
fallback: Record<string, unknown> | undefined,
): MessagePartInfo {
return {
id: getStringField(source, "id") ?? getStringField(source, "callID"),
sessionID: getStringField(source, "sessionID") ?? getStringField(fallback, "sessionID"),
type: getStringField(source, "type") ?? getStringField(fallback, "type"),
tool: getStringField(source, "tool") ?? getStringField(fallback, "tool"),
input: getRecordField(source, "input") ?? getRecordField(fallback, "input"),
state: resolveState(source) ?? resolveState(fallback),
field: getStringField(source, "field") ?? getStringField(fallback, "field"),
activityTime: getDateField(source, "activityTime")
?? getDateField(source, "timestamp")
?? getDateField(fallback, "activityTime")
?? getDateField(fallback, "timestamp"),
}
}
export function resolveMessagePartInfo(properties: unknown): MessagePartInfo | undefined {
const props = isRecord(properties) ? properties : undefined
if (!props) return undefined
const nestedPart = getRecordField(props, "part")
return nestedPart ? buildPartInfo(nestedPart, props) : buildPartInfo(props, undefined)
}
function sessionNextType(eventType: string): string {
if (eventType.startsWith("session.next.reasoning.")) return "reasoning"
if (eventType.startsWith("session.next.tool.") && eventType !== "session.next.tool.called") return "tool_result"
return "text"
}
function isTrackedSessionNextActivityEvent(eventType: string): boolean {
return eventType === "session.next.synthetic"
|| eventType === "session.next.retried"
|| eventType.startsWith("session.next.shell.")
|| eventType.startsWith("session.next.step.")
|| eventType.startsWith("session.next.text.")
|| eventType.startsWith("session.next.reasoning.")
|| eventType.startsWith("session.next.tool.")
|| eventType.startsWith("session.next.compaction.")
}
export function resolveSessionNextPartInfo(eventType: string, properties: unknown): MessagePartInfo | undefined {
if (!eventType.startsWith(SESSION_NEXT_EVENT_PREFIX)) return undefined
if (!isTrackedSessionNextActivityEvent(eventType)) return undefined
const props = isRecord(properties) ? properties : undefined
const sessionID = getStringField(props, "sessionID")
if (!props || !sessionID) return undefined
const input = getRecordField(props, "input")
if (eventType === "session.next.tool.called") {
return {
id: getStringField(props, "callID"),
sessionID,
type: "tool",
tool: getStringField(props, "tool"),
input,
state: {
status: "running",
input,
},
field: undefined,
activityTime: getDateField(props, "timestamp"),
}
}
return {
id: getStringField(props, "callID"),
sessionID,
type: sessionNextType(eventType),
tool: undefined,
input: undefined,
state: undefined,
field: eventType.endsWith(".delta") ? sessionNextType(eventType) : undefined,
activityTime: getDateField(props, "timestamp"),
}
}
export function hasOutputSignalFromPart(partInfo: MessagePartInfo | undefined, sessionID?: string): boolean {
if (!partInfo) return false
if (!partInfo.sessionID && !sessionID) return false
if (partInfo.tool) return true
if (partInfo.type === "tool" || partInfo.type === "tool_result") return true
if (partInfo.type === "text" || partInfo.type === "reasoning") return true
return partInfo.field === "text" || partInfo.field === "reasoning"
}