- Bump 21 @deepseek-ai devDeps from 0.1.5-rc.2 → 0.2.0-rc.2 (dsh-code-runtime stays at 0.1.5-rc.2 — no 0.2.0-rc.2 published) - Fix sidechat-routes.ts: source.kind 'plugin' → 'user' (0.2.0-rc.2 removed 'plugin' from MessageSourceMap) - Remove unused SIDE_INJECTION_PLUGIN import - Fix test fixtures for 0.2.0-rc.2 stricter validation: - tool/result message role: 'user' → 'tool' - add toolCallId at message top level - assertion kind: 'plugin' → 'user' - Icon migration: Icon*14 → Icon*Regular, Icon*16 → Icon*Medium (19 files) - SideChatView API migration to 0.2.0-rc.2 - Add 4 new locale keys (sideChatBlockNoExitCode, codeLabel, wrapLabel, unwrapLabel) - intercept.tsx: guard against undefined matched in SidebarProducedFiles Verification: - pnpm run typecheck: PASS - pnpm run build: PASS - npx vitest run: 1015 passed | 0 failed | 9 skipped
430 lines
20 KiB
TypeScript
430 lines
20 KiB
TypeScript
/**
|
||
* Side Chat routes of the /sidebar JSON API ('sidechat.start' /
|
||
* 'sidechat.prompt' / 'sidechat.cancel' / 'sidechat.dispose' /
|
||
* 'sidechat.info' / 'sidechat.events').
|
||
*
|
||
* A side thread is a child session the plugin creates ITSELF with a custom
|
||
* seed — the parent's full event log up to the click moment, honestly closed
|
||
* at an in-progress turn (see sidechat-core.ts). The child is marked
|
||
* `origin: 'subagent'` so the main session list hides it, and EVERY
|
||
* operation goes through these routes because the generic session RPCs are
|
||
* fenced away from subagent-origin identities (the api-remotes
|
||
* agent-lookup ownership fence). No DSH source is touched:
|
||
*
|
||
* - creation uses the public AgentRegistry.create seam (the same one
|
||
* api-proxy's session.fork and the subagent fork provider use), with the
|
||
* parent's preset composition and provider/model selection so the child's
|
||
* first request shares the parent's token prefix (provider-side prefix
|
||
* cache reuse);
|
||
* - the first prompt (boundary + question) and every follow-up are admitted
|
||
* with the stock `agent.followup`;
|
||
* - a cold thread (DSH restart, or a closed thread) is resumed with
|
||
* AgentRegistry.resume, composing the preset the child recorded.
|
||
*/
|
||
import { randomUUID } from 'node:crypto'
|
||
import { createUserMessage, type ContentBlock, type UserMessage } from '@deepseek-ai/dsh-llm'
|
||
import type { Agent, AgentSetup, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
|
||
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||
import type { Context as CordisContext } from '@deepseek-ai/cordis'
|
||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||
import { SessionLogOffset } from '@deepseek-ai/dsh-session'
|
||
import type {
|
||
Context,
|
||
SidebarAgentPresetsService,
|
||
SidebarSessionPersistenceService,
|
||
SidebarSessionTitleService,
|
||
} from './context-types.ts'
|
||
import {
|
||
boundaryDelivered,
|
||
buildSidechatInheritance,
|
||
liveEventsOf,
|
||
resolvePresetId,
|
||
SIDE_BOUNDARY_PROMPT,
|
||
SIDE_NEW_THREAD_TITLE,
|
||
sideLabel,
|
||
type SeedEvent,
|
||
type SidechatLiveEvent,
|
||
type SidechatLogEvent,
|
||
type SidechatThreadInfo,
|
||
threadOwnLogEvents,
|
||
} from './sidechat-core.ts'
|
||
import type { AssistantLiveBuffer } from './assistant-live.ts'
|
||
import { requireString, SidebarError } from './wire.ts'
|
||
import { readPersistedSession } from './session-store.ts'
|
||
|
||
/** The six Side Chat routes of the sidebar API (wire method names). */
|
||
export interface SidechatRoutes {
|
||
/** Create a side thread child seeded with the parent's log up to now.
|
||
* `question` is optional: empty creates an EMPTY thread (Codex-style
|
||
* immediate create); the first `sidechat.prompt` then carries the
|
||
* boundary + snapshot and earns the thread its real label. */
|
||
'sidechat.start'(payload: unknown): Promise<{ childId: string }>
|
||
/** Deliver one follow-up message to a thread (live, or cold-resumed). */
|
||
'sidechat.prompt'(payload: unknown): Promise<{ accepted: true }>
|
||
/** Abort the thread's running turn (queued work is preserved). */
|
||
'sidechat.cancel'(payload: unknown): Promise<{ accepted: true }>
|
||
/** Release the thread's live agent (session and history stay persisted). */
|
||
'sidechat.dispose'(payload: unknown): Promise<{ accepted: true }>
|
||
/** Live state + agent identity for the thread header. */
|
||
'sidechat.info'(payload: unknown): Promise<SidechatThreadInfo>
|
||
/** The thread's OWN transcript events, seed-cut host-side (the inherited
|
||
* parent log never crosses the wire); `afterSeq` narrows the response to
|
||
* the delta beyond it (poll tail). `live` carries the thread's in-flight
|
||
* model deltas, which DSH 0.1.5 publishes outside the session log — it is
|
||
* the CURRENT attempt's rows on every poll, never a delta. */
|
||
'sidechat.events'(payload: unknown): Promise<{ events: SidechatLogEvent[]; live: SidechatLiveEvent[] }>
|
||
}
|
||
|
||
/** Timeout guarding the create call (the registry detaches it before the
|
||
* handle becomes visible, so the child is never cancelled by it). */
|
||
const CREATE_TIMEOUT_MS = 15_000
|
||
|
||
/** Head cap of one `sidechat.events` response (the ceiling the old
|
||
* client-side walk could load: 40 pages × 200 events). A pathological
|
||
* thread beyond it renders its tail window — the same degradation the
|
||
* capped walk had, never a failed poll. */
|
||
const EVENTS_CAP = 8_000
|
||
|
||
/** Per-activation disposers of created thread agents (the dispose route
|
||
* releases them; the session and its history always stay persisted). */
|
||
const threadDisposers = new Map<string, () => Promise<void>>()
|
||
|
||
/** The in-progress-turn snapshot captured at creation of an EMPTY thread,
|
||
* waiting to ride the first prompt (lost on a host restart — the boundary
|
||
* prompt is then delivered alone, a logged degradation). */
|
||
const pendingSnapshots = new Map<string, string>()
|
||
|
||
/** Resolve the parent's preset and build the child's composition setup
|
||
* (mirror of api-proxy's composeAgent minus the model-selection install —
|
||
* the child carries the parent's provider/model in agentOptions). */
|
||
async function composeChildSetup(
|
||
ctx: Context,
|
||
presetId: string | undefined,
|
||
): Promise<{ agentPreset?: string; setup: AgentSetup }> {
|
||
const presets = ctx.get('agentPresets') as SidebarAgentPresetsService | undefined
|
||
if (presets === undefined) {
|
||
return { setup: () => Promise.resolve() }
|
||
}
|
||
const resolved = await presets.resolve(presetId)
|
||
return {
|
||
agentPreset: resolved.id,
|
||
setup: async (agentCtx: CordisContext) => { await presets.mount(agentCtx, resolved.id) },
|
||
}
|
||
}
|
||
|
||
/** Build the cold-resume setup from the thread's PERSISTED record (the
|
||
* recorded preset wins, newest selection event first). */
|
||
async function composePersistedSetup(
|
||
ctx: Context,
|
||
childId: string,
|
||
): Promise<AgentSetup> {
|
||
const persistence = ctx.get('sessionPersistence') as SidebarSessionPersistenceService | undefined
|
||
if (persistence === undefined) {
|
||
return () => Promise.resolve()
|
||
}
|
||
const inspected = await readPersistedSession(persistence, childId)
|
||
const presetId = resolvePresetId(inspected.header, inspected.events)
|
||
const presets = ctx.get('agentPresets') as SidebarAgentPresetsService | undefined
|
||
if (presets === undefined || presetId === undefined) {
|
||
return () => Promise.resolve()
|
||
}
|
||
const resolved = await presets.resolve(presetId)
|
||
return async (agentCtx: CordisContext) => { await presets.mount(agentCtx, resolved.id) }
|
||
}
|
||
|
||
/** One text-block prompt (the thread boundary + question, or a follow-up). */
|
||
function textPrompt(text: string): ContentBlock[] {
|
||
return [{ type: 'text', text }]
|
||
}
|
||
|
||
/** Admit one user message to a live agent through the stock followup path. */
|
||
function admitFollowup(agent: Agent, blocks: ContentBlock[]): void {
|
||
const message: UserMessage = createUserMessage({ content: blocks, source: { kind: 'user' } })
|
||
agent.followup(message)
|
||
}
|
||
|
||
/**
|
||
* Deliver the thread's FIRST contact as TWO log-separated messages: the
|
||
* boundary prompt (+ the parked in-progress snapshot) rides `agent.inject`
|
||
* — queued model-facing context that does NOT wake the driver and is
|
||
* claimed FIRST at the opening step (Inbox.claim drains next-step before
|
||
* next-turn) — and the user's question is the follow-up that wakes it. The
|
||
* log therefore records two user/message events (injection, then question)
|
||
* instead of one wrapped blob: the transcript shows the question as a user
|
||
* bubble and collapses the injection as a context row. The injection source
|
||
* is stamped `kind: 'plugin'` so recognition is structural; its text still
|
||
* opens with SIDE_BOUNDARY_PREFIX, keeping boundaryDelivered intact.
|
||
*/
|
||
function admitFirstContact(agent: Agent, injectionText: string, question: string): void {
|
||
agent.inject(createUserMessage({
|
||
content: textPrompt(injectionText),
|
||
source: { kind: 'user' },
|
||
}))
|
||
admitFollowup(agent, textPrompt(question))
|
||
}
|
||
|
||
/** The live thread agent, or undefined (cold — the caller resumes). */
|
||
function liveThreadAgent(ctx: Context, childId: string): Agent | undefined {
|
||
const agents = ctx.get('agents') as { get(id: string): Agent | undefined } | undefined
|
||
return agents?.get(childId)
|
||
}
|
||
|
||
/**
|
||
* The thread's event log (seed + its own events, already expanded): the live
|
||
* agent's in-memory log while the thread is attached — the freshest read,
|
||
* including events not yet flushed — else the persisted logical log. Both
|
||
* DSH generations expose these seams with the same shape (the 0.1.2
|
||
* persistence layer packs chunk rows on disk but expands them on inspect;
|
||
* the live log is `Session.snapshotEvents()`, the 0.1.2-alpha.4 rename of
|
||
* the `Session.events` property), which is why the transcript reads here
|
||
* instead of the client's session-history RPC: that face
|
||
* (`ctx.connection.api`) was removed in 0.1.2-alpha.1's Remote-gateway
|
||
* migration.
|
||
*/
|
||
async function threadLogEvents(ctx: Context, childId: string): Promise<readonly SidechatLogEvent[]> {
|
||
const agent = liveThreadAgent(ctx, childId)
|
||
if (agent !== undefined) {
|
||
return agent.session.snapshotEvents() as unknown as readonly SidechatLogEvent[]
|
||
}
|
||
const persistence = ctx.get('sessionPersistence') as SidebarSessionPersistenceService | undefined
|
||
if (persistence === undefined) {
|
||
throw new SidebarError('sidechat-error', 'the session persistence service is unavailable', 503)
|
||
}
|
||
try {
|
||
const inspected = await readPersistedSession(persistence, childId)
|
||
return inspected.events as unknown as readonly SidechatLogEvent[]
|
||
} catch (error: unknown) {
|
||
throw new SidebarError(
|
||
'not-found',
|
||
`thread "${childId}" is not available: ${error instanceof Error ? error.message : String(error)}`,
|
||
404,
|
||
)
|
||
}
|
||
}
|
||
|
||
/** Build the Side Chat routes (all optional services degrade to a wire
|
||
* error the tab surfaces inline). The record keys are the FULL wire method
|
||
* names the /sidebar/api dispatcher looks up (`api[method]`).
|
||
* @param ctx - host plugin context.
|
||
* @param live - the live assistant stream buffer; absent only in tests that
|
||
* never exercise streaming (then every `live` response is empty). */
|
||
export function buildSidechatApi(ctx: Context, live?: AssistantLiveBuffer): SidechatRoutes {
|
||
return {
|
||
'sidechat.start': async (payload: unknown) => {
|
||
const sessionId = requireString(payload, 'sessionId')
|
||
const rawQuestion = (payload as { question?: unknown }).question
|
||
const question = typeof rawQuestion === 'string' ? rawQuestion.trim() : ''
|
||
const parent = liveThreadAgent(ctx, sessionId)
|
||
if (parent === undefined) {
|
||
throw new SidebarError('sidechat-error', `parent session "${sessionId}" is not running`, 409)
|
||
}
|
||
const parentSession = parent.session
|
||
const inheritance = buildSidechatInheritance(
|
||
parentSession.snapshotEvents() as unknown as readonly SidechatLogEvent[],
|
||
live?.chunksFor(sessionId) ?? [],
|
||
)
|
||
const { agentPreset, setup } = await composeChildSetup(
|
||
ctx,
|
||
resolvePresetId(parentSession.header, parentSession.snapshotEvents()),
|
||
)
|
||
const childId = `session-${randomUUID()}` as SessionId
|
||
const label = question === '' ? SIDE_NEW_THREAD_TITLE : sideLabel(question)
|
||
// Honest catalog citizenship: the durable descriptor keeps the thread
|
||
// a HEALTHY row in the host's subagents.list — a cold child without
|
||
// one is deterministically rendered as a 'corrupt' diagnostic. The
|
||
// SubagentView filters the 'Side: ' label out, so the topology UI
|
||
// stays noise-free; the row only serves enumeration correctness.
|
||
const descriptor = snapshotSubagentDescriptor({
|
||
mode: 'continuable',
|
||
provider: 'sidechat',
|
||
label,
|
||
...(parent.options.provider === undefined ? {} : { agentProvider: parent.options.provider }),
|
||
...(parent.options.model === undefined ? {} : { agentModel: parent.options.model }),
|
||
})
|
||
const descriptorEvent: SeedEvent = {
|
||
type: 'subagent/descriptor',
|
||
seq: inheritance.seed.length,
|
||
time: Date.now(),
|
||
data: descriptor as unknown as Record<string, unknown>,
|
||
}
|
||
const seed = [...inheritance.seed, descriptorEvent]
|
||
// Fork-marker fields (the exact shape the host's own session.fork uses,
|
||
// api-session-controller): without `isSeeded` + `inheritedEventCount` the
|
||
// session treats the whole seed as the child's OWN events, so the child's
|
||
// Inbox constructor replays the parent's `agent/inbox/spliced` events and
|
||
// inherits whatever input sat UNCLAIMED in the parent at the click moment
|
||
// (a queued follow-up, or a tool-result context spliced into next-step
|
||
// between step boundaries of a long-running turn). The first side prompt
|
||
// would then claim and send that stale message BEFORE the boundary +
|
||
// question. The marker keeps `ownEvents()` at the end-seed boundary, so
|
||
// the inherited inbox replays to empty.
|
||
const options: CreateAgentOptions = {
|
||
sessionId: childId,
|
||
meta: {
|
||
...(parentSession.header.cwd === undefined ? {} : { cwd: parentSession.header.cwd }),
|
||
parentSession: parentSession.id,
|
||
isSeeded: true,
|
||
origin: 'subagent',
|
||
delegationDepth: (parentSession.header.delegationDepth ?? 0) + 1,
|
||
...(agentPreset === undefined ? {} : { agentPreset }),
|
||
},
|
||
seed: seed as unknown as readonly SessionEvent[],
|
||
inheritedEventCount: SessionLogOffset(seed.length),
|
||
agentOptions: { ...parent.options },
|
||
setup,
|
||
signal: AbortSignal.timeout(CREATE_TIMEOUT_MS),
|
||
}
|
||
const agents = ctx.get('agents') as { create(options: CreateAgentOptions): Promise<{ agent: Agent; dispose(): Promise<void> }> } | undefined
|
||
if (agents?.create === undefined) {
|
||
throw new SidebarError('sidechat-error', 'the agents service is unavailable', 503)
|
||
}
|
||
let handle: { agent: Agent; dispose(): Promise<void> }
|
||
try {
|
||
handle = await agents.create(options)
|
||
} catch (error) {
|
||
throw new SidebarError('sidechat-error', `thread creation failed: ${error instanceof Error ? error.message : String(error)}`, 500)
|
||
}
|
||
threadDisposers.set(childId, () => handle.dispose())
|
||
// Pin the thread label so the client can identify its threads by
|
||
// title prefix (the rename is a live-session op, no RPC fence).
|
||
const titles = ctx.get('sessionTitle') as SidebarSessionTitleService | undefined
|
||
const pinTitle = (label: string): void => {
|
||
if (titles === undefined) return
|
||
try {
|
||
titles.rename(handle.agent.session, label)
|
||
} catch {
|
||
// Keep the auto-generated title; the thread stays usable.
|
||
}
|
||
}
|
||
if (question === '') {
|
||
// Codex-style immediate create: no prompt yet — the composer owns
|
||
// the first message; the snapshot waits for it.
|
||
if (inheritance.snapshot !== null) pendingSnapshots.set(childId, inheritance.snapshot)
|
||
pinTitle(SIDE_NEW_THREAD_TITLE)
|
||
} else {
|
||
const promptParts = [SIDE_BOUNDARY_PROMPT]
|
||
if (inheritance.snapshot !== null) promptParts.push(inheritance.snapshot)
|
||
admitFirstContact(handle.agent, promptParts.join('\n\n'), question)
|
||
pinTitle(sideLabel(question))
|
||
}
|
||
return { childId }
|
||
},
|
||
|
||
'sidechat.prompt': async (payload: unknown) => {
|
||
const childId = requireString(payload, 'childId')
|
||
const text = requireString(payload, 'text').trim()
|
||
if (text === '') {
|
||
throw new SidebarError('bad-request', 'text is required')
|
||
}
|
||
let agent = liveThreadAgent(ctx, childId)
|
||
if (agent === undefined) {
|
||
// Cold thread: resume the persisted session under its recorded
|
||
// composition, then deliver the follow-up.
|
||
const agents = ctx.get('agents') as { resume(options: ResumeAgentOptions): Promise<{ agent: Agent; dispose(): Promise<void> }> } | undefined
|
||
if (agents?.resume === undefined) {
|
||
throw new SidebarError('sidechat-error', 'the agents service is unavailable', 503)
|
||
}
|
||
const setup = await composePersistedSetup(ctx, childId)
|
||
try {
|
||
const handle = await agents.resume({ resumeSessionId: childId as SessionId, setup })
|
||
threadDisposers.set(childId, () => handle.dispose())
|
||
agent = handle.agent
|
||
} catch (error) {
|
||
throw new SidebarError('sidechat-error', `thread resume failed: ${error instanceof Error ? error.message : String(error)}`, 500)
|
||
}
|
||
}
|
||
if (boundaryDelivered(agent.session.snapshotEvents() as unknown as readonly SidechatLogEvent[])) {
|
||
admitFollowup(agent, textPrompt(text))
|
||
} else {
|
||
// First message of an immediately-created thread: it carries the
|
||
// boundary (+ the snapshot parked at creation, if still around)
|
||
// and earns the thread its real label.
|
||
const parts = [SIDE_BOUNDARY_PROMPT]
|
||
const snapshot = pendingSnapshots.get(childId)
|
||
pendingSnapshots.delete(childId)
|
||
if (snapshot !== undefined) parts.push(snapshot)
|
||
admitFirstContact(agent, parts.join('\n\n'), text)
|
||
const titles = ctx.get('sessionTitle') as SidebarSessionTitleService | undefined
|
||
if (titles !== undefined) {
|
||
try {
|
||
titles.rename(agent.session, sideLabel(text))
|
||
} catch {
|
||
// Keep the placeholder title; the thread stays usable.
|
||
}
|
||
}
|
||
}
|
||
return { accepted: true as const }
|
||
},
|
||
|
||
'sidechat.cancel': async (payload: unknown) => {
|
||
const childId = requireString(payload, 'childId')
|
||
const agent = liveThreadAgent(ctx, childId)
|
||
if (agent !== undefined) {
|
||
agent.cancel({ kind: 'user' }, { keepInbox: true })
|
||
}
|
||
return { accepted: true as const }
|
||
},
|
||
|
||
'sidechat.dispose': async (payload: unknown) => {
|
||
const childId = requireString(payload, 'childId')
|
||
pendingSnapshots.delete(childId)
|
||
const dispose = threadDisposers.get(childId)
|
||
if (dispose !== undefined) {
|
||
threadDisposers.delete(childId)
|
||
try {
|
||
await dispose()
|
||
} catch {
|
||
// The agent may already be gone (restart); the session persists.
|
||
}
|
||
}
|
||
return { accepted: true as const }
|
||
},
|
||
|
||
'sidechat.info': async (payload: unknown) => {
|
||
const childId = requireString(payload, 'childId')
|
||
const agent = liveThreadAgent(ctx, childId)
|
||
if (agent !== undefined) {
|
||
const preset = agent.session.header.agentPreset
|
||
return {
|
||
live: true,
|
||
status: agent.status,
|
||
...(agent.options.provider === undefined ? {} : { provider: agent.options.provider }),
|
||
...(agent.options.model === undefined ? {} : { model: agent.options.model }),
|
||
...(preset === undefined ? {} : { preset }),
|
||
}
|
||
}
|
||
// Cold thread: only the persisted preset is worth reading back.
|
||
const persistence = ctx.get('sessionPersistence') as SidebarSessionPersistenceService | undefined
|
||
if (persistence !== undefined) {
|
||
try {
|
||
const inspected = await readPersistedSession(persistence, childId)
|
||
const preset = resolvePresetId(inspected.header, inspected.events)
|
||
return { live: false, ...(preset === undefined ? {} : { preset }) }
|
||
} catch {
|
||
// Unknown/gone session: report a bare cold info.
|
||
}
|
||
}
|
||
return { live: false }
|
||
},
|
||
|
||
'sidechat.events': async (payload: unknown) => {
|
||
const childId = requireString(payload, 'childId')
|
||
const rawAfter = (payload as { afterSeq?: unknown }).afterSeq
|
||
if (rawAfter !== undefined
|
||
&& (typeof rawAfter !== 'number' || !Number.isSafeInteger(rawAfter) || rawAfter < 0)) {
|
||
throw new SidebarError('bad-request', 'afterSeq must be a non-negative integer')
|
||
}
|
||
const events = await threadLogEvents(ctx, childId)
|
||
const own = threadOwnLogEvents(events)
|
||
const fresh = rawAfter === undefined ? own : own.filter(event => event.seq > rawAfter)
|
||
const tailSeq = own.at(-1)?.seq ?? -1
|
||
return {
|
||
events: fresh.length > EVENTS_CAP ? fresh.slice(fresh.length - EVENTS_CAP) : fresh,
|
||
// The live rows ride every poll: they are process-local, so there is
|
||
// no durable seq to page them by. An idle thread has none.
|
||
live: liveEventsOf(live?.chunksFor(childId) ?? [], tailSeq),
|
||
}
|
||
},
|
||
}
|
||
}
|