Files
apes-sidebar/src/subagent-activity.ts
T
Menghuan1918 ba0114749f feat(dsh): adapt to DSH 0.1.5-alpha.1
Move the plugin onto the DSH 0.1.5-alpha.1 baseline (peer floor
^0.1.5-alpha.1, CI pinned to the alpha dist-tag) and fix the two host
contract changes that alpha line introduced.

Baseline:
- all @deepseek-ai/dsh-* peer/dev deps -> 0.1.5-alpha.1; optional peer
  @deepseek-ai/dsh-client-ui-sidebar-right (declared, unused until the
  native-surface phase); devDep @deepseek-ai/dsh-agent-loop (inbox fold)
- dsh.plugin.json engines >=0.1.5-alpha.1; version 0.19.0-alpha.0;
  SIDEBAR_SERVICE_VERSION follows

Live assistant stream (assistant/chunk is gone):
- 0.1.5 publishes in-flight model deltas as process-local
  agent/assistant-stream frames instead of durable assistant/chunk events
  and embeds the settled stream in assistant/message / assistant/attempt.
  src/assistant-live.ts folds the frames into a bounded per-session buffer;
  sidechat.events serves the current attempt as `live` rows (replaced every
  poll, never a delta) and the transcript renders them; the inherited
  in-progress snapshot reads message content / attempt stream / live frames.

Cold session reads (persistence.inspect is gone):
- 0.1.5 reads a stored session through an explicit read handle
  (open(id,'read') -> handle.read() -> close()). src/session-store.ts owns
  that protocol; every cold read (sidechat transcript, cold-resume preset,
  session.cwd, changes replay) goes through it.

Dead code and docs:
- drop the remote.session.openWorkspacePath interception: 0.1.5's chat opens
  files through ctx.sidebarRight.openResource instead, so the wrapper had no
  caller and would hijack the host's "open in app" gesture; the
  interceptOpenPath pref, its settings row and its locale keys go with it
- AGENTS.md §3 + external-plugin-guide.md describe the new baseline
- vitest setup bridges Node's localStorage accessor to jsdom's store (the
  pre-existing 26 failures on Node >= 22)
- desktop-layout e2e seeds through host RPC and types into the Lexical
  composer (0.1.5 replaced the textarea) instead of driving the directory
  picker

Verified: pnpm typecheck / lint / build / test (124 files, 1327 tests) /
check:consumer-types, plus pnpm test:mount against a real
@deepseek-ai/dsh@0.1.5-alpha.1 host (18/18).
2026-09-09 11:15:30 +08:00

94 lines
4.1 KiB
TypeScript

/**
* Pure derivation of the compact LIVE line shown on a running subagent card:
* the last text output and the last tool call of the child's session events.
* The host batch route feeds raw session events into this parser; the client
* only receives the already-folded `LastActivity` map. Renders nothing
* itself — the SubagentView component turns this into the card's status
* lines. Kept framework-free so the parser is unit-testable in the node
* environment.
*/
import type { SidebarSessionEvent } from './context-types.ts'
/**
* Extract the concatenated plain text of a content-block list (the durable
* `ContentBlock[]` shape, structurally: blocks with `type: 'text'` carry
* `text`; anything else — tool_use, image, … — contributes nothing).
* @param content - the raw `content` field of a message event.
* @returns the joined text, or undefined when the message carries no text.
*/
export function contentText(content: unknown): string | undefined {
if (!Array.isArray(content)) return undefined
const parts: string[] = []
for (const block of content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as { type?: unknown; text?: unknown }
if (candidate.type === 'text' && typeof candidate.text === 'string') {
parts.push(candidate.text)
}
}
return parts.length > 0 ? parts.join('\n') : undefined
}
/** The live status of one subagent card (both fields optional). */
export interface LastActivity {
/** The latest assembled assistant text output in the tail. */
text?: string
/** The latest tool call in the tail. */
tool?: { name: string; args: string }
}
/**
* Fold a session event log into the last text output + last tool call (each
* is the LAST occurrence in event order). Lifecycle events and raw stream
* rows are ignored — the card shows what the subagent is doing right now,
* not its plumbing. (DSH 0.1.5 publishes the raw deltas as process-local
* frames instead of log events, so only the assembled `assistant/message`
* reaches this scan.) The scan runs BACKWARD from the newest
* event and stops once both fields are found, so a long history costs only
* the recent tail in the common case.
* @param events - the session's append-only event log (oldest → newest).
* @param maxMessages - optional message-boundary window: only the tail's
* last `maxMessages` surface messages (`user/message`, `assistant/message`)
* and the events between them are considered, mirroring the old
* `subagents.history({ maxMessages })` window. Stale activity older than
* the window is never surfaced, and a long log is never scanned in full.
* @returns the last text and/or tool call; an empty object when the log has neither.
*/
export function lastActivity(
events: readonly SidebarSessionEvent[],
maxMessages = Infinity,
): LastActivity {
let text: string | undefined
let tool: { name: string; args: string } | undefined
let messagesSeen = 0
for (let index = events.length - 1; index >= 0; index -= 1) {
if (text !== undefined && tool !== undefined) break
const event = events[index]
if (event === undefined) continue
const { type, data } = event
if (type === 'user/message' || type === 'assistant/message') {
messagesSeen += 1
if (messagesSeen > maxMessages) break
} else if (messagesSeen >= maxMessages) {
// The window already holds its `maxMessages` messages: anything older
// than the oldest in-window message sits outside the recent window.
continue
}
if (text === undefined && type === 'assistant/message') {
const message = data.message as { content?: unknown } | undefined
const extracted = contentText(message?.content)
if (extracted !== undefined) text = extracted
} else if (tool === undefined && type === 'tool/call') {
tool = {
name: typeof data.name === 'string' ? data.name : 'tool',
args: typeof data.arguments === 'string' ? data.arguments : '',
}
}
}
if (text === undefined && tool === undefined) return {}
return {
...(text === undefined ? {} : { text }),
...(tool === undefined ? {} : { tool }),
}
}