Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # apps/cli/README.i18n.yaml # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/client/connection/src/client/fixture.ts # packages/client/connection/src/client/index.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/input/hub.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/compact/compact-basic/src/summarizer.ts # packages/compact/compact-basic/tests/compact-basic.spec.ts # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/rpc.schema.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/llm/llm-deepseek/tests/serialize.spec.ts # packages/llm/llm-pi-ai/tests/adapter.spec.ts # packages/llm/llm-pi-ai/tests/convert.spec.ts # packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts # packages/llm/token-meter/tests/token-meter.spec.ts # packages/ui/tui/README.i18n.yaml
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: 6b4691164ceb82a68c94e5a71d2f44fcfa1634af
|
||||
README.zh.md: b28b57097a251ee18295a3f9a1ae41b00efe2db2
|
||||
README.md: c20887b73b9b9deb278db30d34d84df07257d664
|
||||
README.zh.md: 18a2f97477e5f57127371429b0dd59ba01f04341
|
||||
|
||||
@@ -22,8 +22,6 @@ Workspace and Session lists are separate reconnect baselines. `workspace.create`
|
||||
|
||||
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
|
||||
|
||||
`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state.
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
@@ -22,8 +22,6 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
|
||||
|
||||
`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
@@ -45,10 +45,12 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
|
||||
@@ -9,13 +9,14 @@ import { join } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
Agent, AgentLlmTarget, AgentLlmTargetRef, AgentMessage, AgentMessageId, AgentStatus,
|
||||
Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxPlacement,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { AttachmentError } from '@deepseek-ai/dsh-attachment-local'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment-local'
|
||||
import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
import {
|
||||
@@ -25,12 +26,17 @@ import {
|
||||
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
ApiProxy, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning,
|
||||
ApiProxy, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning,
|
||||
MuxFrame, PromptContentPart, QuestionResponsePayload, SessionProjectionsBlock, SessionSummary, ToolEventView,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
// Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column).
|
||||
import type {} from '@deepseek-ai/dsh-session-projection-cache'
|
||||
// GoalError narrows domain rejections to their stable codes at the wire boundary.
|
||||
import { GoalError } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal'
|
||||
// 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'
|
||||
@@ -215,13 +221,24 @@ function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Sess
|
||||
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session's conversation has started: no turn has run yet (a
|
||||
* turn is one model-loop execution). Standalone plugin events — command
|
||||
* lifecycle records, plan/mode, titles, goals — never open a turn, so
|
||||
* running `/plan` or `/goal` on a fresh session keeps it blank
|
||||
* (list-hidden, reusable).
|
||||
*/
|
||||
function sessionBlank(session: Session): boolean {
|
||||
return !session.events.some(event => event.type === 'turn/start')
|
||||
}
|
||||
|
||||
/** SessionSummary projection for attached (in-memory) sessions. */
|
||||
function summarize(session: Session, running: boolean): SessionSummary {
|
||||
return {
|
||||
sessionId: session.id,
|
||||
updatedAt: session.events.at(-1)?.time ?? session.header.createdAt,
|
||||
running,
|
||||
blank: session.events.length === 0,
|
||||
blank: sessionBlank(session),
|
||||
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
|
||||
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
|
||||
}
|
||||
@@ -246,8 +263,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.
|
||||
// Lazy persistence keeps never-appended sessions out of list(); reading
|
||||
// a cold log to check for turns would defeat the index read, so a listed
|
||||
// cold session is served as not-blank (its log holds its conversation).
|
||||
blank: false,
|
||||
...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
|
||||
/* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
|
||||
@@ -273,9 +291,6 @@ export interface ApiProxyDefaults {
|
||||
|
||||
/** The tool/call payload fields the presenter path reads. */
|
||||
interface ToolCallData { callId: string; name: string; arguments: string }
|
||||
/** The tool/result payload fields the presenter path reads. */
|
||||
interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: JsonValue }
|
||||
|
||||
/** One host-owned question wait, addressed by the stable server-request id. */
|
||||
interface PendingQuestion {
|
||||
rpcId: RpcId
|
||||
@@ -322,10 +337,16 @@ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) =>
|
||||
return view === undefined ? undefined : { for: 'call', view }
|
||||
}
|
||||
if (event.type === 'tool/result') {
|
||||
const { callId, content, isError, meta } = event.data as ToolResultData
|
||||
const { message, meta } = event.data
|
||||
const [result] = message.content
|
||||
const callId = message.source.callId
|
||||
const call = argsFor(callId) as { name: string; args: unknown } | undefined
|
||||
if (call === undefined) return undefined
|
||||
const view = ctx.tools.get(call.name)?.presentResult?.(call.args, { content, isError, ...meta === undefined ? {} : { meta } })
|
||||
const view = ctx.tools.get(call.name)?.presentResult?.(call.args, {
|
||||
content: result.content,
|
||||
isError: result.isError === true,
|
||||
...meta === undefined ? {} : { meta },
|
||||
})
|
||||
return view === undefined ? undefined : { for: 'result', view }
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
@@ -373,6 +394,28 @@ function projectionsFor(ctx: Context, agent: Agent): SessionProjectionsBlock | u
|
||||
return registry.snapshot(agent.session)
|
||||
}
|
||||
|
||||
/**
|
||||
* The projection baseline of one session.list row, fail-soft: attached
|
||||
* sessions cut the registry's live watermark cache; cold sessions view the
|
||||
* persisted projection cache's identity-checked stored rows (zero log loads
|
||||
* either way — the listing use case the cache exists for). The block shape
|
||||
* (values + asOfSeq) matches the history tail's, so a client seeds its
|
||||
* value store under the same higher-seq-wins rule. Any failure — and an
|
||||
* empty value set — yields an absent block: a listing without projections
|
||||
* is degraded, never broken.
|
||||
*/
|
||||
function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session | undefined): SessionProjectionsBlock | undefined {
|
||||
try {
|
||||
const block = session !== undefined
|
||||
? ctx.get('sessionProjections')?.snapshot(session)
|
||||
: ctx.get('sessionProjectionCache')?.cachedSnapshot(meta)
|
||||
return block !== undefined && Object.keys(block.values).length > 0 ? block : undefined
|
||||
} catch (error) {
|
||||
ctx.logger.warn(`session.list: projection column for "${meta.id}" failed (serving the row without it): ${String(error)}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by the cold-resume path when the id names no servable session
|
||||
* (absent from the store, or a pre-project legacy log without a cwd).
|
||||
@@ -500,41 +543,42 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
})
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Per-session inbox occurrence mirror serving the mux-open queue snapshot
|
||||
* (the same refresh-recovery baseline as pending questions). Each terminal
|
||||
* inbox event retires one matching occurrence, so repeated sends of the same
|
||||
* identified message remain visible until every occurrence is claimed.
|
||||
*/
|
||||
const queuedMirror = new Map<SessionId, Map<AgentMessageId, { message: AgentMessage; steering: boolean }>>()
|
||||
const queuedMirror = new Map<SessionId, { message: UserMessage; steering: boolean }[]>()
|
||||
ctx.effect(() => {
|
||||
const retire = (agent: Agent, id: AgentMessageId): void => {
|
||||
const retire = (agent: Agent, id: MessageId, placement?: InboxPlacement): void => {
|
||||
const entries = queuedMirror.get(agent.id)
|
||||
if (entries === undefined) return
|
||||
entries.delete(id)
|
||||
if (entries.size === 0) queuedMirror.delete(agent.id)
|
||||
const index = entries.findIndex(entry =>
|
||||
entry.message.id === id
|
||||
&& (placement === undefined || entry.steering === (placement === 'steering')))
|
||||
if (index !== -1) entries.splice(index, 1)
|
||||
if (entries.length === 0) queuedMirror.delete(agent.id)
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('agent/inbox/enqueue', (agent: Agent, message: AgentMessage, placement) => {
|
||||
ctx.on('agent/inbox/enqueue', (agent: Agent, message: UserMessage, placement) => {
|
||||
let entries = queuedMirror.get(agent.id)
|
||||
if (entries === undefined) {
|
||||
entries = new Map<AgentMessageId, { message: AgentMessage; steering: boolean }>()
|
||||
entries = []
|
||||
queuedMirror.set(agent.id, entries)
|
||||
}
|
||||
const steering = placement === 'steering'
|
||||
entries.set(message.id, { message, steering })
|
||||
entries.push({ message, steering })
|
||||
broadcast({
|
||||
type: 'session/queued',
|
||||
sessionId: agent.id,
|
||||
content: message.content,
|
||||
source: message.source,
|
||||
message,
|
||||
steering,
|
||||
})
|
||||
}),
|
||||
ctx.on('agent/inbox/dequeue', (agent: Agent, message: AgentMessage) => {
|
||||
retire(agent, message.id)
|
||||
ctx.on('agent/inbox/dequeue', (agent: Agent, message: UserMessage, placement) => {
|
||||
retire(agent, message.id, placement)
|
||||
}),
|
||||
ctx.on('agent/inbox/discard', (agent: Agent, messages: AgentMessage[]) => {
|
||||
ctx.on('agent/inbox/discard', (agent: Agent, messages: UserMessage[]) => {
|
||||
for (const message of messages) retire(agent, message.id)
|
||||
}),
|
||||
ctx.on('session/disposed', (session: Session) => {
|
||||
@@ -720,6 +764,38 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
return operation
|
||||
}
|
||||
|
||||
/** Resolve the goal service; absent = the deployment did not compose @deepseek-ai/dsh-goal. */
|
||||
function goalService(): NonNullable<ReturnType<typeof ctx.get<'goals'>>> | { error: RpcError } {
|
||||
const goals = ctx.get('goals')
|
||||
if (goals === undefined) {
|
||||
return { error: { code: 'internal', message: 'goal service is absent: this deployment does not mount @deepseek-ai/dsh-goal in its composition (cordis.yml or explicit assembly)', details: {} } }
|
||||
}
|
||||
return goals
|
||||
}
|
||||
|
||||
/** Map one goal-domain rejection to the wire error (stable GoalError codes ride in details). */
|
||||
function goalError(request: RpcRequest<unknown>, error: unknown): RpcResponse<never> {
|
||||
const details = error instanceof GoalError ? { goalCode: error.code } : {}
|
||||
return err(request, { code: 'internal', message: String(error), details })
|
||||
}
|
||||
|
||||
/** Resolve a session's agent, apply one goal mutation, and acknowledge with the new CAS ref. */
|
||||
async function mutateGoal(
|
||||
request: RpcRequest<{ sessionId: SessionId }>,
|
||||
mutation: (goals: NonNullable<ReturnType<typeof ctx.get<'goals'>>>, agent: Agent) => CoreGoalRef,
|
||||
): Promise<RpcResponse<{ ref: GoalRef }>> {
|
||||
const goals = goalService()
|
||||
if ('error' in goals) return err(request, goals.error)
|
||||
const found = await agentFor(request.payload.sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
try {
|
||||
const ref = mutation(goals, found.agent)
|
||||
return ok(request, { ref: { id: ref.id, revision: ref.revision } })
|
||||
} catch (error: unknown) {
|
||||
return goalError(request, error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: {
|
||||
// Attached sessions summarize from memory; persisted-but-unattached (cold)
|
||||
@@ -729,13 +805,25 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
async list(request) {
|
||||
const items = ctx.sessions.list().map((session) => {
|
||||
const agent = ctx.agents.get(session.id)
|
||||
return summarize(session, agent?.status === 'running')
|
||||
const projections = listProjectionsFor(ctx, session.header, session)
|
||||
return {
|
||||
...summarize(session, agent?.status === 'running'),
|
||||
...projections === undefined ? {} : { projections },
|
||||
}
|
||||
})
|
||||
const attached = new Set(items.map(item => item.sessionId))
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
if (persistence !== undefined) {
|
||||
const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
|
||||
items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta))))
|
||||
items.push(...await Promise.all(cold.map(async (meta) => {
|
||||
// Cold rows read the persisted projection cache only — never a
|
||||
// log load; a session without a cache row simply has no column.
|
||||
const projections = listProjectionsFor(ctx, meta, undefined)
|
||||
return {
|
||||
...await summarizeCold(persistence, meta),
|
||||
...projections === undefined ? {} : { projections },
|
||||
}
|
||||
})))
|
||||
}
|
||||
items.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
return ok(request, { items })
|
||||
@@ -936,8 +1024,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
}
|
||||
const durable = await durablePromptContent(ctx, content)
|
||||
if (mode === 'steer') agent.steer({ content: durable, source })
|
||||
else agent.followup({ content: durable, source })
|
||||
const message: UserMessage = createUserMessage({ content: durable, source })
|
||||
if (mode === 'steer') agent.steer(message)
|
||||
else agent.followup(message)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AttachmentError) {
|
||||
return err(request, {
|
||||
@@ -1226,6 +1315,54 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
},
|
||||
|
||||
goals: {
|
||||
// Mutations only — the read side is the 'goal' session projection.
|
||||
// Every verb resolves the session's agent (agentFor: implicit cold
|
||||
// resume, the command.* precedent) and acknowledges with the new CAS
|
||||
// ref; the committed goal/change event carries the whole value to every
|
||||
// client through the projection frames.
|
||||
async create(request) {
|
||||
const { objective, maxGoalRounds } = request.payload
|
||||
return mutateGoal(request, (goals, agent) => goals.create(agent, {
|
||||
objective,
|
||||
...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
|
||||
}))
|
||||
},
|
||||
|
||||
async edit(request) {
|
||||
const { ref, objective, maxGoalRounds } = request.payload
|
||||
return mutateGoal(request, (goals, agent) => goals.edit(agent, ref, {
|
||||
...(objective !== undefined ? { objective } : {}),
|
||||
...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
|
||||
}))
|
||||
},
|
||||
|
||||
async pause(request) {
|
||||
return mutateGoal(request, (goals, agent) => goals.pause(agent, request.payload.ref))
|
||||
},
|
||||
|
||||
async resume(request) {
|
||||
return mutateGoal(request, (goals, agent) => goals.resume(agent, request.payload.ref))
|
||||
},
|
||||
|
||||
async complete(request) {
|
||||
return mutateGoal(request, (goals, agent) => goals.complete(agent, request.payload.ref))
|
||||
},
|
||||
|
||||
async clear(request) {
|
||||
const goals = goalService()
|
||||
if ('error' in goals) return err(request, goals.error)
|
||||
const found = await agentFor(request.payload.sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
try {
|
||||
goals.clear(found.agent, request.payload.ref)
|
||||
return ok(request, { cleared: true as const })
|
||||
} catch (error: unknown) {
|
||||
return goalError(request, error)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
skills: {
|
||||
// Skill lookup never touches the Agent registry: the session address
|
||||
// resolves to a canonical cwd from the host-resident session header, so
|
||||
@@ -1290,12 +1427,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// 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()) {
|
||||
for (const entry of entries) {
|
||||
queue.push(frame({
|
||||
type: 'session/queued',
|
||||
sessionId,
|
||||
content: entry.message.content,
|
||||
source: entry.message.source,
|
||||
message: entry.message,
|
||||
steering: entry.steering,
|
||||
}))
|
||||
}
|
||||
@@ -1346,8 +1482,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
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,
|
||||
// has run no turn yet, so this is constantly true in practice.
|
||||
blank: sessionBlank(session),
|
||||
...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 },
|
||||
|
||||
@@ -23,6 +23,14 @@ export const askUserQuestionItemSchema = z.object({
|
||||
multiSelect: z.boolean().optional(),
|
||||
}) satisfies z.ZodType<Wire<AskUserQuestionItem>>
|
||||
|
||||
/** Unified message envelope carried by transient queue frames. */
|
||||
const messageSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
role: z.union([z.literal('system'), z.literal('user'), z.literal('assistant')]),
|
||||
content: z.array(contentBlockSchema),
|
||||
source: z.looseObject({ kind: z.string() }),
|
||||
})
|
||||
|
||||
/** MuxFrame union (payload slot of a mux-stream ServerRequest). */
|
||||
export const muxFrameSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }),
|
||||
@@ -34,8 +42,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
|
||||
// and must fail loud here, not reach the composer.
|
||||
z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema).min(1) }),
|
||||
z.object({ type: z.literal('question/resolved'), sessionId: sessionIdSchema, questionRpcId: rpcIdSchema, outcome: z.union([z.literal('answered'), z.literal('cancelled')]) }),
|
||||
// content/source reuse the wide passthroughs (both are merge-extensible in core).
|
||||
z.object({ type: z.literal('session/queued'), sessionId: sessionIdSchema, content: z.array(contentBlockSchema), source: z.looseObject({ kind: z.string() }), steering: z.boolean() }),
|
||||
z.object({ type: z.literal('session/queued'), sessionId: sessionIdSchema, message: messageSchema, steering: z.boolean() }),
|
||||
// value stays wide: it already passed its unit's own schema on the host,
|
||||
// and deep-validating here would import every domain's schema into the carrier.
|
||||
z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }),
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction/types'
|
||||
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
@@ -69,11 +69,11 @@ export type MuxFrame =
|
||||
* refresh-recovery baseline as pending questions); queue clearing on cancel
|
||||
* has no dedicated frame — clients fold it from the status flip.
|
||||
* `steering` is the host's acceptance-time queue classification and remains
|
||||
* authoritative in reconnect snapshots. `source` carries the prompt's rpcId
|
||||
* authoritative in reconnect snapshots. `message.source` carries the prompt's rpcId
|
||||
* when the message came over this wire (the client's provisional-echo
|
||||
* reconciliation key).
|
||||
*/
|
||||
| { type: 'session/queued'; sessionId: SessionId; content: ContentBlock[]; source: MessageSource; steering: boolean }
|
||||
| { type: 'session/queued'; sessionId: SessionId; message: Message; steering: boolean }
|
||||
/**
|
||||
* One projection unit's finished value changed (session-projection RFC).
|
||||
* Live push state, never logged — replay recomputes on the host (the
|
||||
|
||||
79
packages/host/apiproxy/src/api/goals.schema.ts
Normal file
79
packages/host/apiproxy/src/api/goals.schema.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* goals domain zod schemas. Mutation-only shapes: every value schema is a
|
||||
* `{ ref }` acknowledgement (clear: `{ cleared }`) — the current goal state
|
||||
* travels exclusively on the 'goal' session projection.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type { GoalRef, RequestPayload, ResponseValue } from './index.ts'
|
||||
|
||||
/** GoalRef schema. */
|
||||
export const goalRefSchema = z.object({
|
||||
id: z.string(),
|
||||
revision: z.number().int().positive(),
|
||||
}) as unknown as z.ZodType<Wire<GoalRef>>
|
||||
|
||||
/** Shared `{ ref }` acknowledgement value of every non-clear mutation. */
|
||||
const goalRefValueSchema = z.object({ ref: goalRefSchema })
|
||||
|
||||
/** goal.create request payload. */
|
||||
export const goalCreateRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
objective: z.string().min(1),
|
||||
maxGoalRounds: z.number().int().positive().optional(),
|
||||
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.create'>>>
|
||||
|
||||
/** goal.create response value. */
|
||||
export const goalCreateValueSchema = goalRefValueSchema as unknown as z.ZodType<Wire<ResponseValue<'goal.create'>>>
|
||||
|
||||
/** goal.edit request payload. */
|
||||
export const goalEditRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
ref: goalRefSchema,
|
||||
objective: z.string().min(1).optional(),
|
||||
maxGoalRounds: z.number().int().positive().optional(),
|
||||
}).refine(value => value.objective !== undefined || value.maxGoalRounds !== undefined, {
|
||||
message: 'goal.edit requires objective or maxGoalRounds',
|
||||
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.edit'>>>
|
||||
|
||||
/** goal.edit response value. */
|
||||
export const goalEditValueSchema = goalRefValueSchema as unknown as z.ZodType<Wire<ResponseValue<'goal.edit'>>>
|
||||
|
||||
/** goal.pause request payload. */
|
||||
export const goalPauseRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
ref: goalRefSchema,
|
||||
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.pause'>>>
|
||||
|
||||
/** goal.pause response value. */
|
||||
export const goalPauseValueSchema = goalRefValueSchema as unknown as z.ZodType<Wire<ResponseValue<'goal.pause'>>>
|
||||
|
||||
/** goal.resume request payload. */
|
||||
export const goalResumeRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
ref: goalRefSchema,
|
||||
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.resume'>>>
|
||||
|
||||
/** goal.resume response value. */
|
||||
export const goalResumeValueSchema = goalRefValueSchema as unknown as z.ZodType<Wire<ResponseValue<'goal.resume'>>>
|
||||
|
||||
/** goal.complete request payload. */
|
||||
export const goalCompleteRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
ref: goalRefSchema,
|
||||
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.complete'>>>
|
||||
|
||||
/** goal.complete response value. */
|
||||
export const goalCompleteValueSchema = goalRefValueSchema as unknown as z.ZodType<Wire<ResponseValue<'goal.complete'>>>
|
||||
|
||||
/** goal.clear request payload. */
|
||||
export const goalClearRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
ref: goalRefSchema,
|
||||
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.clear'>>>
|
||||
|
||||
/** goal.clear response value. */
|
||||
export const goalClearValueSchema = z.object({
|
||||
cleared: z.literal(true),
|
||||
}) as unknown as z.ZodType<Wire<ResponseValue<'goal.clear'>>>
|
||||
50
packages/host/apiproxy/src/api/goals.ts
Normal file
50
packages/host/apiproxy/src/api/goals.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* goals domain contract. Method signatures are the source of truth:
|
||||
* unary methods take the RpcRequest<P> narrow form and the impl echoes rpcId.
|
||||
*
|
||||
* Mutations only: the read side is the 'goal' session projection (history
|
||||
* tail-page projections block + session/projection frames), so there is no
|
||||
* goal.get and no wire goal view — responses acknowledge with the new CAS
|
||||
* ref and never feed client state (the committed goal/change event reaches
|
||||
* every client through the mux stream carrying the same whole value).
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/** Identifies one goal across its durable revisions. */
|
||||
export type GoalId = Branded<'GoalId'>
|
||||
|
||||
/** Compare-and-set identity for one exact goal revision. */
|
||||
export interface GoalRef {
|
||||
readonly id: GoalId
|
||||
readonly revision: number
|
||||
}
|
||||
|
||||
/** Goal-domain unary methods (every mutation resolves the session's agent and applies one CAS-guarded verb). */
|
||||
export interface GoalsApi {
|
||||
/** Create and arm a goal. */
|
||||
create(request: RpcRequest<{ sessionId: SessionId; objective: string; maxGoalRounds?: number }>):
|
||||
Promise<RpcResponse<{ ref: GoalRef }>>
|
||||
|
||||
/** Edit objective and/or round cap without changing phase. */
|
||||
edit(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef; objective?: string; maxGoalRounds?: number }>):
|
||||
Promise<RpcResponse<{ ref: GoalRef }>>
|
||||
|
||||
/** Pause an active goal and disarm automatic continuation. */
|
||||
pause(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>):
|
||||
Promise<RpcResponse<{ ref: GoalRef }>>
|
||||
|
||||
/** Resume and arm a stopped goal. */
|
||||
resume(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>):
|
||||
Promise<RpcResponse<{ ref: GoalRef }>>
|
||||
|
||||
/** Mark a current non-complete goal complete and disarm it. */
|
||||
complete(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>):
|
||||
Promise<RpcResponse<{ ref: GoalRef }>>
|
||||
|
||||
/** Clear the current goal while retaining a durable tombstone and history. */
|
||||
clear(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>):
|
||||
Promise<RpcResponse<{ cleared: true }>>
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { CommandsApi } from './commands.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { EventsApi } from './events.ts'
|
||||
import type { GoalsApi } from './goals.ts'
|
||||
import type { ClientResponse, RpcReceipt } from './rpc.ts'
|
||||
|
||||
/** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */
|
||||
@@ -20,6 +21,7 @@ export interface ApiProxy {
|
||||
commands: CommandsApi
|
||||
skills: SkillsApi
|
||||
events: EventsApi
|
||||
goals: GoalsApi
|
||||
/** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
|
||||
respond(message: ClientResponse): Promise<RpcReceipt>
|
||||
}
|
||||
@@ -35,6 +37,7 @@ export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
|
||||
export type { CommandsApi, CommandDescriptor } from './commands.ts'
|
||||
export type { SkillsApi, SkillEntry } from './skills.ts'
|
||||
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
|
||||
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
|
||||
export type { ApprovalResponsePayload } from './approvals.ts'
|
||||
export type { QuestionResponsePayload } from './questions.ts'
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { HostApi } from './host.ts'
|
||||
import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { CommandsApi } from './commands.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { GoalsApi } from './goals.ts'
|
||||
import type { RpcResponse } from './rpc.ts'
|
||||
|
||||
/**
|
||||
@@ -36,6 +37,12 @@ export interface RpcMethodMap {
|
||||
'command.list': CommandsApi['list']
|
||||
'command.execute': CommandsApi['execute']
|
||||
'skill.list': SkillsApi['list']
|
||||
'goal.create': GoalsApi['create']
|
||||
'goal.edit': GoalsApi['edit']
|
||||
'goal.pause': GoalsApi['pause']
|
||||
'goal.resume': GoalsApi['resume']
|
||||
'goal.complete': GoalsApi['complete']
|
||||
'goal.clear': GoalsApi['clear']
|
||||
}
|
||||
|
||||
/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */
|
||||
|
||||
@@ -44,6 +44,8 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }),
|
||||
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
|
||||
z.object({ code: z.literal('attachment-error'), message: z.string(), details: z.object({ reason: z.string() }) }),
|
||||
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
|
||||
]) as unknown as z.ZodType<RpcError>
|
||||
|
||||
|
||||
@@ -41,6 +41,10 @@ export interface RpcErrorDetailsMap {
|
||||
'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId }
|
||||
'agent-busy': { reason: string }
|
||||
'attachment-error': { reason: string }
|
||||
/** A known slash command reported a usage/state error; the message is the command's own text. */
|
||||
'command-error': {}
|
||||
/** A leading-/ prompt named no registered command; the message names the token. */
|
||||
'unknown-command': {}
|
||||
'internal': {}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export const sessionEventSchema = z.object({
|
||||
surfaceOp: z.unknown().optional(),
|
||||
}) as unknown as z.ZodType<SessionEvent>
|
||||
|
||||
/** SessionSummary row of session.list. */
|
||||
/** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */
|
||||
export const sessionSummarySchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
updatedAt: z.number(),
|
||||
@@ -46,7 +46,8 @@ export const sessionSummarySchema = z.object({
|
||||
blank: z.boolean(),
|
||||
parentSessionId: sessionIdSchema.optional(),
|
||||
cwd: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<SessionSummary>>
|
||||
projections: z.lazy(() => sessionProjectionsBlockSchema).optional(),
|
||||
}) as unknown as z.ZodType<Wire<SessionSummary>>
|
||||
|
||||
/** session.list request payload (cursor is a reserved seat, unimplemented in v1). */
|
||||
export const sessionListRequestSchema = z.object({
|
||||
@@ -54,9 +55,9 @@ export const sessionListRequestSchema = z.object({
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.list'>>>
|
||||
|
||||
/** session.list response value. */
|
||||
export const sessionListValueSchema = z.object({
|
||||
export const sessionListValueSchema: z.ZodType<Wire<ResponseValue<'session.list'>>> = z.object({
|
||||
items: z.array(sessionSummarySchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.list'>>>
|
||||
})
|
||||
|
||||
/** session.create request payload (at most one of workspaceId / cwd). */
|
||||
export const sessionCreateRequestSchema = z.object({
|
||||
@@ -207,9 +208,13 @@ export const sessionPromptRequestSchema = z.object({
|
||||
content: z.array(promptContentPartSchema),
|
||||
}) as unknown as z.ZodType<RequestPayload<'session.prompt'>>
|
||||
|
||||
/** session.prompt response value. */
|
||||
/** session.prompt response value (the command slot appears only when the prompt dispatched a slash command). */
|
||||
export const sessionPromptValueSchema = z.object({
|
||||
accepted: z.literal(true),
|
||||
command: z.object({
|
||||
kind: z.literal('success'),
|
||||
text: z.string().optional(),
|
||||
}).optional(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.prompt'>>>
|
||||
|
||||
/** Opaque attachment id after string-shape validation. */
|
||||
|
||||
@@ -132,17 +132,31 @@ export interface SessionSummary {
|
||||
/** Status of the attached agent; always false for cold (unattached) sessions. */
|
||||
running: boolean
|
||||
/**
|
||||
* Derived emptiness bit: true while the session log holds zero events (no
|
||||
* user message yet). Clients hide blank sessions from lists and reuse them
|
||||
* for New Session on the same workspace. Always false for cold sessions —
|
||||
* lazy persistence keeps a never-appended session out of the store, so a
|
||||
* listed cold session necessarily has events.
|
||||
* Derived conversation-not-started bit: true while no turn has run (no
|
||||
* prompt was accepted yet). Standalone plugin events — command lifecycle
|
||||
* records, plan/mode, titles, goals — do not open a turn and therefore do
|
||||
* not clear it. Clients hide blank sessions from lists and reuse them for
|
||||
* New Session on the same workspace. Always false for cold sessions —
|
||||
* lazy persistence keeps a never-appended session out of the store, and a
|
||||
* listed cold session's log holds its turns.
|
||||
*/
|
||||
blank: boolean
|
||||
/** fork/spawn lineage (session.header.parentSession passthrough); absent for root sessions. */
|
||||
parentSessionId?: SessionId
|
||||
/** Session working directory (header.cwd passthrough); absent when unrecorded. */
|
||||
cwd?: string
|
||||
/**
|
||||
* Projection baseline for this row, with zero log loads: attached sessions
|
||||
* read the registry's live watermark cut; cold sessions read the persisted
|
||||
* projection cache's stored rows — as stale as that session's last durable
|
||||
* checkpoint (`asOfSeq` says exactly how stale), never wrong, and directly
|
||||
* seedable into the client's per-session value store under its
|
||||
* higher-seq-wins rule (a list baseline can never overwrite a newer push
|
||||
* frame). Absent when no value is available (no registry, no cache row for
|
||||
* a cold session, or a fail-soft cache read miss); a listing client treats
|
||||
* absence as "no title yet", exactly like a blank session.
|
||||
*/
|
||||
projections?: SessionProjectionsBlock
|
||||
}
|
||||
|
||||
/** Browser-submitted prompt content; image bytes are promoted to durable references by the host. */
|
||||
@@ -199,9 +213,16 @@ export interface SessionsApi {
|
||||
}>):
|
||||
Promise<RpcResponse<{ selected: ModelTarget }>>
|
||||
|
||||
/** Sends text plus temporary base64 image uploads; the host persists images before calling the agent. */
|
||||
/**
|
||||
* Sends text plus temporary base64 image uploads; the host persists images before calling the agent.
|
||||
* A prompt whose content is exactly one text block starting with '/' is a slash command: the host
|
||||
* executes it through the command registry (mode-agnostic) and it is never sent to the model. A
|
||||
* successful command returns ok with the command slot (its success text, when the command produced
|
||||
* one — carried for future rendering; the state change is the feedback). A usage/state error is an
|
||||
* RPC error with code command-error; an unrecognized name is an RPC error with code unknown-command.
|
||||
*/
|
||||
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: PromptContentPart[] }>):
|
||||
Promise<RpcResponse<{ accepted: true }>>
|
||||
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
|
||||
|
||||
/** Reads one durable image after proving that this session's log references its id. */
|
||||
attachment(request: RpcRequest<{ sessionId: SessionId; attachmentId: AttachmentIdType }> ):
|
||||
|
||||
@@ -35,6 +35,14 @@ import {
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
|
||||
import { skillListValueSchema } from '../api/skills.schema.ts'
|
||||
import {
|
||||
goalCreateValueSchema,
|
||||
goalEditValueSchema,
|
||||
goalPauseValueSchema,
|
||||
goalResumeValueSchema,
|
||||
goalCompleteValueSchema,
|
||||
goalClearValueSchema,
|
||||
} from '../api/goals.schema.ts'
|
||||
|
||||
/**
|
||||
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
|
||||
@@ -85,6 +93,14 @@ export interface IApiClient {
|
||||
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
|
||||
host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>>
|
||||
}
|
||||
goals: {
|
||||
create(payload: RequestPayload<'goal.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.create'>>>
|
||||
edit(payload: RequestPayload<'goal.edit'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.edit'>>>
|
||||
pause(payload: RequestPayload<'goal.pause'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.pause'>>>
|
||||
resume(payload: RequestPayload<'goal.resume'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.resume'>>>
|
||||
complete(payload: RequestPayload<'goal.complete'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.complete'>>>
|
||||
clear(payload: RequestPayload<'goal.clear'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.clear'>>>
|
||||
}
|
||||
/** client-response passthrough (rpcId is a backfill of the server-request's id — never minted here). */
|
||||
respond(message: ClientResponse, signal?: AbortSignal): Promise<RpcReceipt>
|
||||
}
|
||||
@@ -113,6 +129,12 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'command.list': commandListValueSchema,
|
||||
'command.execute': commandExecuteValueSchema,
|
||||
'skill.list': skillListValueSchema,
|
||||
'goal.create': goalCreateValueSchema,
|
||||
'goal.edit': goalEditValueSchema,
|
||||
'goal.pause': goalPauseValueSchema,
|
||||
'goal.resume': goalResumeValueSchema,
|
||||
'goal.complete': goalCompleteValueSchema,
|
||||
'goal.clear': goalClearValueSchema,
|
||||
}
|
||||
|
||||
/** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
|
||||
@@ -333,6 +355,15 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
list: (payload, signal) => this.callUnary('skill.list', payload, signal),
|
||||
}
|
||||
|
||||
readonly goals: IApiClient['goals'] = {
|
||||
create: (payload, signal) => this.callUnary('goal.create', payload, signal),
|
||||
edit: (payload, signal) => this.callUnary('goal.edit', payload, signal),
|
||||
pause: (payload, signal) => this.callUnary('goal.pause', payload, signal),
|
||||
resume: (payload, signal) => this.callUnary('goal.resume', payload, signal),
|
||||
complete: (payload, signal) => this.callUnary('goal.complete', payload, signal),
|
||||
clear: (payload, signal) => this.callUnary('goal.clear', payload, signal),
|
||||
}
|
||||
|
||||
readonly events: IApiClient['events'] = {
|
||||
mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen),
|
||||
host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen),
|
||||
|
||||
@@ -36,6 +36,14 @@ import {
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
|
||||
import { skillListRequestSchema } from '../api/skills.schema.ts'
|
||||
import {
|
||||
goalCreateRequestSchema,
|
||||
goalEditRequestSchema,
|
||||
goalPauseRequestSchema,
|
||||
goalResumeRequestSchema,
|
||||
goalCompleteRequestSchema,
|
||||
goalClearRequestSchema,
|
||||
} from '../api/goals.schema.ts'
|
||||
|
||||
/**
|
||||
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
|
||||
@@ -73,6 +81,12 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
|
||||
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
|
||||
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
|
||||
'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) },
|
||||
'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) },
|
||||
'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) },
|
||||
'goal.resume': { schema: goalResumeRequestSchema, invoke: (api, r) => api.goals.resume(r) },
|
||||
'goal.complete': { schema: goalCompleteRequestSchema, invoke: (api, r) => api.goals.complete(r) },
|
||||
'goal.clear': { schema: goalClearRequestSchema, invoke: (api, r) => api.goals.clear(r) },
|
||||
}
|
||||
|
||||
/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */
|
||||
|
||||
@@ -57,6 +57,7 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
readonly workspace: ApiProxy['workspace']
|
||||
readonly host: ApiProxy['host']
|
||||
readonly commands: ApiProxy['commands']
|
||||
readonly goals: ApiProxy['goals']
|
||||
readonly skills: ApiProxy['skills']
|
||||
readonly events: ApiProxy['events']
|
||||
readonly respond: ApiProxy['respond']
|
||||
@@ -74,6 +75,7 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
this.workspace = api.workspace
|
||||
this.host = api.host
|
||||
this.commands = api.commands
|
||||
this.goals = api.goals
|
||||
this.skills = api.skills
|
||||
this.events = api.events
|
||||
// createApiProxy returns closures (no `this` capture); bind only satisfies
|
||||
|
||||
77
packages/host/apiproxy/tests/api-proxy-blank.spec.ts
Normal file
77
packages/host/apiproxy/tests/api-proxy-blank.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* The summary blank bit means "conversation not started" (no turn has run),
|
||||
* not "log empty": standalone plugin events — command lifecycle records,
|
||||
* plan/mode, session titles — never flip it, so running /plan or /goal on a
|
||||
* fresh session keeps it list-hidden and reusable, while the first accepted
|
||||
* prompt's turn/start clears it. The host/session-added frame shares the
|
||||
* same predicate function (covered by the workspace spec's frame assertion).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { ApiProxy, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
let nextRpc = 1
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`blank-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
|
||||
async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (session: Session) => void }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
return {
|
||||
ctx,
|
||||
api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }),
|
||||
attach: (session) => {
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Append the standalone (non-conversation) event family a fresh session can accumulate. */
|
||||
function appendStandalone(session: Session): void {
|
||||
session.append('command/run', {
|
||||
commandId: CommandId('blank-cmd-1'), name: 'plan', args: '', source: { kind: 'user' },
|
||||
})
|
||||
session.append('plan/mode', { active: true })
|
||||
session.append('command/done', { commandId: CommandId('blank-cmd-1'), kind: 'success', text: 'Plan mode on.' })
|
||||
session.append('session/title', {
|
||||
title: 'standalone title', messageSeqs: [], source: { kind: 'fallback' },
|
||||
})
|
||||
}
|
||||
|
||||
async function listBlank(api: ApiProxy, id: string): Promise<boolean | undefined> {
|
||||
const response = await api.sessions.list(request({}))
|
||||
if (!response.result.ok) throw new Error('list failed')
|
||||
return response.result.value.items.find(item => item.sessionId === id)?.blank
|
||||
}
|
||||
|
||||
describe('summary blank = conversation not started', () => {
|
||||
it('standalone events (command lifecycle, plan/mode, title) keep the session blank', async () => {
|
||||
const { ctx, api, attach } = await harness()
|
||||
const session = ctx.sessions.create()
|
||||
attach(session)
|
||||
expect(await listBlank(api, session.id)).toBe(true)
|
||||
appendStandalone(session)
|
||||
expect(await listBlank(api, session.id)).toBe(true)
|
||||
})
|
||||
|
||||
it('the first turn clears blank', async () => {
|
||||
const { ctx, api, attach } = await harness()
|
||||
const session = ctx.sessions.create()
|
||||
attach(session)
|
||||
appendStandalone(session)
|
||||
session.append('turn/start', { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(await listBlank(api, session.id)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm'
|
||||
/**
|
||||
* Command/skill RPC handlers and the two new frames over createApiProxy:
|
||||
* command.list serves the addressed agent's effective catalog (missing
|
||||
@@ -10,10 +11,10 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentMessage } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, {} from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
@@ -246,9 +247,10 @@ describe('host/commands-changed frame', () => {
|
||||
})
|
||||
|
||||
/** Build one frozen inbox message for the live `agent/inbox/*` events. */
|
||||
function inboxMessage(id: string, text: string, rpcId?: string): AgentMessage {
|
||||
return Object.freeze({
|
||||
id: AgentMessageId(id),
|
||||
function inboxMessage(id: string, text: string, rpcId?: string): UserMessage {
|
||||
return freezeMessage({
|
||||
id: MessageId(id),
|
||||
role: 'user',
|
||||
content: [{ type: 'text' as const, text }],
|
||||
source: rpcId === undefined ? { kind: 'user' as const } : { kind: 'user' as const, rpcId: RpcId(rpcId) },
|
||||
})
|
||||
@@ -271,8 +273,8 @@ describe('session/queued frames', () => {
|
||||
|
||||
const liveFrames = (await liveCollected).filter(f => f.type === 'session/queued')
|
||||
expect(liveFrames).toEqual([
|
||||
{ type: 'session/queued', sessionId: agent.id, content: queued.content, source: { kind: 'user' }, steering: false },
|
||||
{ type: 'session/queued', sessionId: agent.id, content: steering.content, source: { kind: 'user' }, steering: true },
|
||||
{ type: 'session/queued', sessionId: agent.id, message: queued, steering: false },
|
||||
{ type: 'session/queued', sessionId: agent.id, message: steering, steering: true },
|
||||
])
|
||||
|
||||
// A fresh mux connection replays the still-pending entries as its baseline.
|
||||
@@ -290,8 +292,8 @@ describe('session/queued frames', () => {
|
||||
const steering = inboxMessage('m-4', 'x', 'r-1')
|
||||
ctx.emit('agent/inbox/enqueue', agent, queued, 'queued')
|
||||
ctx.emit('agent/inbox/enqueue', agent, steering, 'steering')
|
||||
ctx.emit('agent/inbox/dequeue', agent, queued)
|
||||
ctx.emit('agent/inbox/dequeue', agent, steering)
|
||||
ctx.emit('agent/inbox/dequeue', agent, queued, 'queued')
|
||||
ctx.emit('agent/inbox/dequeue', agent, steering, 'steering')
|
||||
|
||||
const abort = new AbortController()
|
||||
const frames = await collect<MuxFrame>(
|
||||
@@ -299,6 +301,24 @@ describe('session/queued frames', () => {
|
||||
expect(frames.filter(f => f.type === 'session/queued')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('retires the matching placement when one message identity is queued and steering', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const repeated = inboxMessage('m-repeat', 'same prompt')
|
||||
ctx.emit('agent/inbox/enqueue', agent, repeated, 'queued')
|
||||
ctx.emit('agent/inbox/enqueue', agent, repeated, 'steering')
|
||||
ctx.emit('agent/inbox/dequeue', agent, inboxMessage('unknown', 'not queued'), 'queued')
|
||||
ctx.emit('agent/inbox/dequeue', agent, repeated, 'steering')
|
||||
|
||||
const abort = new AbortController()
|
||||
const frames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-repeat'), payload: {} }, abort.signal), 2, abort)
|
||||
expect(frames.filter(f => f.type === 'session/queued')).toEqual([
|
||||
{ type: 'session/queued', sessionId: agent.id, message: repeated, steering: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('retires mirror entries on a batch discard (cancel path)', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
@@ -314,6 +334,6 @@ describe('session/queued frames', () => {
|
||||
api.events.mux({ rpcId: RpcId('t-mux-swept'), payload: {} }, abort.signal), 2, abort)
|
||||
const remaining = frames.filter(f => f.type === 'session/queued')
|
||||
expect(remaining).toHaveLength(1)
|
||||
expect(remaining[0]).toMatchObject({ content: survivor.content })
|
||||
expect(remaining[0]).toMatchObject({ message: survivor })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,8 @@ import { Context } from 'cordis'
|
||||
import { z } from 'zod'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
@@ -61,7 +62,10 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session:
|
||||
/** Append `count` user messages so the log has paginable message boundaries. */
|
||||
function seedMessages(session: Session, count: number): void {
|
||||
for (let i = 0; i < count; i++) {
|
||||
session.append('user/message', { content: [{ type: 'text', text: `m${i}` }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `m${i}` }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +125,82 @@ describe('session.history projections block', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('session.list projections column', () => {
|
||||
it('serves attached rows from the live registry cut, watermarked for client seeding', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
ctx.sessionProjections.register(lastUserUnit())
|
||||
seedMessages(session, 1)
|
||||
const response = await api(ctx).sessions.list(request({}))
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const row = response.result.value.items.find(item => item.sessionId === session.id)
|
||||
expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' })
|
||||
expect(row?.projections?.asOfSeq).toBe(session.seq - 1)
|
||||
})
|
||||
|
||||
it('omits the column entirely when no registry is mounted', async () => {
|
||||
const { ctx, session } = await harness(false)
|
||||
seedMessages(session, 1)
|
||||
const response = await api(ctx).sessions.list(request({}))
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const row = response.result.value.items.find(item => item.sessionId === session.id)
|
||||
expect(row).toBeDefined()
|
||||
expect(row !== undefined && 'projections' in row).toBe(false)
|
||||
})
|
||||
|
||||
it('serves cold rows from the persisted projection cache with zero log loads', async () => {
|
||||
const { ctx } = await harness(true)
|
||||
const coldId = SessionId('session-cold-listing')
|
||||
const load = () => { throw new Error('list must not load event logs') }
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }],
|
||||
locate: () => undefined,
|
||||
load,
|
||||
inspect: load,
|
||||
readFrom: load,
|
||||
} as never)
|
||||
ctx.provide('sessionProjectionCache', {
|
||||
// The carrier hands the listed header through as the identity witness.
|
||||
cachedSnapshot: (meta: { id: unknown; createdAt: number }) =>
|
||||
(meta.id === coldId && meta.createdAt === 5
|
||||
? { asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } }
|
||||
: undefined),
|
||||
} as never)
|
||||
const response = await api(ctx).sessions.list(request({}))
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const row = response.result.value.items.find(item => item.sessionId === coldId)
|
||||
expect(row?.running).toBe(false)
|
||||
expect(row?.projections).toEqual({ asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } })
|
||||
})
|
||||
|
||||
it('cold rows without a cache plugin (or without a stored row) just lack the column', async () => {
|
||||
const { ctx } = await harness(true)
|
||||
const coldId = SessionId('session-cold-uncached')
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }],
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
const response = await api(ctx).sessions.list(request({}))
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const row = response.result.value.items.find(item => item.sessionId === coldId)
|
||||
expect(row).toBeDefined()
|
||||
expect(row !== undefined && 'projections' in row).toBe(false)
|
||||
})
|
||||
|
||||
it('a throwing column read degrades that row, never the listing', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
ctx.sessionProjections.register({
|
||||
...lastUserUnit(),
|
||||
view: () => { throw new Error('unit exploded') },
|
||||
})
|
||||
seedMessages(session, 1)
|
||||
const response = await api(ctx).sessions.list(request({}))
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const row = response.result.value.items.find(item => item.sessionId === session.id)
|
||||
expect(row).toBeDefined()
|
||||
expect(row !== undefined && 'projections' in row).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session/projection push frame', () => {
|
||||
/** Drain frames until `count` session/projection frames arrived. */
|
||||
async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: number, abort: AbortController): Promise<MuxFrame[]> {
|
||||
|
||||
@@ -14,9 +14,9 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import { CallId, createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
@@ -88,16 +88,35 @@ describe('mux live view computation', () => {
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-call-only'), name: 'call-only', arguments: '{}' })
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c-call-only'), content: [{ type: 'text', text: rawResult }], isError: false }, { surfaceOp: 'append' })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c-call-only'),
|
||||
content: [{ type: 'text', text: rawResult }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-plain'), name: 'plain', arguments: '{}' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-boom'), name: 'boom', arguments: '{}' })
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c-gen'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c-gen'),
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
const frames = await collected
|
||||
const events = frames.filter(f => f.type === 'session/event')
|
||||
const byCall = new Map(events
|
||||
.filter(f => f.event.type === 'tool/call' || f.event.type === 'tool/result')
|
||||
.map(f => [`${f.event.type}:${(f.event.data as { callId: string }).callId}`, f]))
|
||||
.map(f => [
|
||||
`${f.event.type}:${f.event.type === 'tool/call'
|
||||
? f.event.data.callId
|
||||
: (f.event.data as SessionEvent<'tool/result'>['data']).message.source.callId}`,
|
||||
f,
|
||||
]))
|
||||
|
||||
expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
|
||||
expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
|
||||
@@ -130,15 +149,44 @@ describe('mux live view computation', () => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' })
|
||||
// meta rides through to presentResult's ToolResult (the spread arm).
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-term'), content: [{ type: 'text', text: 'ok' }], isError: false, meta: { n: 1 } }, { surfaceOp: 'append' })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('h-term'),
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
}),
|
||||
meta: { n: 1 },
|
||||
}, { surfaceOp: 'append' })
|
||||
// Unpaired result: no tool/call with this id anywhere in the page.
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-orphan'), content: [{ type: 'text', text: 'x' }], isError: false }, { surfaceOp: 'append' })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('h-orphan'),
|
||||
content: [{ type: 'text', text: 'x' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
// Paired, but the call's stored arguments do not parse: backscan soft-falls.
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-bad'), name: 'term', arguments: '{broken' })
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-bad'), content: [{ type: 'text', text: 'y' }], isError: false }, { surfaceOp: 'append' })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('h-bad'),
|
||||
content: [{ type: 'text', text: 'y' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
// Presenterless tool: pairing succeeds but presentResult is absent.
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-plain'), name: 'plain', arguments: '{}' })
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-plain'), content: [{ type: 'text', text: 'z' }], isError: false }, { surfaceOp: 'append' })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('h-plain'),
|
||||
content: [{ type: 'text', text: 'z' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
const response = await api.sessions.history({ rpcId: RpcId('t-hist'), payload: { sessionId: session.id } })
|
||||
expect(response.result.ok).toBe(true)
|
||||
@@ -146,7 +194,12 @@ describe('mux live view computation', () => {
|
||||
const entries = response.result.value.events
|
||||
const byKey = new Map(entries
|
||||
.filter(entry => entry.event.type === 'tool/call' || entry.event.type === 'tool/result')
|
||||
.map(entry => [`${entry.event.type}:${(entry.event.data as { callId: string }).callId}`, entry]))
|
||||
.map(entry => [
|
||||
`${entry.event.type}:${entry.event.type === 'tool/call'
|
||||
? entry.event.data.callId
|
||||
: (entry.event.data as SessionEvent<'tool/result'>['data']).message.source.callId}`,
|
||||
entry,
|
||||
]))
|
||||
expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } })
|
||||
expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
|
||||
expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false)
|
||||
@@ -188,7 +241,14 @@ describe('mux live view computation', () => {
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// The turn/end above cleared the live table; pairing must fall back to
|
||||
// scanning the session's in-memory events.
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c-late'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c-late'),
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
const frames = await collected
|
||||
const result = frames.find(f => f.type === 'session/event' && f.event.type === 'tool/result')
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, {} from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
@@ -45,10 +45,10 @@ function stubAgent(session: Session): Agent {
|
||||
status: 'idle',
|
||||
acceptsNextStep: false,
|
||||
ctx: new Context(),
|
||||
followup: () => AgentMessageId('stub'),
|
||||
steer: () => AgentMessageId('stub'),
|
||||
inject: () => AgentMessageId('stub'),
|
||||
send: () => AgentMessageId('stub'),
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject: () => {},
|
||||
send: () => {},
|
||||
cancel() {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ApiProxy, HostFrame, MuxFrame, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import type { ApiProxy, GoalRef, HostFrame, MuxFrame, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { InProcessApiClient, RpcId, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
@@ -23,9 +23,12 @@ function scriptedApi(overrides: {
|
||||
commands?: Partial<ApiProxy['commands']>
|
||||
skills?: Partial<ApiProxy['skills']>
|
||||
events?: Partial<ApiProxy['events']>
|
||||
goals?: Partial<ApiProxy['goals']>
|
||||
respond?: ApiProxy['respond']
|
||||
} = {}): ApiProxy {
|
||||
async function *empty<F>(): AsyncGenerator<RpcRequest<F>> { /* no frames */ }
|
||||
const err = <T>(r: RpcRequest<unknown>): Promise<RpcResponse<T>> =>
|
||||
Promise.resolve({ rpcId: r.rpcId, result: { ok: false, error: { code: 'internal' as const, message: 'stub', details: {} } } })
|
||||
return {
|
||||
sessions: {
|
||||
list: r => ok(r, { items: [] }),
|
||||
@@ -70,6 +73,15 @@ function scriptedApi(overrides: {
|
||||
...overrides.commands,
|
||||
},
|
||||
skills: { list: r => ok(r, { skills: [] }), ...overrides.skills },
|
||||
goals: {
|
||||
create: err,
|
||||
edit: err,
|
||||
pause: err,
|
||||
resume: err,
|
||||
complete: err,
|
||||
clear: err,
|
||||
...overrides.goals,
|
||||
},
|
||||
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
|
||||
respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })),
|
||||
}
|
||||
@@ -409,6 +421,67 @@ describe('SSE stream path', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('goals unary surface', () => {
|
||||
const ref: GoalRef = { id: 'goal-1' as GoalRef['id'], revision: 1 }
|
||||
/** The `{ ref }` acknowledgement every non-clear mutation answers (state travels on the projection). */
|
||||
const ack = { ref: { id: 'goal-1' as GoalRef['id'], revision: 2 } }
|
||||
|
||||
it('round-trips every goal method with its own payload and value shape', async () => {
|
||||
const seen: { method: string; payload: unknown }[] = []
|
||||
const record = <P, V>(method: string, respond: (r: RpcRequest<P>) => Promise<RpcResponse<V>>) =>
|
||||
(r: RpcRequest<P>): Promise<RpcResponse<V>> => {
|
||||
seen.push({ method, payload: r.payload })
|
||||
return respond(r)
|
||||
}
|
||||
const api = scriptedApi({
|
||||
goals: {
|
||||
create: record('goal.create', r => ok(r, ack)),
|
||||
edit: record('goal.edit', r => ok(r, { ref: { ...ack.ref, revision: 3 } })),
|
||||
pause: record('goal.pause', r => ok(r, ack)),
|
||||
resume: record('goal.resume', r => ok(r, ack)),
|
||||
complete: record('goal.complete', r => ok(r, ack)),
|
||||
clear: record('goal.clear', r => ok(r, { cleared: true as const })),
|
||||
},
|
||||
})
|
||||
const c = client(api)
|
||||
|
||||
const created = await c.goals.create({ sessionId: sid('s1'), objective: 'ship it', maxGoalRounds: 4 })
|
||||
expect(created.result).toEqual({ ok: true, value: ack })
|
||||
const edited = await c.goals.edit({ sessionId: sid('s1'), ref, objective: 'ship v2' })
|
||||
expect(edited.result).toEqual({ ok: true, value: { ref: { ...ack.ref, revision: 3 } } })
|
||||
expect((await c.goals.pause({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack })
|
||||
expect((await c.goals.resume({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack })
|
||||
expect((await c.goals.complete({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack })
|
||||
const cleared = await c.goals.clear({ sessionId: sid('s1'), ref })
|
||||
expect(cleared.result).toEqual({ ok: true, value: { cleared: true } })
|
||||
|
||||
// The handler dispatched each call through its own route row: payload parsed per method.
|
||||
expect(seen.map(s => s.method)).toEqual(['goal.create', 'goal.edit', 'goal.pause', 'goal.resume', 'goal.complete', 'goal.clear'])
|
||||
expect(seen[0]?.payload).toEqual({ sessionId: 's1', objective: 'ship it', maxGoalRounds: 4 })
|
||||
expect(seen[1]?.payload).toEqual({ sessionId: 's1', ref, objective: 'ship v2' })
|
||||
})
|
||||
|
||||
it('passes business errors through as results, not throws', async () => {
|
||||
// Default scripted goals impl answers an err result: it must arrive as a result, not a throw.
|
||||
const failed = await client(scriptedApi()).goals.pause({ sessionId: sid('s1'), ref })
|
||||
expect(failed.result.ok).toBe(false)
|
||||
if (!failed.result.ok) expect(failed.result.error.code).toBe('internal')
|
||||
})
|
||||
|
||||
it('rejects an invalid goal payload at the handler as bad-request', async () => {
|
||||
const response = await client(scriptedApi()).goals.create({ sessionId: sid('s1'), objective: '' })
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (!response.result.ok) expect(response.result.error.code).toBe('bad-request')
|
||||
|
||||
let editCalls = 0
|
||||
const api = scriptedApi({ goals: { edit: (r) => { editCalls++; return ok(r, ack) } } })
|
||||
const emptyEdit = await client(api).goals.edit({ sessionId: sid('s1'), ref })
|
||||
expect(emptyEdit.result.ok).toBe(false)
|
||||
if (!emptyEdit.result.ok) expect(emptyEdit.result.error.code).toBe('bad-request')
|
||||
expect(editCalls).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('respond path', () => {
|
||||
it('round-trips a client-response to a receipt', async () => {
|
||||
const seen: unknown[] = []
|
||||
|
||||
@@ -159,6 +159,26 @@ function fakeApi(overrides: Partial<{
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } } }
|
||||
},
|
||||
},
|
||||
goals: {
|
||||
async create(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
|
||||
},
|
||||
async edit(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
|
||||
},
|
||||
async pause(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
|
||||
},
|
||||
async resume(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
|
||||
},
|
||||
async complete(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
|
||||
},
|
||||
async clear(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
|
||||
},
|
||||
},
|
||||
events: {
|
||||
mux: (_request, signal) => stream(muxFrames, signal),
|
||||
host: (_request, signal) => stream(hostFrames, signal),
|
||||
|
||||
@@ -29,6 +29,7 @@ import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '
|
||||
import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
|
||||
import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
|
||||
import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
|
||||
import { goalEditRequestSchema } from '../src/api/goals.schema.ts'
|
||||
|
||||
describe('RpcId', () => {
|
||||
it('brands a raw string at zero runtime cost', () => {
|
||||
@@ -65,11 +66,14 @@ describe('rpcErrorSchema', () => {
|
||||
}).code).toBe('model-unavailable')
|
||||
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
|
||||
expect(rpcErrorSchema.parse({ code: 'attachment-error', message: 'm', details: { reason: 'r' } }).code).toBe('attachment-error')
|
||||
expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error')
|
||||
expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command')
|
||||
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
|
||||
})
|
||||
|
||||
it('rejects a known code with missing details', () => {
|
||||
expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow()
|
||||
expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow()
|
||||
expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -121,9 +125,19 @@ describe('sessions domain schemas', () => {
|
||||
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: true, blank: false, parentSessionId: 'p', cwd: '/x' }).cwd).toBe('/x')
|
||||
// blank is mandatory: a summary without it fails the parse.
|
||||
expect(() => sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false })).toThrow()
|
||||
const event = sessionEventSchema.parse({ type: 'user/message', seq: 0, time: 1, data: { any: true } })
|
||||
const event = sessionEventSchema.parse({
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { any: true },
|
||||
})
|
||||
expect(event).toMatchObject({ type: 'user/message' })
|
||||
expect(() => sessionEventSchema.parse({ type: 'user/message', seq: -1, time: 1, data: {} })).toThrow()
|
||||
expect(() => sessionEventSchema.parse({
|
||||
type: 'user/message',
|
||||
seq: -1,
|
||||
time: 1,
|
||||
data: {},
|
||||
})).toThrow()
|
||||
})
|
||||
|
||||
it('validates the per-method request/value pairs', () => {
|
||||
@@ -197,6 +211,11 @@ describe('sessions domain schemas', () => {
|
||||
expect(prompt.mode).toBe('queue')
|
||||
expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow()
|
||||
expect(sessionPromptValueSchema.parse({ accepted: true }).accepted).toBe(true)
|
||||
// The command slot appears only when the prompt dispatched a slash command.
|
||||
const dispatched = sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'success', text: 'Goal set' } })
|
||||
expect(dispatched.command?.text).toBe('Goal set')
|
||||
expect(sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'success' } }).command).toEqual({ kind: 'success' })
|
||||
expect(() => sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'failure' } })).toThrow()
|
||||
expect(sessionCancelRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true)
|
||||
expect(promptContentPartSchema.parse({ type: 'text', text: 'x', extra: 1 })).toEqual({ type: 'text', text: 'x' })
|
||||
@@ -338,6 +357,15 @@ describe('skills domain schemas', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('goals domain schemas', () => {
|
||||
it('requires at least one replacement field for goal.edit', () => {
|
||||
const ref = { id: 'g1', revision: 1 }
|
||||
expect(goalEditRequestSchema.parse({ sessionId: 's1', ref, objective: 'updated' }).objective).toBe('updated')
|
||||
expect(goalEditRequestSchema.parse({ sessionId: 's1', ref, maxGoalRounds: 3 }).maxGoalRounds).toBe(3)
|
||||
expect(() => goalEditRequestSchema.parse({ sessionId: 's1', ref })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('events frame schemas', () => {
|
||||
it('accepts every mux frame branch', () => {
|
||||
const frames = [
|
||||
@@ -347,8 +375,8 @@ describe('events frame schemas', () => {
|
||||
{ type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' },
|
||||
{ type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
|
||||
{ type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' },
|
||||
{ type: 'session/queued', sessionId: 's', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' }, steering: false },
|
||||
{ type: 'session/queued', sessionId: 's', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' }, steering: true },
|
||||
{ type: 'session/queued', sessionId: 's', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } }, steering: false },
|
||||
{ type: 'session/queued', sessionId: 's', message: { id: 'm2', role: 'user', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' } }, steering: true },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
]
|
||||
@@ -367,9 +395,9 @@ describe('events frame schemas', () => {
|
||||
})
|
||||
|
||||
it('rejects a queued frame missing its members', () => {
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: 'x', source: { kind: 'user' } })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: [], source: { kind: 'user' } })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: [], source: {}, steering: false })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: 'x', steering: false })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: { kind: 'user' } } })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: {} }, steering: false })).toThrow()
|
||||
})
|
||||
|
||||
it('accepts every host frame branch', () => {
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
@@ -41,6 +44,9 @@
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection-cache"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/skill"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user