mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into web2-todo
# Conflicts: # packages/client/connection/src/client/fixture.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/index.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx # packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx # packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx # packages/client/ui-conversation/tests/chat-view.spec.tsx # packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/client/ui-trajectory/tests/views.spec.tsx
This commit is contained in:
@@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId, TodoItem } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
@@ -23,6 +23,9 @@ import type {
|
||||
ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
|
||||
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
|
||||
import { RpcId } from './api/rpc.ts'
|
||||
@@ -147,6 +150,7 @@ function summarize(session: Session, running: boolean): SessionSummary {
|
||||
sessionId: session.id,
|
||||
updatedAt: session.events.at(-1)?.time ?? session.header.createdAt,
|
||||
running,
|
||||
blank: session.events.length === 0,
|
||||
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
|
||||
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
|
||||
}
|
||||
@@ -171,6 +175,9 @@ async function summarizeCold(persistence: SessionPersistence, meta: SessionHeade
|
||||
sessionId: meta.id,
|
||||
updatedAt,
|
||||
running: false,
|
||||
// Lazy persistence keeps never-appended sessions out of list(): a cold
|
||||
// session necessarily has events, so blank is constantly false here.
|
||||
blank: false,
|
||||
...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
|
||||
/* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
|
||||
filters those out (legacy logs are not served); the conditional mirrors
|
||||
@@ -365,6 +372,41 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
for (const queue of muxQueues) queue.push(envelope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session inbox mirror serving the mux-open queue snapshot (the same
|
||||
* refresh-recovery baseline as pending questions). Keyed by the stable
|
||||
* AgentMessageId: every enqueued id receives exactly one terminal
|
||||
* `agent/inbox/dequeue` OR `agent/inbox/discard` (the inbox contract), so
|
||||
* the mirror needs no consumption heuristics or sweeps beyond disposal.
|
||||
*/
|
||||
const queuedMirror = new Map<SessionId, Map<AgentMessageId, AgentMessage>>()
|
||||
ctx.effect(() => {
|
||||
const retire = (agent: Agent, id: AgentMessageId): void => {
|
||||
const entries = queuedMirror.get(agent.id)
|
||||
if (entries === undefined) return
|
||||
entries.delete(id)
|
||||
if (entries.size === 0) queuedMirror.delete(agent.id)
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('agent/inbox/enqueue', (agent: Agent, message: AgentMessage) => {
|
||||
let entries = queuedMirror.get(agent.id)
|
||||
if (entries === undefined) queuedMirror.set(agent.id, entries = new Map<AgentMessageId, AgentMessage>())
|
||||
entries.set(message.id, message)
|
||||
broadcast({ type: 'session/queued', sessionId: agent.id, content: message.content, source: message.source, steering: message.steering })
|
||||
}),
|
||||
ctx.on('agent/inbox/dequeue', (agent: Agent, message: AgentMessage) => {
|
||||
retire(agent, message.id)
|
||||
}),
|
||||
ctx.on('agent/inbox/discard', (agent: Agent, messages: AgentMessage[]) => {
|
||||
for (const message of messages) retire(agent, message.id)
|
||||
}),
|
||||
ctx.on('session/disposed', (session: Session) => {
|
||||
queuedMirror.delete(session.id)
|
||||
}),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'api-proxy: queued mirror')
|
||||
|
||||
/** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
|
||||
function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
|
||||
pendingQuestions.delete(pending.rpcId)
|
||||
@@ -627,7 +669,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
if (mode === 'steer') agent.steer(content, { source })
|
||||
else agent.followup(content, { source })
|
||||
} catch (error: unknown) {
|
||||
// A synchronous throw from send/steer means disposed or invalid input; surface as agent-busy with the reason attached.
|
||||
// A synchronous throw from steer/followup means disposed or invalid input; surface as agent-busy with the reason attached.
|
||||
return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
|
||||
}
|
||||
return ok(request, { accepted: true as const })
|
||||
@@ -774,6 +816,88 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
},
|
||||
|
||||
commands: {
|
||||
// Both methods address one session's agent (agentFor keeps its
|
||||
// resume-on-miss: clients only send a sessionId for a published
|
||||
// session, and resume restores an existing entity).
|
||||
async list(request) {
|
||||
// Missing service = the deployment omitted dsh-commands from its
|
||||
// composition, not an empty catalog: fail loud instead of serving [].
|
||||
const commands = ctx.get('commands')
|
||||
if (commands === undefined) {
|
||||
return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} })
|
||||
}
|
||||
const found = await agentFor(request.payload.sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
return ok(request, { commands: commands.list(found.agent) })
|
||||
},
|
||||
|
||||
async execute(request, signal) {
|
||||
const commands = ctx.get('commands')
|
||||
if (commands === undefined) {
|
||||
return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} })
|
||||
}
|
||||
const { sessionId, line } = request.payload
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
try {
|
||||
const result = await commands.execute(found.agent, line, signal)
|
||||
if (result === undefined) return ok(request, { matched: false })
|
||||
return ok(request, {
|
||||
matched: true,
|
||||
result: { kind: result.kind, ...result.text === undefined ? {} : { text: result.text } },
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) return err(request, { code: 'cancelled', message: 'command execution was aborted', details: {} })
|
||||
return err(request, { code: 'internal', message: `command failed: ${String(error)}`, details: {} })
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
skills: {
|
||||
// Skill lookup never touches the Agent registry: the session address
|
||||
// resolves to a canonical cwd from the host-resident session header, so
|
||||
// listing skills cannot create or resume an agent as a side effect.
|
||||
async list(request) {
|
||||
const { sessionId } = request.payload
|
||||
const session = ctx.sessions.get(sessionId)
|
||||
if (session === undefined) {
|
||||
return err(request, {
|
||||
code: 'session-not-found',
|
||||
message: `session "${sessionId}" not found (not attached)`,
|
||||
details: { sessionId },
|
||||
})
|
||||
}
|
||||
if (session.header.cwd === undefined) {
|
||||
// Every served session records its project at create time; a
|
||||
// cwd-less header is a pre-project legacy log (not served).
|
||||
return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
|
||||
}
|
||||
const cwd = session.header.cwd
|
||||
// Same stance as the commands domain: a missing service means the
|
||||
// deployment omitted dsh-skill from its composition, not an empty
|
||||
// catalog. ctx.get also keeps this handler independent of the gateway
|
||||
// plugin's inject list (an undeclared `ctx.skills` property read
|
||||
// fails the reflect proxy).
|
||||
const skillRegistry = ctx.get('skills')
|
||||
if (skillRegistry === undefined) {
|
||||
return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} })
|
||||
}
|
||||
try {
|
||||
const skills = await skillRegistry.list({ cwd })
|
||||
return ok(request, {
|
||||
skills: skills.map(skill => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
|
||||
})),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} })
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
events: {
|
||||
mux(_request, signal) {
|
||||
const queue = new FrameQueue<RpcRequest<MuxFrame>>()
|
||||
@@ -790,6 +914,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
})
|
||||
}
|
||||
// Queue snapshot baseline (pendingQuestions precedent): frames replayed
|
||||
// in arrival order per session; a reconnecting client rebuilds its
|
||||
// queue view from these alone.
|
||||
for (const [sessionId, entries] of queuedMirror) {
|
||||
for (const entry of entries.values()) {
|
||||
queue.push(frame({ type: 'session/queued', sessionId, content: entry.content, source: entry.source, steering: entry.steering }))
|
||||
}
|
||||
}
|
||||
// Per-session open-call table for result-view pairing. Bounded by the
|
||||
// per-turn call count: entries clear on turn/end; a table miss (stream
|
||||
// opened mid-turn) backscans the session's in-memory events instead.
|
||||
@@ -839,6 +971,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
queue.push(frame({
|
||||
type: 'host/session-added',
|
||||
sessionId: session.id,
|
||||
// Derived at frame time like summarize(); a just-created session
|
||||
// has no events yet, so this is constantly true in practice.
|
||||
blank: session.events.length === 0,
|
||||
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
|
||||
// cwd rides the frame so the client list needs no refresh to group the new session.
|
||||
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
|
||||
@@ -877,6 +1012,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
workspace: changedWorkspaceView(change.key, change.value),
|
||||
}))
|
||||
}),
|
||||
ctx.on('commands/change', () => {
|
||||
queue.push(frame({ type: 'host/commands-changed' }))
|
||||
}),
|
||||
]
|
||||
return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user