Merge remote-tracking branch 'origin/master' into worktree/web-plugin-config
# Conflicts: # docs/event-producer-consumer.i18n.yaml # docs/event-producer-consumer.md # docs/event-producer-consumer.zh.md # docs/module-graph.i18n.yaml # docs/module-graph.md # docs/module-graph.zh.md # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.md # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/package.json # pnpm-lock.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/client/runtime/README.md
|
||||
README.md: 452f82f9c626fae5b2c2efc913eb9865fe593c3a
|
||||
README.zh.md: abde13ea66fe807a0e5b85c5eb4ef8efcbeb093c
|
||||
README.md: 1ec6cc38aed1bebff6b6ecb40faee7ae3ba9e412
|
||||
README.zh.md: 6602152790a1d433371e27b274a4eb8c9e3cfcd8
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
|
||||
|
||||
`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`session/preset-changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
|
||||
`bindSettingsScope` 面向单个由领域持有的 namespace,是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、revision、可写性、host/内存模式),使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API,远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有,而非把产品政策放入运行时。
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
* must stub); runtime-internal entry points (history staging, wire-frame
|
||||
* dispatch) stay on the class, invisible out here.
|
||||
*/
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
MessageId, QueueAction, RpcResult, SessionId,
|
||||
MessageId, PromptContentPart, QueueAction, RpcResult, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConversationSnapshot } from '../sessions/conversation.ts'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
@@ -33,11 +33,19 @@ export interface ISession {
|
||||
readonly projections: ProjectionsFace
|
||||
/**
|
||||
* Send a prompt into the session.
|
||||
* @param content - model-facing content blocks.
|
||||
* @param content - text plus browser-owned temporary image uploads.
|
||||
* @param mode - 'queue' appends a turn; 'steer' interrupts the running one.
|
||||
* @returns acceptance, or the business error (also mirrored into snapshot.promptError).
|
||||
*/
|
||||
prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
|
||||
prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
|
||||
/**
|
||||
* Resolve one durable image referenced by this session.
|
||||
* @param attachmentId - opaque id found in the folded session log.
|
||||
* @returns the authenticated reference and decoded bytes.
|
||||
*/
|
||||
readAttachment(
|
||||
attachmentId: AttachmentIdType,
|
||||
): Promise<RpcResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>>
|
||||
/**
|
||||
* Apply one edit, remove, or strict steer action to a still-pending queue occurrence.
|
||||
* @param itemId - agent-owned inbox occurrence identity.
|
||||
|
||||
@@ -183,6 +183,18 @@ declare module 'cordis' {
|
||||
* @mode emit
|
||||
*/
|
||||
'models/changed'(): void
|
||||
/**
|
||||
* One session's agent preset changed (host/session-preset-changed
|
||||
* passthrough), so everything its composition decides — the command
|
||||
* catalog, the skill catalog — is stale for that session and no other.
|
||||
* Every connected client observes it, not only the one that issued the
|
||||
* switch. Subscribers refetch their own session-keyed caches; the frame
|
||||
* carries no catalog.
|
||||
* @mode emit
|
||||
* @param sessionId - the session whose composition changed.
|
||||
* @param agentPreset - the preset it now runs.
|
||||
*/
|
||||
'session/preset-changed'(sessionId: SessionId, agentPreset: string): void
|
||||
/**
|
||||
* A connection generation was (re-)established. Wire-derived caches must
|
||||
* treat their state as stale and repull (commands directory; the queue
|
||||
@@ -246,6 +258,9 @@ export function apply(ctx: Context): void {
|
||||
// and model surfaces) subscribe on ctx.
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'host/commands-changed') ctx.emit('commands/changed')
|
||||
else if (frame.type === 'host/session-preset-changed') {
|
||||
ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset)
|
||||
}
|
||||
else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns)
|
||||
else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref)
|
||||
else if (frame.type === 'host/models-changed') ctx.emit('models/changed')
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
@@ -43,6 +44,7 @@ export interface AssistantProvenanceView {
|
||||
export type AssistantBlock =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'reasoning'; text: string }
|
||||
| { kind: 'image'; attachment: ImageAttachmentRef }
|
||||
| { kind: 'tool-call'; callId: string; name: string; argsRaw: string }
|
||||
| { kind: 'other'; block: unknown }
|
||||
|
||||
@@ -64,6 +66,7 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock {
|
||||
switch (block.type) {
|
||||
case 'text': return { kind: 'text', text: block.text }
|
||||
case 'reasoning': return { kind: 'reasoning', text: block.text }
|
||||
case 'image': return { kind: 'image', attachment: block.attachment }
|
||||
case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments }
|
||||
default: return { kind: 'other', block }
|
||||
}
|
||||
|
||||
@@ -780,6 +780,14 @@ export class SessionManager {
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'host/session-preset-changed': {
|
||||
// Every connected client observes the switch here; only the tab that
|
||||
// issued it also gets the RPC echo. The merge keeps the row's own
|
||||
// updatedAt and lowers `blank` only, so re-applying the switching
|
||||
// tab's own frame is a no-op.
|
||||
this.noteAgentPreset(frame.sessionId, frame.agentPreset)
|
||||
return
|
||||
}
|
||||
case 'host/session-removed': {
|
||||
const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId)
|
||||
const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId)
|
||||
@@ -1005,7 +1013,7 @@ export class SessionManager {
|
||||
const prev = this.entryCache.get(entry.sessionId)
|
||||
if (
|
||||
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
|
||||
&& prev.blank === entry.blank
|
||||
&& prev.blank === entry.blank && prev.agentPreset === entry.agentPreset
|
||||
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
|
||||
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
|
||||
&& prev.pendingInteraction === entry.pendingInteraction
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Sessions remain resident after creation so they continue consuming mux frames off-screen.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError,
|
||||
HistoryEntry, IApiClient, MessageId, MuxFrame, PromptContentPart, QueueAction, RpcError,
|
||||
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
@@ -178,11 +178,11 @@ export class Session implements SessionFace {
|
||||
|
||||
/**
|
||||
* Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
|
||||
* @param content - core content blocks verbatim.
|
||||
* @param content - text plus browser-owned temporary image uploads.
|
||||
* @param mode - queue appends after the current turn; steer interrupts it.
|
||||
* @returns the prompt result (also mirrored into promptError on failure).
|
||||
*/
|
||||
async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> {
|
||||
async prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> {
|
||||
this.promptError = null
|
||||
this.lastAgentError = null
|
||||
// Synchronous, before the first await: the blank → engaging edge must be
|
||||
@@ -205,8 +205,24 @@ export class Session implements SessionFace {
|
||||
},
|
||||
}
|
||||
} else {
|
||||
const routed = (await this.api.subagents.prompt({ ...this.address, content })).result
|
||||
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
|
||||
if (content.some(part => part.type === 'image')) {
|
||||
result = {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'attachment-error',
|
||||
message: 'Image input is unavailable for subagent continuations.',
|
||||
details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' },
|
||||
},
|
||||
}
|
||||
} else {
|
||||
const routed = (await this.api.subagents.prompt({
|
||||
...this.address,
|
||||
content: content.flatMap(part => part.type === 'text'
|
||||
? [{ type: 'text' as const, text: part.text }]
|
||||
: []),
|
||||
})).result
|
||||
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
result = transportError(error)
|
||||
@@ -232,6 +248,28 @@ export class Session implements SessionFace {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one image referenced by this session into browser-consumable bytes.
|
||||
* @param attachmentId - opaque id found in the folded session log.
|
||||
* @returns the authenticated reference and decoded bytes.
|
||||
*/
|
||||
async readAttachment(
|
||||
attachmentId: AttachmentIdType,
|
||||
): Promise<RpcResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>> {
|
||||
try {
|
||||
const result = (await this.api.sessions.attachment({
|
||||
sessionId: this.sessionId,
|
||||
attachmentId,
|
||||
})).result
|
||||
if (!result.ok) return result
|
||||
const binary = atob(result.value.data)
|
||||
const data = Uint8Array.from(binary, char => char.charCodeAt(0))
|
||||
return { ok: true, value: { attachment: result.value.attachment, data } }
|
||||
} catch (error) {
|
||||
return transportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply one operation to a still-pending queue occurrence. */
|
||||
async updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{ accepted: true }>> {
|
||||
try {
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
/** Assistant block classifier (moved here with sessions/conversation.ts). */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts'
|
||||
|
||||
describe('toAssistantBlock', () => {
|
||||
it('classifies the four block shapes', () => {
|
||||
const attachment = {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png' as const,
|
||||
bytes: 68,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}
|
||||
const blocks: ContentBlock[] = [
|
||||
{ type: 'text', text: '正文' },
|
||||
{ type: 'reasoning', text: '思考' },
|
||||
{ type: 'tool-call', id: 'c1', name: 'echo', arguments: '{}' } as ContentBlock,
|
||||
{ type: 'image', data: 'x' } as unknown as ContentBlock,
|
||||
{ type: 'image', attachment },
|
||||
]
|
||||
expect(toAssistantBlocks(blocks)).toEqual([
|
||||
{ kind: 'text', text: '正文' },
|
||||
{ kind: 'reasoning', text: '思考' },
|
||||
{ kind: 'tool-call', callId: 'c1', name: 'echo', argsRaw: '{}' },
|
||||
{ kind: 'other', block: blocks[3] },
|
||||
{ kind: 'image', attachment },
|
||||
])
|
||||
expect(toAssistantBlock(blocks[0] as ContentBlock)).toEqual({ kind: 'text', text: '正文' })
|
||||
})
|
||||
|
||||
@@ -85,6 +85,8 @@ export class FakeApiClient implements IApiClient {
|
||||
Promise<RpcResponse<{ selected: ModelSelection }>> =
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onAttachment: (payload: unknown) => Promise<RpcResponse<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
|
||||
() => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
|
||||
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
|
||||
@@ -129,6 +131,7 @@ export class FakeApiClient implements IApiClient {
|
||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
||||
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
|
||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||
attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)),
|
||||
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
}
|
||||
|
||||
@@ -531,6 +531,21 @@ describe('prompt and cancel errors', () => {
|
||||
expect(result.ok).toBe(false)
|
||||
expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'internal' } })
|
||||
})
|
||||
|
||||
it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const result = await session.readAttachment('attachment-1' as never)
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
|
||||
data: Uint8Array.of(0),
|
||||
},
|
||||
})
|
||||
expect(api.callsOf('session.attachment')).toEqual([{
|
||||
sessionId: SID, attachmentId: 'attachment-1',
|
||||
}])
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename', () => {
|
||||
|
||||
@@ -35,6 +35,7 @@ type FeedRow = {
|
||||
origin?: 'subagent'
|
||||
running?: boolean
|
||||
blank?: boolean
|
||||
agentPreset?: string
|
||||
}
|
||||
|
||||
async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
|
||||
@@ -44,6 +45,7 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
|
||||
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
||||
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
|
||||
...(r.origin !== undefined ? { origin: r.origin } : {}),
|
||||
...(r.agentPreset !== undefined ? { agentPreset: r.agentPreset } : {}),
|
||||
})),
|
||||
}) as never)
|
||||
await b.svc.refresh()
|
||||
@@ -70,6 +72,38 @@ describe('list store projection', () => {
|
||||
expect(state.byId[sid('s2')]?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reprojects a blank session whose composition switched and nothing else moved', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('standard')
|
||||
|
||||
// A confirmed switch moves the preset alone: the row keeps its updatedAt,
|
||||
// title, running, and blank bits, so an identity guard blind to the preset
|
||||
// would serve the old row forever — and every reader (the hero chip's own
|
||||
// no-op check, the header label) would keep the composition it replaced.
|
||||
b.svc.noteAgentPreset(sid('s1'), 'minimal')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
|
||||
})
|
||||
|
||||
it('learns a preset switch from the host frame, not only from the tab that issued it', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
|
||||
|
||||
// Every connected client gets this frame; only the switching tab gets the
|
||||
// RPC echo. A client that ignored the payload would keep labelling the
|
||||
// session with the composition it replaced.
|
||||
b.svc.handleHostEnvelope({
|
||||
rpcId: 'r1' as never,
|
||||
payload: { type: 'host/session-preset-changed', sessionId: sid('s1'), agentPreset: 'minimal' } as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.blank).toBe(true)
|
||||
})
|
||||
|
||||
it('reflects live increments (host stream via manager) into the store', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Wire-to-typed-event bridge: host/commands-changed
|
||||
* → ctx 'commands/changed'; each established connection generation →
|
||||
* → ctx 'commands/changed'; host/session-preset-changed →
|
||||
* ctx 'session/preset-changed'; each established connection generation →
|
||||
* ctx 'connection/reset' (the forced cache-invalidation broadcast).
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
@@ -67,6 +68,17 @@ describe('wire event bridge', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('broadcasts session/preset-changed with the recomposed session and its new preset', async () => {
|
||||
const bench = await mount()
|
||||
const seen: Array<[string, string]> = []
|
||||
bench.ctx.on('session/preset-changed', (sessionId, agentPreset) => { seen.push([sessionId, agentPreset]) })
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r1' as never,
|
||||
payload: { type: 'host/session-preset-changed', sessionId: 's1' as never, agentPreset: 'minimal' },
|
||||
})
|
||||
expect(seen).toEqual([['s1', 'minimal']])
|
||||
})
|
||||
|
||||
it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {
|
||||
const bench = await mount()
|
||||
let resets = 0
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../attachment/attachment"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user