Merge branch 'worktree/schedule-conversational-after' into worktree/schedule-explicit-at
This commit is contained in:
@@ -9,14 +9,14 @@ import { dirname } from 'node:path'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets/types'
|
||||
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { isAppendSurfaceEvent, isJsonValue, lastActivityTime } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue, Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
|
||||
import { SubagentError } from '@deepseek-ai/dsh-subagent'
|
||||
@@ -96,6 +96,7 @@ import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
|
||||
import {
|
||||
ApiRemoteSessionNotFound as SessionNotFound,
|
||||
ApiRemoteSubagentSessionOwnership as SubagentSessionOwnership,
|
||||
API_REMOTE_FORWARDED_EVENTS,
|
||||
apiRemoteSubagentOwnershipError,
|
||||
createApiRemoteAgentResolver,
|
||||
hasApiRemoteSubagentOwner,
|
||||
@@ -433,6 +434,29 @@ function frame<F>(payload: F): RpcRequest<F> {
|
||||
return { rpcId: RpcId(randomUUID()), payload }
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow one allowlisted host event's argument list to the JSON values the
|
||||
* wrapper frame carries. A rejected argument is an allowlist mistake (the
|
||||
* forwarded path applies no projection), not hostile input, so it throws rather
|
||||
* than degrading to a lossy frame. The throw surfaces where the forwarding
|
||||
* listener runs, so the emitter's own listener containment logs it and drops
|
||||
* that frame — loud in the Host log, not at load or at the emit. Exported for
|
||||
* the test that owns this decision: every currently allowlisted event has a
|
||||
* statically JSON-safe payload, so a type-legal `ctx.emit` cannot reach the
|
||||
* rejection branch.
|
||||
* @param event - forwarded host event name, named in the failure.
|
||||
* @param args - the emitter's argument list.
|
||||
* @returns the same arguments typed as JSON values.
|
||||
*/
|
||||
export function assertJsonArgs(event: string, args: readonly unknown[]): JsonValue[] {
|
||||
for (const [index, arg] of args.entries()) {
|
||||
if (!isJsonValue(arg)) {
|
||||
throw new Error(`forwarded host event "${event}" argument ${index} is not lossless JSON data`)
|
||||
}
|
||||
}
|
||||
return args as JsonValue[]
|
||||
}
|
||||
|
||||
/** Queue the subscription baseline frame. */
|
||||
function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Session): void {
|
||||
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
|
||||
@@ -3488,44 +3512,23 @@ 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' }))
|
||||
}),
|
||||
// The recompose itself registers nothing (it re-parents the agent's
|
||||
// scope onto a standing mount that may already exist), so the
|
||||
// logged selection is the only commit point a client can follow.
|
||||
ctx.on('session/event', (session: Session, event: SessionEvent) => {
|
||||
if (event.type !== 'agent-preset/selected') return
|
||||
queue.push(frame({
|
||||
type: 'host/session-preset-changed',
|
||||
sessionId: session.id,
|
||||
agentPreset: event.data.agentPreset,
|
||||
}))
|
||||
}),
|
||||
ctx.on('settings/document-updated', (ns) => {
|
||||
// The RAW-section event, not the resolved one: a field going from
|
||||
// inherited to overridden leaves the resolved value equal, and a
|
||||
// configuration client still has to re-read (its held revision is
|
||||
// stale, and the field's meaning changed).
|
||||
const name = String(ns)
|
||||
queue.push(frame({ type: 'host/settings-changed', ns: name }))
|
||||
// A provider's own settings carry its model catalog and endpoint,
|
||||
// so a change there invalidates the model list even when the route
|
||||
// set is untouched — `llm/adapters-updated` alone misses it. The
|
||||
// Agent default section is the other such source: it names the
|
||||
// selection every session with no logged one resolves to, so an
|
||||
// externally edited default (another tab, a hand-edited
|
||||
// settings.yaml) has to reach an open selector too.
|
||||
if (modelProviderNamespaces().has(name) || name === String(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE)) {
|
||||
queue.push(frame({ type: 'host/models-changed' }))
|
||||
}
|
||||
}),
|
||||
ctx.on('credentials/updated', (ref) => {
|
||||
queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) }))
|
||||
}),
|
||||
ctx.on('llm/adapters-updated', () => {
|
||||
queue.push(frame({ type: 'host/models-changed' }))
|
||||
}),
|
||||
// Allowlisted host events ride one verbatim wrapper frame each. The
|
||||
// allowlist is api-remotes', and `ctx.remote.$on` is the consumer
|
||||
// face; nothing here projects, redacts, or renames.
|
||||
...API_REMOTE_FORWARDED_EVENTS.map(name => ctx.on(
|
||||
name,
|
||||
// The allowlist's shape assertion proves each name is a real,
|
||||
// non-scoped, void-returning event, so the rest-parameter handler
|
||||
// satisfies every member of the union `on` accepts here;
|
||||
// assertJsonArgs proves the payload is JSON-safe before it queues.
|
||||
((...args: unknown[]) => {
|
||||
queue.push(frame({
|
||||
type: 'host/remote-event',
|
||||
event: name,
|
||||
args: assertJsonArgs(name, args),
|
||||
}))
|
||||
}),
|
||||
)),
|
||||
]
|
||||
return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
|
||||
},
|
||||
|
||||
@@ -83,10 +83,10 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }),
|
||||
z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }),
|
||||
z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }),
|
||||
z.object({ type: z.literal('host/commands-changed') }),
|
||||
z.object({ type: z.literal('host/session-preset-changed'), sessionId: sessionIdSchema, agentPreset: z.string() }),
|
||||
z.object({ type: z.literal('host/settings-changed'), ns: z.string() }),
|
||||
z.object({ type: z.literal('host/credentials-changed'), ref: z.string() }),
|
||||
z.object({ type: z.literal('host/models-changed') }),
|
||||
// args stays wide, the same posture as session/projection's value: the frame
|
||||
// arrives from JSON.parse, so every element is already a JSON value, and the
|
||||
// structural contract belongs to the owner package's cordis `Events`
|
||||
// declaration — the host validated JSON-safety before forwarding.
|
||||
z.object({ type: z.literal('host/remote-event'), event: z.string().min(1), args: z.array(z.unknown()) }),
|
||||
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
|
||||
]) as unknown as z.ZodType<HostFrame>
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-a
|
||||
import type { Message } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { JsonValue, SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
import type { RpcError, RpcId, RpcRequest } from './rpc.ts'
|
||||
import type { TaskView } from './tasks.ts'
|
||||
@@ -140,40 +140,14 @@ export type HostFrame =
|
||||
| { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] }
|
||||
| { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] }
|
||||
/**
|
||||
* The command registry changed (`commands/change` passthrough). Pure
|
||||
* invalidation signal, no payload: clients refetch `command.list` in the
|
||||
* background rather than diffing.
|
||||
* One allowlisted host cordis event forwarded verbatim. The allowlist is
|
||||
* owned by `@deepseek-ai/dsh-api-remotes` (`API_REMOTE_FORWARDED_EVENTS`),
|
||||
* which is also the only control point over what a consumer can receive.
|
||||
* `event` is the host's own event name and `args` its argument list: this
|
||||
* path applies no projection, no redaction, and no renaming, so the payload
|
||||
* contract is the owner package's cordis `Events` declaration rather than
|
||||
* anything stated here. Delivery lands on `ctx.remote.$on`, not on a
|
||||
* per-event frame variant.
|
||||
*/
|
||||
| { type: 'host/commands-changed' }
|
||||
/**
|
||||
* One blank session was recomposed onto another agent preset (the logged
|
||||
* `agent-preset/selected` commit point, read off the session stream). The
|
||||
* registry-wide `host/commands-changed` cannot stand in for it: recomposing
|
||||
* re-parents that agent's scope without registering anything, so a
|
||||
* preset already mounted for another session produces no registry change
|
||||
* at all. Clients refetch the catalogs this session's composition decides
|
||||
* (`command.list`, `skill.list`) for this sessionId alone, and fold the
|
||||
* preset id into their session row — the RPC echo reaches only the client
|
||||
* that issued the switch, so the row is where every other one learns it.
|
||||
*/
|
||||
| { type: 'host/session-preset-changed'; sessionId: SessionId; agentPreset: string }
|
||||
/**
|
||||
* One settings namespace's resolved value changed (`settings/updated`
|
||||
* passthrough) — an RPC write, an external `settings.yaml` edit, or a
|
||||
* provider reload all converge here. Clients refetch `settings.describe`;
|
||||
* values never ride the frame (they would need redaction and can go stale).
|
||||
*/
|
||||
| { type: 'host/settings-changed'; ns: string }
|
||||
/**
|
||||
* One credential reference's state changed (`credentials/updated`
|
||||
* passthrough): a set/unset over this wire or an external `.env` edit.
|
||||
* The ref is an environment-variable NAME — never a value.
|
||||
*/
|
||||
| { type: 'host/credentials-changed'; ref: string }
|
||||
/**
|
||||
* The provider topology changed (`llm/adapters-updated` passthrough):
|
||||
* routes registered or dropped, or the configurable directory moved. Pure
|
||||
* invalidation: clients refetch `llm.providers`/`llm.models`/`session.models`.
|
||||
*/
|
||||
| { type: 'host/models-changed' }
|
||||
| { type: 'host/remote-event'; event: string; args: JsonValue[] }
|
||||
| { type: 'stream/error'; error: RpcError }
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* (which providers CAN be configured, and where their settings live) with the
|
||||
* live route registry; `llm.models` is the session-independent model catalog
|
||||
* (the same groups as `session.models`, without a per-session selection).
|
||||
* Both invalidate on the `host/models-changed` frame.
|
||||
* Clients invalidate from the forwarded `llm/adapters-updated` and
|
||||
* `settings/document-updated` owner events.
|
||||
*/
|
||||
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
Reference in New Issue
Block a user