mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-14 00:10:03 +00:00
715 lines
18 KiB
TypeScript
715 lines
18 KiB
TypeScript
import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
|
import * as Locale from "../../../util/locale"
|
|
import {
|
|
bootstrapSessionData,
|
|
createSessionData,
|
|
formatError,
|
|
reduceSessionData,
|
|
type SessionData,
|
|
} from "./session-data"
|
|
import type { FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
|
|
|
|
export const SUBAGENT_BOOTSTRAP_LIMIT = 200
|
|
export const SUBAGENT_CALL_BOOTSTRAP_LIMIT = 80
|
|
|
|
const SUBAGENT_COMMIT_LIMIT = 80
|
|
const SUBAGENT_CALL_LIMIT = 32
|
|
const SUBAGENT_ROLE_LIMIT = 32
|
|
const SUBAGENT_ERROR_LIMIT = 16
|
|
const SUBAGENT_ECHO_LIMIT = 8
|
|
|
|
type SessionMessage = {
|
|
parts: Part[]
|
|
}
|
|
|
|
type Frame = {
|
|
key: string
|
|
commit: StreamCommit
|
|
}
|
|
|
|
type DetailState = {
|
|
sessionID: string
|
|
data: SessionData
|
|
frames: Frame[]
|
|
}
|
|
|
|
export type SubagentData = {
|
|
tabs: Map<string, FooterSubagentTab>
|
|
details: Map<string, DetailState>
|
|
}
|
|
|
|
export type BootstrapSubagentInput = {
|
|
data: SubagentData
|
|
messages: SessionMessage[]
|
|
children: Array<{ id: string; title?: string }>
|
|
permissions: PermissionRequest[]
|
|
questions: QuestionRequest[]
|
|
}
|
|
|
|
function createDetail(sessionID: string): DetailState {
|
|
return {
|
|
sessionID,
|
|
data: createSessionData({
|
|
includeUserText: true,
|
|
}),
|
|
frames: [],
|
|
}
|
|
}
|
|
|
|
function ensureDetail(data: SubagentData, sessionID: string) {
|
|
const current = data.details.get(sessionID)
|
|
if (current) {
|
|
return current
|
|
}
|
|
|
|
const next = createDetail(sessionID)
|
|
data.details.set(sessionID, next)
|
|
return next
|
|
}
|
|
|
|
function sameTab(a: FooterSubagentTab | undefined, b: FooterSubagentTab) {
|
|
if (!a) {
|
|
return false
|
|
}
|
|
|
|
return (
|
|
a.sessionID === b.sessionID &&
|
|
a.partID === b.partID &&
|
|
a.callID === b.callID &&
|
|
a.label === b.label &&
|
|
a.description === b.description &&
|
|
a.status === b.status &&
|
|
a.title === b.title &&
|
|
a.toolCalls === b.toolCalls &&
|
|
a.lastUpdatedAt === b.lastUpdatedAt
|
|
)
|
|
}
|
|
|
|
function sameQueue<T extends { id: string }>(left: T[], right: T[]) {
|
|
return (
|
|
left.length === right.length && left.every((item, index) => item.id === right[index]?.id && item === right[index])
|
|
)
|
|
}
|
|
|
|
function sameCommit(left: StreamCommit, right: StreamCommit) {
|
|
return (
|
|
left.kind === right.kind &&
|
|
left.text === right.text &&
|
|
left.phase === right.phase &&
|
|
left.source === right.source &&
|
|
left.messageID === right.messageID &&
|
|
left.partID === right.partID &&
|
|
left.tool === right.tool &&
|
|
left.interrupted === right.interrupted &&
|
|
left.toolState === right.toolState &&
|
|
left.toolError === right.toolError
|
|
)
|
|
}
|
|
|
|
function text(value: unknown) {
|
|
if (typeof value !== "string") {
|
|
return
|
|
}
|
|
|
|
const next = value.trim()
|
|
return next || undefined
|
|
}
|
|
|
|
function num(value: unknown) {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return value
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
function inputLabel(input: Record<string, unknown>) {
|
|
const description = text(input.description)
|
|
if (description) {
|
|
return description
|
|
}
|
|
|
|
const command = text(input.command)
|
|
if (command) {
|
|
return command
|
|
}
|
|
|
|
const filePath = text(input.filePath) ?? text(input.filepath)
|
|
if (filePath) {
|
|
return filePath
|
|
}
|
|
|
|
const pattern = text(input.pattern)
|
|
if (pattern) {
|
|
return pattern
|
|
}
|
|
|
|
const query = text(input.query)
|
|
if (query) {
|
|
return query
|
|
}
|
|
|
|
const url = text(input.url)
|
|
if (url) {
|
|
return url
|
|
}
|
|
|
|
const path = text(input.path)
|
|
if (path) {
|
|
return path
|
|
}
|
|
|
|
const prompt = text(input.prompt)
|
|
if (prompt) {
|
|
return prompt
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
function stateTitle(part: ToolPart) {
|
|
return text("title" in part.state ? part.state.title : undefined)
|
|
}
|
|
|
|
function callKey(messageID: string | undefined, callID: string | undefined) {
|
|
if (!messageID || !callID) {
|
|
return
|
|
}
|
|
|
|
return `${messageID}:${callID}`
|
|
}
|
|
|
|
function recent<T>(input: Iterable<T>, limit: number) {
|
|
const list = [...input]
|
|
return list.slice(Math.max(0, list.length - limit))
|
|
}
|
|
|
|
function copyMap<K, V>(source: Map<K, V>, keep: Set<K>) {
|
|
const out = new Map<K, V>()
|
|
for (const [key, value] of source) {
|
|
if (!keep.has(key)) {
|
|
continue
|
|
}
|
|
|
|
out.set(key, value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
function compactToolPart(part: ToolPart): ToolPart {
|
|
return {
|
|
id: part.id,
|
|
type: "tool",
|
|
sessionID: part.sessionID,
|
|
messageID: part.messageID,
|
|
callID: part.callID,
|
|
tool: part.tool,
|
|
state: {
|
|
status: part.state.status,
|
|
input: part.state.input,
|
|
metadata: "metadata" in part.state ? part.state.metadata : undefined,
|
|
time: "time" in part.state ? part.state.time : undefined,
|
|
title: "title" in part.state ? part.state.title : undefined,
|
|
error: "error" in part.state ? part.state.error : undefined,
|
|
},
|
|
} as ToolPart
|
|
}
|
|
|
|
function compactCommit(commit: StreamCommit): StreamCommit {
|
|
if (!commit.part) {
|
|
return commit
|
|
}
|
|
|
|
return {
|
|
...commit,
|
|
part: compactToolPart(commit.part),
|
|
}
|
|
}
|
|
|
|
function stateUpdatedAt(part: ToolPart) {
|
|
if (!("time" in part.state)) {
|
|
return Date.now()
|
|
}
|
|
|
|
const time = part.state.time
|
|
if (!("end" in time)) {
|
|
return time.start ?? Date.now()
|
|
}
|
|
|
|
return time.end ?? time.start ?? Date.now()
|
|
}
|
|
|
|
function metadata(part: ToolPart, key: string) {
|
|
return ("metadata" in part.state ? part.state.metadata?.[key] : undefined) ?? part.metadata?.[key]
|
|
}
|
|
|
|
function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab {
|
|
const label = Locale.titlecase(text(part.state.input.subagent_type) ?? "general")
|
|
const description = text(part.state.input.description) ?? stateTitle(part) ?? inputLabel(part.state.input) ?? ""
|
|
const status = part.state.status === "error" ? "error" : part.state.status === "completed" ? "completed" : "running"
|
|
|
|
return {
|
|
sessionID,
|
|
partID: part.id,
|
|
callID: part.callID,
|
|
label,
|
|
description,
|
|
status,
|
|
title: stateTitle(part),
|
|
toolCalls: num(metadata(part, "toolcalls")) ?? num(metadata(part, "toolCalls")) ?? num(metadata(part, "calls")),
|
|
lastUpdatedAt: stateUpdatedAt(part),
|
|
}
|
|
}
|
|
|
|
function taskSessionID(part: ToolPart) {
|
|
return text(metadata(part, "sessionId")) ?? text(metadata(part, "sessionID"))
|
|
}
|
|
|
|
function syncTaskTab(data: SubagentData, part: ToolPart, children?: Set<string>) {
|
|
if (part.tool !== "task") {
|
|
return false
|
|
}
|
|
|
|
const sessionID = taskSessionID(part)
|
|
if (!sessionID) {
|
|
return false
|
|
}
|
|
|
|
if (children && children.size > 0 && !children.has(sessionID)) {
|
|
return false
|
|
}
|
|
|
|
const next = taskTab(part, sessionID)
|
|
if (sameTab(data.tabs.get(sessionID), next)) {
|
|
ensureDetail(data, sessionID)
|
|
return false
|
|
}
|
|
|
|
data.tabs.set(sessionID, next)
|
|
ensureDetail(data, sessionID)
|
|
return true
|
|
}
|
|
|
|
function frameKey(commit: StreamCommit) {
|
|
if (commit.partID) {
|
|
return `${commit.kind}:${commit.partID}:${commit.phase}`
|
|
}
|
|
|
|
if (commit.messageID) {
|
|
return `${commit.kind}:${commit.messageID}:${commit.phase}`
|
|
}
|
|
|
|
return `${commit.kind}:${commit.phase}:${commit.text}`
|
|
}
|
|
|
|
function limitFrames(detail: DetailState) {
|
|
if (detail.frames.length <= SUBAGENT_COMMIT_LIMIT) {
|
|
return
|
|
}
|
|
|
|
detail.frames.splice(0, detail.frames.length - SUBAGENT_COMMIT_LIMIT)
|
|
}
|
|
|
|
function mergeLiveCommit(current: StreamCommit, next: StreamCommit) {
|
|
if (current.phase !== "progress" || next.phase !== "progress") {
|
|
if (sameCommit(current, next)) {
|
|
return current
|
|
}
|
|
|
|
return next
|
|
}
|
|
|
|
const merged = {
|
|
...current,
|
|
...next,
|
|
text: current.text + next.text,
|
|
}
|
|
|
|
if (sameCommit(current, merged)) {
|
|
return current
|
|
}
|
|
|
|
return merged
|
|
}
|
|
|
|
function appendCommits(detail: DetailState, commits: StreamCommit[]) {
|
|
let changed = false
|
|
|
|
for (const commit of commits.map(compactCommit)) {
|
|
const key = frameKey(commit)
|
|
const index = detail.frames.findIndex((item) => item.key === key)
|
|
if (index === -1) {
|
|
detail.frames.push({
|
|
key,
|
|
commit,
|
|
})
|
|
changed = true
|
|
continue
|
|
}
|
|
|
|
const next = mergeLiveCommit(detail.frames[index].commit, commit)
|
|
if (sameCommit(detail.frames[index].commit, next)) {
|
|
continue
|
|
}
|
|
|
|
detail.frames[index] = {
|
|
key,
|
|
commit: next,
|
|
}
|
|
changed = true
|
|
}
|
|
|
|
if (changed) {
|
|
limitFrames(detail)
|
|
}
|
|
|
|
return changed
|
|
}
|
|
|
|
function ensureBlockerTab(
|
|
data: SubagentData,
|
|
sessionID: string,
|
|
title: string | undefined,
|
|
kind: "permission" | "question",
|
|
) {
|
|
if (data.tabs.has(sessionID)) {
|
|
ensureDetail(data, sessionID)
|
|
return false
|
|
}
|
|
|
|
data.tabs.set(sessionID, {
|
|
sessionID,
|
|
partID: `bootstrap:${sessionID}`,
|
|
callID: `bootstrap:${sessionID}`,
|
|
label: text(title) ?? Locale.titlecase(kind),
|
|
description: kind === "permission" ? "Pending permission" : "Pending question",
|
|
status: "running",
|
|
lastUpdatedAt: Date.now(),
|
|
})
|
|
ensureDetail(data, sessionID)
|
|
return true
|
|
}
|
|
|
|
function compactCallMap(detail: DetailState) {
|
|
const keep = new Set(recent(detail.data.call.keys(), SUBAGENT_CALL_LIMIT))
|
|
|
|
for (const request of detail.data.permissions) {
|
|
const key = callKey(request.tool?.messageID, request.tool?.callID)
|
|
if (key) {
|
|
keep.add(key)
|
|
}
|
|
}
|
|
|
|
for (const item of detail.frames) {
|
|
const key = callKey(item.commit.part?.messageID, item.commit.part?.callID)
|
|
if (key) {
|
|
keep.add(key)
|
|
}
|
|
}
|
|
|
|
return copyMap(detail.data.call, keep)
|
|
}
|
|
|
|
function compactEchoMap(data: SessionData, messageIDs: Set<string>) {
|
|
const keys = new Set([...messageIDs, ...recent(data.echo.keys(), SUBAGENT_ECHO_LIMIT)])
|
|
return copyMap(data.echo, keys)
|
|
}
|
|
|
|
function compactIDs(detail: DetailState) {
|
|
return new Set(recent(detail.data.ids, SUBAGENT_COMMIT_LIMIT + SUBAGENT_ERROR_LIMIT))
|
|
}
|
|
|
|
function compactDetail(detail: DetailState) {
|
|
const next = createSessionData({
|
|
includeUserText: true,
|
|
})
|
|
const activePartIDs = new Set(detail.data.part.keys())
|
|
const framePartIDs = new Set(detail.frames.flatMap((item) => (item.commit.partID ? [item.commit.partID] : [])))
|
|
const partIDs = new Set([...activePartIDs, ...framePartIDs, ...detail.data.tools])
|
|
const messageIDs = new Set([
|
|
...[...activePartIDs]
|
|
.map((partID) => detail.data.msg.get(partID))
|
|
.filter((item): item is string => typeof item === "string"),
|
|
...recent(detail.data.role.keys(), SUBAGENT_ROLE_LIMIT),
|
|
])
|
|
|
|
next.announced = detail.data.announced
|
|
next.permissions = detail.data.permissions
|
|
next.questions = detail.data.questions
|
|
next.ids = compactIDs(detail)
|
|
next.tools = new Set([...detail.data.tools].filter((item) => partIDs.has(item)))
|
|
next.call = compactCallMap(detail)
|
|
next.role = copyMap(detail.data.role, messageIDs)
|
|
next.msg = copyMap(detail.data.msg, activePartIDs)
|
|
next.part = copyMap(detail.data.part, activePartIDs)
|
|
next.text = copyMap(detail.data.text, activePartIDs)
|
|
next.sent = copyMap(detail.data.sent, activePartIDs)
|
|
next.end = new Set([...detail.data.end].filter((item) => activePartIDs.has(item)))
|
|
next.echo = compactEchoMap(detail.data, messageIDs)
|
|
detail.data = next
|
|
}
|
|
|
|
function applyChildEvent(input: {
|
|
detail: DetailState
|
|
event: Event
|
|
thinking: boolean
|
|
limits: Record<string, number>
|
|
}) {
|
|
const beforePermissions = input.detail.data.permissions.slice()
|
|
const beforeQuestions = input.detail.data.questions.slice()
|
|
const out = reduceSessionData({
|
|
data: input.detail.data,
|
|
event: input.event,
|
|
sessionID: input.detail.sessionID,
|
|
thinking: input.thinking,
|
|
limits: input.limits,
|
|
})
|
|
const changed = appendCommits(input.detail, out.commits)
|
|
compactDetail(input.detail)
|
|
|
|
return (
|
|
changed ||
|
|
!sameQueue(beforePermissions, input.detail.data.permissions) ||
|
|
!sameQueue(beforeQuestions, input.detail.data.questions)
|
|
)
|
|
}
|
|
|
|
function knownSession(data: SubagentData, sessionID: string) {
|
|
return data.tabs.has(sessionID)
|
|
}
|
|
|
|
export function listSubagentPermissions(data: SubagentData) {
|
|
return [...data.details.values()].flatMap((detail) => detail.data.permissions)
|
|
}
|
|
|
|
export function listSubagentQuestions(data: SubagentData) {
|
|
return [...data.details.values()].flatMap((detail) => detail.data.questions)
|
|
}
|
|
|
|
export function createSubagentData(): SubagentData {
|
|
return {
|
|
tabs: new Map(),
|
|
details: new Map(),
|
|
}
|
|
}
|
|
|
|
function snapshotDetail(detail: DetailState) {
|
|
return {
|
|
sessionID: detail.sessionID,
|
|
commits: detail.frames.map((item) => item.commit),
|
|
}
|
|
}
|
|
|
|
export function listSubagentTabs(data: SubagentData) {
|
|
return [...data.tabs.values()].sort((a, b) => {
|
|
const active = Number(b.status === "running") - Number(a.status === "running")
|
|
if (active !== 0) {
|
|
return active
|
|
}
|
|
|
|
return b.lastUpdatedAt - a.lastUpdatedAt
|
|
})
|
|
}
|
|
|
|
function snapshotQueues(data: SubagentData) {
|
|
return {
|
|
permissions: listSubagentPermissions(data).sort((a, b) => a.id.localeCompare(b.id)),
|
|
questions: listSubagentQuestions(data).sort((a, b) => a.id.localeCompare(b.id)),
|
|
}
|
|
}
|
|
|
|
export function snapshotSubagentData(data: SubagentData): FooterSubagentState {
|
|
return {
|
|
tabs: listSubagentTabs(data),
|
|
details: Object.fromEntries(
|
|
[...data.details.entries()].map(([sessionID, detail]) => [sessionID, snapshotDetail(detail)]),
|
|
),
|
|
...snapshotQueues(data),
|
|
}
|
|
}
|
|
|
|
export function snapshotSelectedSubagentData(
|
|
data: SubagentData,
|
|
selectedSessionID: string | undefined,
|
|
): FooterSubagentState {
|
|
const detail = selectedSessionID ? data.details.get(selectedSessionID) : undefined
|
|
|
|
return {
|
|
tabs: listSubagentTabs(data),
|
|
details: detail ? { [detail.sessionID]: snapshotDetail(detail) } : {},
|
|
...snapshotQueues(data),
|
|
}
|
|
}
|
|
|
|
export function bootstrapSubagentData(input: BootstrapSubagentInput) {
|
|
const child = new Map(input.children.map((item) => [item.id, item]))
|
|
const children = new Set(child.keys())
|
|
let changed = false
|
|
|
|
for (const message of input.messages) {
|
|
for (const part of message.parts) {
|
|
if (part.type !== "tool") {
|
|
continue
|
|
}
|
|
|
|
changed = syncTaskTab(input.data, part, children) || changed
|
|
}
|
|
}
|
|
|
|
for (const item of input.permissions) {
|
|
if (!children.has(item.sessionID)) {
|
|
continue
|
|
}
|
|
|
|
changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "permission") || changed
|
|
}
|
|
|
|
for (const item of input.questions) {
|
|
if (!children.has(item.sessionID)) {
|
|
continue
|
|
}
|
|
|
|
changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "question") || changed
|
|
}
|
|
|
|
for (const sessionID of input.data.tabs.keys()) {
|
|
const detail = ensureDetail(input.data, sessionID)
|
|
const beforePermissions = detail.data.permissions.slice()
|
|
const beforeQuestions = detail.data.questions.slice()
|
|
|
|
bootstrapSessionData({
|
|
data: detail.data,
|
|
messages: [],
|
|
permissions: input.permissions
|
|
.filter((item) => item.sessionID === sessionID)
|
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
questions: input.questions
|
|
.filter((item) => item.sessionID === sessionID)
|
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
})
|
|
compactDetail(detail)
|
|
|
|
changed =
|
|
!sameQueue(beforePermissions, detail.data.permissions) ||
|
|
!sameQueue(beforeQuestions, detail.data.questions) ||
|
|
changed
|
|
}
|
|
|
|
return changed
|
|
}
|
|
|
|
export function bootstrapSubagentCalls(input: { data: SubagentData; sessionID: string; messages: SessionMessage[] }) {
|
|
if (!knownSession(input.data, input.sessionID) || input.messages.length === 0) {
|
|
return false
|
|
}
|
|
|
|
const detail = ensureDetail(input.data, input.sessionID)
|
|
const beforePermissions = detail.data.permissions.slice()
|
|
const beforeQuestions = detail.data.questions.slice()
|
|
const beforeCallCount = detail.data.call.size
|
|
bootstrapSessionData({
|
|
data: detail.data,
|
|
messages: input.messages,
|
|
permissions: detail.data.permissions,
|
|
questions: detail.data.questions,
|
|
})
|
|
compactDetail(detail)
|
|
|
|
return (
|
|
beforeCallCount !== detail.data.call.size ||
|
|
!sameQueue(beforePermissions, detail.data.permissions) ||
|
|
!sameQueue(beforeQuestions, detail.data.questions)
|
|
)
|
|
}
|
|
|
|
export function clearFinishedSubagents(data: SubagentData) {
|
|
let changed = false
|
|
|
|
for (const [sessionID, tab] of [...data.tabs.entries()]) {
|
|
if (tab.status === "running") {
|
|
continue
|
|
}
|
|
|
|
data.tabs.delete(sessionID)
|
|
data.details.delete(sessionID)
|
|
changed = true
|
|
}
|
|
|
|
return changed
|
|
}
|
|
|
|
export function reduceSubagentData(input: {
|
|
data: SubagentData
|
|
event: Event
|
|
sessionID: string
|
|
thinking: boolean
|
|
limits: Record<string, number>
|
|
}) {
|
|
const event = input.event
|
|
|
|
if (event.type === "message.part.updated") {
|
|
const part = event.properties.part
|
|
if (part.sessionID === input.sessionID) {
|
|
if (part.type !== "tool") {
|
|
return false
|
|
}
|
|
|
|
return syncTaskTab(input.data, part)
|
|
}
|
|
}
|
|
|
|
const sessionID =
|
|
event.type === "message.updated" ||
|
|
event.type === "message.part.delta" ||
|
|
event.type === "permission.asked" ||
|
|
event.type === "permission.replied" ||
|
|
event.type === "question.asked" ||
|
|
event.type === "question.replied" ||
|
|
event.type === "question.rejected" ||
|
|
event.type === "session.error" ||
|
|
event.type === "session.status"
|
|
? event.properties.sessionID
|
|
: event.type === "message.part.updated"
|
|
? event.properties.part.sessionID
|
|
: undefined
|
|
|
|
if (!sessionID || !knownSession(input.data, sessionID)) {
|
|
return false
|
|
}
|
|
|
|
const detail = ensureDetail(input.data, sessionID)
|
|
if (event.type === "session.status") {
|
|
if (event.properties.status.type !== "retry") {
|
|
return false
|
|
}
|
|
|
|
return appendCommits(detail, [
|
|
{
|
|
kind: "error",
|
|
text: event.properties.status.message,
|
|
phase: "start",
|
|
source: "system",
|
|
messageID: `retry:${event.properties.status.attempt}`,
|
|
},
|
|
])
|
|
}
|
|
|
|
if (event.type === "session.error" && event.properties.error) {
|
|
return appendCommits(detail, [
|
|
{
|
|
kind: "error",
|
|
text: formatError(event.properties.error),
|
|
phase: "start",
|
|
source: "system",
|
|
messageID: `session.error:${event.properties.sessionID}:${formatError(event.properties.error)}`,
|
|
},
|
|
])
|
|
}
|
|
|
|
return applyChildEvent({
|
|
detail,
|
|
event,
|
|
thinking: input.thinking,
|
|
limits: input.limits,
|
|
})
|
|
}
|