Merge pull request #70 from y574444354/feat/server-refactor-90331

feat(server): support reading subagent transcripts
This commit is contained in:
DoSun 2026-05-12 19:14:04 +08:00 committed by GitHub
commit daa2dce7b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 169 additions and 52 deletions

View File

@ -88,6 +88,10 @@ export function routeMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
ctx.emitEvent('stream_event', msg)
break
}
case 'system': {
ctx.emitEvent('message', msg)
break
}
default: {
break
}

View File

@ -112,6 +112,142 @@ function parseEntry(line: string): TranscriptEntry | null {
return parsed as TranscriptEntry
}
function entryToSessionMessage(
entry: TranscriptEntry,
includeSystem: boolean,
): SessionMessage | null {
if (!entry.type || !entry.uuid) return null
if (SKIP_TYPES.has(entry.type)) return null
if (entry.type === 'attribution-snapshot') return null
const includeThis =
MESSAGE_TYPES.has(entry.type) ||
(includeSystem && entry.type === 'system')
if (!includeThis) return null
const role = entry.role ?? entry.message?.role ?? entry.type
const content = entry.message?.content ?? entry.content ?? ''
const timestamp = entry.timestamp
? new Date(entry.timestamp).getTime()
: 0
return {
uuid: entry.uuid,
type: entry.type,
role: role ?? entry.type,
content,
timestamp,
parent_uuid: entry.parentUuid ?? null,
usage: entry.usage as
| { input_tokens: number; output_tokens: number }
| undefined,
}
}
function readMessagesFromLines(
lines: string[],
includeSystem: boolean,
): SessionMessage[] {
const messages: SessionMessage[] = []
for (const line of lines) {
if (!line) continue
const entry = parseEntry(line)
if (!entry) continue
const msg = entryToSessionMessage(entry, includeSystem)
if (msg) messages.push(msg)
}
return messages
}
/**
* Locate the subagent transcript file for a given agentId by scanning
* `<projectsDir>/<project>/<parentSessionId>/subagents/agent-<agentId>.jsonl`.
*
* `cwd` is used to narrow the search to a specific project dir; if omitted or
* not matched, we fall back to scanning every project directory.
*/
async function findSubagentTranscriptPath(
agentId: string,
cwd?: string,
): Promise<string | null> {
const fileName = `agent-${agentId}.jsonl`
const searchProject = async (projectDir: string): Promise<string | null> => {
let sessionDirs: string[]
try {
sessionDirs = await readdir(projectDir)
} catch {
return null
}
for (const sessionDir of sessionDirs) {
const direct = join(projectDir, sessionDir, 'subagents', fileName)
try {
const s = await stat(direct)
if (s.isFile() && s.size > 0) return direct
} catch {
}
const nestedSubagentsDir = join(projectDir, sessionDir, 'subagents')
let subdirs: string[]
try {
subdirs = await readdir(nestedSubagentsDir)
} catch {
continue
}
for (const sub of subdirs) {
const candidate = join(nestedSubagentsDir, sub, fileName)
try {
const s = await stat(candidate)
if (s.isFile() && s.size > 0) return candidate
} catch {
}
}
}
return null
}
if (cwd) {
const projectDir = getProjectDir(cwd)
const hit = await searchProject(projectDir)
if (hit) return hit
const found = await findProjectDir(cwd)
if (found && found !== projectDir) {
const hit2 = await searchProject(found)
if (hit2) return hit2
}
}
const projectsDir = getProjectsDir()
let dirs: string[]
try {
dirs = await readdir(projectsDir)
} catch {
return null
}
for (const dir of dirs) {
const hit = await searchProject(join(projectsDir, dir))
if (hit) return hit
}
return null
}
async function readSubagentMessages(
agentId: string,
cwd: string | undefined,
includeSystem: boolean,
): Promise<SessionMessage[]> {
const path = await findSubagentTranscriptPath(agentId, cwd)
if (!path) return []
try {
const raw = await readFile(path, 'utf-8')
const lines = raw.split('\n')
return readMessagesFromLines(lines, includeSystem)
} catch {
return []
}
}
export async function readSessionMessages(opts: {
sessionId: string
cwd?: string
@ -122,66 +258,43 @@ export async function readSessionMessages(opts: {
messages: SessionMessage[]
nextCursor?: string
}> {
const includeSystem = opts.includeSystem ?? false
const path = await resolveTranscriptPath(opts.sessionId, opts.cwd)
if (!path || !existsSync(path)) {
return { messages: [] }
}
const raw = await readFile(path, 'utf-8')
const lines = raw.split('\n').filter(Boolean)
let mainMessages: SessionMessage[] = []
if (path && existsSync(path)) {
const raw = await readFile(path, 'utf-8')
const lines = raw.split('\n').filter(Boolean)
let lastCompactBoundaryIndex = -1
for (let i = lines.length - 1; i >= 0; i--) {
const entry = parseEntry(lines[i])
if (
entry?.type === 'system' &&
entry?.subtype === 'compact_boundary' &&
!entry?.compactMetadata?.preservedSegment
) {
lastCompactBoundaryIndex = i
break
let lastCompactBoundaryIndex = -1
for (let i = lines.length - 1; i >= 0; i--) {
const entry = parseEntry(lines[i])
if (
entry?.type === 'system' &&
entry?.subtype === 'compact_boundary' &&
!entry?.compactMetadata?.preservedSegment
) {
lastCompactBoundaryIndex = i
break
}
}
const startLine = lastCompactBoundaryIndex >= 0 ? lastCompactBoundaryIndex + 1 : 0
mainMessages = readMessagesFromLines(lines.slice(startLine), includeSystem)
} else {
// Fallback: 视 sessionId 为 agentId读取 subagent transcript。
mainMessages = await readSubagentMessages(opts.sessionId, opts.cwd, includeSystem)
if (mainMessages.length === 0) {
return { messages: [] }
}
}
const startLine = lastCompactBoundaryIndex >= 0 ? lastCompactBoundaryIndex + 1 : 0
const allMessages = mainMessages
allMessages.sort((a, b) => a.timestamp - b.timestamp)
const allMessages: SessionMessage[] = []
let cursorIndex = -1
for (let i = startLine; i < lines.length; i++) {
const entry = parseEntry(lines[i])
if (!entry) continue
if (!entry.type || !entry.uuid) continue
if (SKIP_TYPES.has(entry.type)) continue
if (entry.type === 'attribution-snapshot') continue
const includeThis =
MESSAGE_TYPES.has(entry.type) ||
(opts.includeSystem && entry.type === 'system')
if (!includeThis) continue
if (entry.uuid === opts.before) {
cursorIndex = allMessages.length
}
const role = entry.role ?? entry.message?.role ?? entry.type
const content = entry.message?.content ?? entry.content ?? ''
const timestamp = entry.timestamp
? new Date(entry.timestamp).getTime()
: 0
allMessages.push({
uuid: entry.uuid,
type: entry.type,
role: role ?? entry.type,
content,
timestamp,
parent_uuid: entry.parentUuid ?? null,
usage: entry.usage as
| { input_tokens: number; output_tokens: number }
| undefined,
})
if (opts.before) {
cursorIndex = allMessages.findIndex(m => m.uuid === opts.before)
}
let sliced: SessionMessage[]